diff --git a/XIAO_ESP32S3_MIDI_Stepper/.gitignore b/XIAO_ESP32S3_MIDI_Stepper/.gitignore new file mode 100644 index 0000000..214ccd4 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/.gitignore @@ -0,0 +1,11 @@ +.pio/ +build/ +dist/ +__pycache__/ +*.pyc +*.bin +*.elf +*.hex +*.map +.vscode/ +.DS_Store diff --git a/XIAO_ESP32S3_MIDI_Stepper/README.md b/XIAO_ESP32S3_MIDI_Stepper/README.md new file mode 100644 index 0000000..869b4c1 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/README.md @@ -0,0 +1,65 @@ +# XIAO ESP32-S3 MIDI Stepper Player + +Standalone project for a Seeed XIAO ESP32-S3-based MIDI stepper controller. +This repository is separate from the Gauge workspace and is designed to use a host MIDI bridge and serial-controlled stepper drivers. + +## Features +- XIAO ESP32-S3 PlatformIO project using `AccelStepper` +- Three independent stepper outputs with a shared enable pin +- Host-side MIDI parsing with serial commands +- Compatible with the BarlowTJ48 MIDI stepper example flow + +## Getting started +1. Open this folder in VS Code. +2. Build with PlatformIO. +3. Flash the XIAO ESP32-S3 with the firmware in `src/main.cpp`. +4. Install Python dependencies: `pip install -r requirements.txt`. +5. Run `python midi_interface.py COMx` (replace `COMx` with your XIAO serial port). +6. Select a MIDI input port and send notes to channels 0, 1, and 2. + +## Wiring +- Connect each stepper driver STEP/DIR pins to the GPIO pins defined in `src/main.cpp`. +- Connect the shared stepper driver enable pin to `ENABLE_PIN`. +- Power the stepper drivers from an external supply (e.g. 12V), not from the XIAO USB. + +## Notes +- MIDI is parsed on the host PC and sent to the XIAO over USB serial. +- The firmware accepts commands of the form `s,,`, `e,`, and `d`. +- This project is configured for 3 motors, matching the XIAO's available GPIO and your hardware setup. + +## Packaging as a single Windows EXE (PyInstaller) + +1. Install Python requirements in the environment you'll run PyInstaller from: + +```powershell +python -m pip install -r requirements.txt +``` + +2. Build a one-file EXE using PyInstaller (console output): + +```powershell +pyinstaller --onefile --console launcher.py +``` + +3. To embed an icon, place an ICO at `assets/app.ico` and either: + +```powershell +pyinstaller --onefile --icon=assets/app.ico launcher.py +``` + +or use the provided spec file: + +```powershell +pyinstaller launcher.spec +``` + +4. The built executable will be at `dist\launcher.exe`. Run it with the COM port: + +```powershell +dist\launcher.exe COM3 +``` + +Notes: +- Use `--noconsole` to hide the console window (not recommended for debugging). +- If PyInstaller misses imports (e.g., `rtmidi`), add `--hidden-import=rtmidi` or edit `launcher.spec`. +- Test the EXE on the target Windows machine where the XIAO is connected. diff --git a/XIAO_ESP32S3_MIDI_Stepper/XIAO_ESP32S3_MIDI_Stepper/example_repo b/XIAO_ESP32S3_MIDI_Stepper/XIAO_ESP32S3_MIDI_Stepper/example_repo new file mode 160000 index 0000000..021ba0e --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/XIAO_ESP32S3_MIDI_Stepper/example_repo @@ -0,0 +1 @@ +Subproject commit 021ba0ee32a4f8101e92716dce6a4c5749eaf742 diff --git a/XIAO_ESP32S3_MIDI_Stepper/assets/README.txt b/XIAO_ESP32S3_MIDI_Stepper/assets/README.txt new file mode 100644 index 0000000..54ee12e --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/assets/README.txt @@ -0,0 +1,13 @@ +Place a Windows ICO file here named `app.ico` if you want an embedded icon in the EXE. + +Recommended size: include multiple sizes in the ICO (16x16, 32x32, 48x48, 256x256) for best results. + +Example usage with PyInstaller (from project root): + +pyinstaller --onefile --icon=assets/app.ico launcher.py + +Or use the provided spec file: + +pyinstaller launcher.spec + +Note: The spec references `assets/app.ico` as the icon path. diff --git a/XIAO_ESP32S3_MIDI_Stepper/docs/wiring.md b/XIAO_ESP32S3_MIDI_Stepper/docs/wiring.md new file mode 100644 index 0000000..08067d5 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/docs/wiring.md @@ -0,0 +1,84 @@ +# Wiring (text-only) + +This project uses a Seeed XIAO ESP32-S3 as the controller, three stepper motor drivers, and an external motor power supply. + +XIAO -> Stepper driver signal wiring (default pins used in `src/main.cpp`): + +- GPIO2 -> STEP0 +- GPIO5 -> DIR0 +- GPIO3 -> STEP1 +- GPIO6 -> DIR1 +- GPIO4 -> STEP2 +- GPIO7 -> DIR2 +- GPIO8 -> ENABLE (shared enable for drivers) + +Important notes and safety: + +- Connect all stepper driver GND pins to the XIAO GND. The drivers and XIAO must share a common ground. +- Power the stepper drivers from an external motor supply (for example 12V). Do NOT power the motors from the XIAO USB. +- Keep motor power wiring separate from logic wiring as much as possible; use short, thick wires for motor supply and twisted pairs where appropriate. +- Before connecting motors to drivers, verify coil pair polarity using a multimeter or the paperclip polarity test; reversed coils will reduce torque or cause poor operation. +- Set current limit on your driver (A4988/DRV8825 or similar) appropriately for your motors before running at full speed. + +MIDI and host-side bridge (recommended flow): + +- The XIAO runs a serial command firmware (see `src/main.cpp`). MIDI is parsed on the host PC by `midi_interface.py`. +- Connect the XIAO to the PC over USB. Run the Python bridge: + +```bash +pip install -r requirements.txt +python midi_interface.py COMx +``` + +- Select the desired MIDI input port when prompted and play notes on MIDI channels 0, 1 and 2 (they map to motors 0..2). + +Troubleshooting quick tips: + +- If motors do not move, verify the shared `ENABLE` pin state and that each driver's `STEP`/`DIR` pins are connected to the pins listed above. +- If movement sounds wrong, re-check motor coil polarity and driver current limit. +- Use the serial monitor at `115200` to view status messages from the firmware. + +If you want the wiring document converted to a printable PDF or a separate schematic SVG later, say so and I will add that. +# Wiring Diagrams + +This project uses a Seeed XIAO ESP32-S3 as the controller, three stepper motor drivers, and an external power supply for the motors. + +## XIAO to Stepper Driver Wiring + +- `GPIO2` -> `STEP0` +- `GPIO5` -> `DIR0` +- `GPIO3` -> `STEP1` +- `GPIO6` -> `DIR1` +- `GPIO4` -> `STEP2` +- `GPIO7` -> `DIR2` +- `GPIO8` -> `ENABLE` + +### Notes +- All stepper driver grounds must be connected to the XIAO ground. +- Motor power must come from the stepper driver power supply, not the XIAO USB. +- Use separate wiring for motor power and logic power when possible. + +## Example Diagram + +![XIAO Stepper Wiring](images/xiao_stepper_wiring.svg) + +## Reference Images + +The following real-device reference photos are pulled from the example MIDI stepper project and show the type of stepper enable wiring and motor driver setup used in similar builds. + +![Stepper driver enable jumper](images/a_stepper_enable.png) +*Example: stepper driver enable jumper configuration.* + +![Stepper motor polarity test](images/polarity_test.jpg) +*Example: verifying stepper coil polarity before connecting to the driver.* + +![Driver and motor layout](images/motor_5_configuration.png) +*Example: stepper driver board and motor wiring layout.* + +## MIDI / Serial Bridge Wiring + +For this project, MIDI is interpreted on the host PC and sent to the XIAO via USB serial. + +- XIAO USB -> PC USB +- Host runs `midi_interface.py` +- MIDI input port selected from your DAW or MIDI device diff --git a/XIAO_ESP32S3_MIDI_Stepper/launcher.py b/XIAO_ESP32S3_MIDI_Stepper/launcher.py new file mode 100644 index 0000000..11decdd --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/launcher.py @@ -0,0 +1,47 @@ +import argparse +import subprocess +import sys + +def install_requirements(): + subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt']) + + +def main(): + parser = argparse.ArgumentParser(description='Launch MIDI -> Stepper bridge') + parser.add_argument('comport', nargs='?', help='XIAO COM port (e.g. COM3)') + parser.add_argument('--pitch', action='store_true', help='Enable pitch bending') + parser.add_argument('--install', action='store_true', help='Install Python requirements before running') + args = parser.parse_args() + + if args.install: + print('Installing requirements...') + try: + install_requirements() + except subprocess.CalledProcessError: + print('Failed to install requirements. Exiting.') + sys.exit(1) + + if not args.comport: + args.comport = input('Enter XIAO COM port (e.g. COM3): ').strip() + if not args.comport: + print('No COM port provided, exiting.') + sys.exit(1) + + # Import here so packaging picks up the module + try: + import midi_interface + except Exception as e: + print('Failed to import midi_interface:', e) + print('Make sure this script is run from the project folder or that the package is built correctly.') + sys.exit(1) + + argv = [args.comport] + if args.pitch: + argv.append('pitch_bending') + + # Call the main function from midi_interface + ret = midi_interface.main(argv) + sys.exit(ret) + +if __name__ == '__main__': + main() diff --git a/XIAO_ESP32S3_MIDI_Stepper/launcher.spec b/XIAO_ESP32S3_MIDI_Stepper/launcher.spec new file mode 100644 index 0000000..76b1068 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/launcher.spec @@ -0,0 +1,35 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis(['launcher.py'], + pathex=['.'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='launcher', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + icon='assets/app.ico') + +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name='launcher') diff --git a/XIAO_ESP32S3_MIDI_Stepper/midi_interface.py b/XIAO_ESP32S3_MIDI_Stepper/midi_interface.py new file mode 100644 index 0000000..9364fb5 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/midi_interface.py @@ -0,0 +1,148 @@ +try: + import inquirer + import mido + import serial +except ImportError: + print("Please install the required libraries using pip:") + import time + import re + import sys + + + A4_KEY = 69 + A4_FREQ = 440 + + + def main(argv=None): + try: + import inquirer + import mido + import serial + except ImportError: + print("Please install the required libraries using pip:") + print("pip install -r requirements.txt") + return 1 + + if argv is None: + argv = sys.argv[1:] + + baud_rate = 115200 + motor_channels = 0 + + if len(argv) > 0: + arduino_port = argv[0] + else: + print("Please specify the port that the Arduino is connected to.") + print("For example:\npython midi_interface.py COM3") + return 1 + + pitch_bending = False + if len(argv) > 1 and argv[1] == "pitch_bending": + pitch_bending = True + print("Pitch bending enabled.") + + try: + ser = serial.Serial(arduino_port, baud_rate, timeout=1) + except serial.SerialException: + print("Serial connection failed. Please check your port and try again.") + return 1 + + time.sleep(3) + start_time = time.time() + while motor_channels == 0 and time.time() - start_time < 5: + try: + channels_str = str(ser.readline().decode(errors='ignore')) + channel_match = re.search(r'motors: (\d+)', channels_str) + if channel_match is not None: + motor_channels = int(channel_match.group(1)) + ser.write(b'ack\n') + print(f"Script connected to Arduino with {motor_channels} motors.") + except Exception: + pass + + if motor_channels == 0: + print("Failed to connect to Arduino. Please check your port and try again.") + return 1 + + questions = [ + inquirer.List("port", message="Choose MIDI port", choices=mido.get_input_names()), + ] + answers = inquirer.prompt(questions) + if answers is None or "port" not in answers: + print("No MIDI port selected.") + return 1 + + inport = mido.open_input(name=answers["port"]) + last_midi_activity_time = time.time() + channel_outputting = [False] * motor_channels + motors_enabled = True + + def note_to_frequency(note): + freq = A4_FREQ * 2 ** ((note - A4_KEY) / 12) + return freq + + def send_buffer_to_arduino(msg_buffer): + nonlocal channel_outputting, motors_enabled, last_midi_activity_time + serial_data = b'' + for msg in msg_buffer: + if msg['channel'] > motor_channels - 1: + continue + if channel_outputting[msg['channel']] and msg['type'] == 'note_on': + serial_data += f'e,{msg["channel"]}\n'.encode() + serial_data += f's,{msg["channel"]},{msg["freq"]}\n'.encode() + else: + if msg['type'] == 'note_on': + if not motors_enabled: + motors_enabled = True + channel_outputting[msg['channel']] = True + serial_data += f's,{msg["channel"]},{msg["freq"]}\n'.encode() + elif msg['type'] == 'note_off': + channel_outputting[msg['channel']] = False + serial_data += f'e,{msg["channel"]}\n'.encode() + try: + last_midi_activity_time = time.time() + ser.write(serial_data) + except Exception: + print("Serial Write Failure. Was the device unplugged?") + return 1 + + def disable_motors(): + nonlocal motors_enabled, channel_outputting + if not motors_enabled: + return + if any(channel_outputting): + return + print("5 second input timeout. Temporarily disabling motors.") + motors_enabled = False + try: + ser.write(b'd\n') + except Exception: + print("Serial Write Failure. Was the device unplugged?") + return 1 + + try: + print("Listening for MIDI input...") + while True: + msg_buffer = [] + for msg in inport.iter_pending(): + motor_index = msg.channel + if motor_index > motor_channels - 1: + continue + if msg.type == 'note_on': + frequency = note_to_frequency(msg.note) + msg_buffer.append({'type': 'note_on', 'freq': frequency, 'channel': motor_index}) + elif msg.type == 'note_off': + msg_buffer.append({'type': 'note_off', 'channel': motor_index}) + if msg_buffer: + send_buffer_to_arduino(msg_buffer) + if time.time() - last_midi_activity_time > 5: + disable_motors() + finally: + inport.close() + ser.close() + print("Ports closed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/XIAO_ESP32S3_MIDI_Stepper/platformio.ini b/XIAO_ESP32S3_MIDI_Stepper/platformio.ini new file mode 100644 index 0000000..d757a34 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/platformio.ini @@ -0,0 +1,11 @@ +[env:xiao_esp32s3] +platform = espressif32 +board = seeed_xiao_esp32s3 +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 +lib_deps = + AccelStepper + +[platformio] +description = XIAO ESP32-S3 MIDI Stepper Player diff --git a/XIAO_ESP32S3_MIDI_Stepper/requirements.txt b/XIAO_ESP32S3_MIDI_Stepper/requirements.txt new file mode 100644 index 0000000..2c9dddd --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/requirements.txt @@ -0,0 +1,4 @@ +mido +pyserial +inquirer +python-rtmidi diff --git a/XIAO_ESP32S3_MIDI_Stepper/run_midi_player.bat b/XIAO_ESP32S3_MIDI_Stepper/run_midi_player.bat new file mode 100644 index 0000000..e296c05 --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/run_midi_player.bat @@ -0,0 +1,48 @@ +@echo off +setlocal + +:: Ensure script runs from project folder +cd /d "%~dp0" + +echo Checking for Python... +where python >nul 2>&1 +if errorlevel 1 ( + echo Python not found. Please install Python 3 and add it to PATH. + pause + exit /b 1 +) + +echo Installing/upgrading pip and required Python packages (may take a minute)... +python -m pip install --upgrade pip +python -m pip install -r "requirements.txt" +if errorlevel 1 ( + echo Failed to install Python dependencies. If you have a virtualenv, activate it and re-run this script. + pause +) + +echo. +echo Ensure loopMIDI (or your MIDI port provider) is running before starting. +echo. +set /p COMPORT=Enter XIAO COM port (for example COM3): +if "%COMPORT%"=="" ( + echo No COM port provided. Exiting. + pause + exit /b 1 +) + +set /p PITCH=Enable pitch bending? (y/N): +if /I "%PITCH%"=="y" ( + set "PB=pitch_bending" +) else ( + set "PB=" +) + +echo Starting MIDI bridge connecting to %COMPORT%... + +REM Launch Python bridge. It will prompt to choose a MIDI input port. +python midi_interface.py "%COMPORT%" %PB% + +echo. +echo MIDI bridge exited. Press any key to close. +pause >nul +endlocal diff --git a/XIAO_ESP32S3_MIDI_Stepper/src/main.cpp b/XIAO_ESP32S3_MIDI_Stepper/src/main.cpp new file mode 100644 index 0000000..b8209ec --- /dev/null +++ b/XIAO_ESP32S3_MIDI_Stepper/src/main.cpp @@ -0,0 +1,95 @@ +#include + +#define MOTOR_COUNT 3 +#define ENABLE_PIN 8 + +#define STEP_0_PIN 2 +#define DIR_0_PIN 5 + +#define STEP_1_PIN 3 +#define DIR_1_PIN 6 + +#define STEP_2_PIN 4 +#define DIR_2_PIN 7 + +AccelStepper stepper0(AccelStepper::DRIVER, STEP_0_PIN, DIR_0_PIN); +AccelStepper stepper1(AccelStepper::DRIVER, STEP_1_PIN, DIR_1_PIN); +AccelStepper stepper2(AccelStepper::DRIVER, STEP_2_PIN, DIR_2_PIN); + +AccelStepper* motors[MOTOR_COUNT] = {&stepper0, &stepper1, &stepper2}; +bool motorsRunning[MOTOR_COUNT] = {false, false, false}; +bool motorNumberAcknowledged = false; + +void setup() { + pinMode(ENABLE_PIN, OUTPUT); + digitalWrite(ENABLE_PIN, HIGH); + + Serial.begin(115200); + delay(100); + Serial.println("XIAO ESP32-S3 MIDI Stepper Player starting..."); + + for (int i = 0; i < MOTOR_COUNT; ++i) { + motors[i]->setMaxSpeed(4000); + motors[i]->setAcceleration(1500); + } +} + +void handleSerialCommand(const String& command) { + if (command.startsWith("s,")) { + // Format: s,, + int motorIndex = command.substring(2, 3).toInt(); + float speed = command.substring(4).toFloat(); + if (motorIndex < 0 || motorIndex >= MOTOR_COUNT) { + return; + } + digitalWrite(ENABLE_PIN, LOW); + motors[motorIndex]->setSpeed(speed); + motors[motorIndex]->runSpeed(); + motorsRunning[motorIndex] = true; + } else if (command.startsWith("e,")) { + int motorIndex = command.substring(2).toInt(); + if (motorIndex < 0 || motorIndex >= MOTOR_COUNT) { + return; + } + motors[motorIndex]->stop(); + motorsRunning[motorIndex] = false; + } else if (command == "d") { + digitalWrite(ENABLE_PIN, HIGH); + for (int i = 0; i < MOTOR_COUNT; ++i) { + motorsRunning[i] = false; + } + } +} + +void loop() { + if (!motorNumberAcknowledged) { + Serial.println("motors: " + String(MOTOR_COUNT)); + while (Serial.available()) { + String command = Serial.readStringUntil('\n'); + command.trim(); + command.toLowerCase(); + if (command == "ack") { + motorNumberAcknowledged = true; + } + } + delay(200); + return; + } + + while (Serial.available()) { + String command = Serial.readStringUntil('\n'); + command.trim(); + command.toLowerCase(); + handleSerialCommand(command); + } + + for (int i = 0; i < MOTOR_COUNT; ++i) { + if (motorsRunning[i]) { + if (motors[i]->isRunning()) { + motors[i]->runSpeed(); + } else { + motorsRunning[i] = false; + } + } + } +}