diff --git a/.gitignore b/.gitignore
index 5e9f987..18989bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,7 +35,7 @@
# Toolchain
gcc-*
-# Build artefacts
+# Build artifacts
kernel*
MiniDexed*
sdcard
@@ -46,7 +46,10 @@ sdcard
*.swp
*.swo
-CMSIS_5/
-Synth_Dexed/
-circle-stdlib/
+# git submodules
+CMSIS_5
+circle-stdlib
+Synth_Dexed
+
+# Build directories
minidexed_*
\ No newline at end of file
diff --git a/miditest.py b/miditest.py
new file mode 100644
index 0000000..b5fefaf
--- /dev/null
+++ b/miditest.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Send MIDI data to a MIDI device using the `python-rtmidi` library.
+
+try:
+ import rtmidi
+except ImportError:
+ print("Please install the python-rtmidi library: pip install python-rtmidi")
+ exit(1)
+import time
+import sys
+import argparse
+import os
+import signal
+import atexit
+
+def signal_handler(sig, frame):
+ print("\nExiting...")
+ sys.exit(0)
+signal.signal(signal.SIGINT, signal_handler)
+
+def cleanup_all_notes_off():
+ try:
+ midiout = rtmidi.MidiOut()
+ ports = midiout.get_ports()
+ if ports:
+ midiout.open_port(0)
+ all_notes_off(midiout, verbose=True)
+ time.sleep(0.5)
+ midiout.close_port()
+ except Exception as e:
+ print(f"Cleanup error: {e}")
+
+atexit.register(cleanup_all_notes_off)
+
+# Ask the user which MIDI port to use
+def get_midi_port(midiout):
+ print("Available MIDI output ports:")
+ ports = midiout.get_ports()
+ for i, port in enumerate(ports):
+ print(f"{i}: {port}")
+ while True:
+ try:
+ choice = int(input("Select a port by number: "))
+ if 0 <= choice < len(ports):
+ return choice
+ else:
+ print("Invalid choice. Please select a valid port number.")
+ except ValueError:
+ print("Invalid input. Please enter a number.")
+
+def play_chord(midiout, notes, velocity=100, channel=0, delay=0.25, verbose=True):
+ print("Playing chord:", notes)
+ for note in notes:
+ midiout.send_message([0x90 | channel, note, velocity])
+ time.sleep(delay)
+ time.sleep(0.5)
+ for note in notes:
+ midiout.send_message([0x80 | channel, note, 0])
+
+def send_sysex(midiout, msg, verbose=True, label=None):
+ midiout.send_message(msg)
+ if verbose:
+ if label:
+ print(f"Sent: {label} {msg}")
+ else:
+ print(f"Sent: {msg}")
+
+def all_notes_off(midiout, verbose=True):
+ # Send All Notes Off (CC 123) and individual Note Off for all notes 0-127 on all 16 MIDI channels
+ for channel in range(16):
+ midiout.send_message([0xB0 | channel, 120, 0])
+ if verbose:
+ print(f"Sent: All Sound Off (CC120) on channel {channel+1}")
+
+def send_modwheel(midiout, value, channel=0, verbose=True):
+ # Modulation wheel is CC 1
+ midiout.send_message([0xB0 | channel, 1, value])
+ if verbose:
+ print(f"Sent: ModWheel CC1 value {value} on channel {channel+1}")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Send MIDI messages to a device.")
+ parser.add_argument("-p", "--port", type=str, help="MIDI output port name or number")
+ # Always verbose, so remove the verbose switch and set verbose to True
+ args = parser.parse_args()
+ verbose = True
+
+ midiout = rtmidi.MidiOut()
+ ports = midiout.get_ports()
+
+ if args.port is not None:
+ # Allow port to be specified by number or name
+ try:
+ port_index = int(args.port)
+ except ValueError:
+ # Try to find by name
+ port_index = next((i for i, p in enumerate(ports) if args.port in p), None)
+ if port_index is None:
+ print(f"Port '{args.port}' not found.")
+ sys.exit(1)
+ else:
+ port_index = get_midi_port(midiout)
+
+ if verbose:
+ print(f"Using MIDI output port: {ports[port_index]}")
+
+ try:
+ midiout.open_port(port_index)
+ chord_notes = [60, 64, 67]
+ # Send all notes off before starting the test program
+ all_notes_off(midiout, verbose=verbose)
+ while True:
+ send_sysex(midiout, [0xF0, 0x43, 0x10, 0x04, 0x05, 0x00, 0xF7], verbose, "Portamento Time 0")
+ play_chord(midiout, chord_notes, verbose=verbose)
+ send_sysex(midiout, [0xF0, 0x43, 0x10, 0x04, 0x05, 0x02, 0xF7], verbose, "Portamento Time 2")
+ time.sleep(1)
+
+ # Test Detune
+ detune_msgs = [
+ ([0xF0, 0x43, 0x10, 0x04, 0x40, 0x2F, 0xF7], "Detune TG1"),
+ ([0xF0, 0x43, 0x11, 0x04, 0x40, 0x37, 0xF7], "Detune TG2"),
+ ([0xF0, 0x43, 0x12, 0x04, 0x40, 0x40, 0xF7], "Detune TG3"),
+ ([0xF0, 0x43, 0x13, 0x04, 0x40, 0x48, 0xF7], "Detune TG4"),
+ ]
+ for msg, label in detune_msgs:
+ send_sysex(midiout, msg, verbose, label)
+ play_chord(midiout, chord_notes, verbose=verbose)
+ for ch in range(0x10, 0x14):
+ send_sysex(midiout, [0xF0, 0x43, ch, 0x04, 0x40, 0x40, 0xF7], verbose, f"Detune TG{ch-0x0F} reset")
+ time.sleep(1)
+
+ # Test Poly and Mono modes
+ send_sysex(midiout, [0xF0, 0x43, 0x13, 0x04, 0x02, 0x00, 0xF7], verbose, "TG4 Poly")
+ play_chord(midiout, chord_notes, verbose=verbose)
+ send_sysex(midiout, [0xF0, 0x43, 0x13, 0x04, 0x02, 0x01, 0xF7], verbose, "TG4 Mono")
+ time.sleep(1)
+
+ # Test ModWheel sensitivity
+ send_sysex(midiout, [0xF0, 0x43, 0x13, 0x04, 0x09, 0x01, 0xF7], verbose, "TG4 ModWheel Sensitivity ON")
+ send_modwheel(midiout, 127, channel=0, verbose=verbose)
+ play_chord(midiout, chord_notes, verbose=verbose)
+ send_modwheel(midiout, 0, channel=0, verbose=verbose)
+ send_sysex(midiout, [0xF0, 0x43, 0x13, 0x04, 0x09, 0x00, 0xF7], verbose, "TG4 ModWheel Sensitivity OFF")
+ send_modwheel(midiout, 127, channel=0, verbose=verbose)
+ play_chord(midiout, chord_notes, verbose=verbose)
+ send_modwheel(midiout, 0, channel=0, verbose=verbose)
+ time.sleep(1)
+
+ # Disable all Tone Generators except TG1
+ send_sysex(midiout, [0xF0, 0x43, 0x13, 0x04, 0x08, 0x00, 0xF7], verbose, "Disable TG2, TG3, TG4")
+ play_chord(midiout, chord_notes, verbose=verbose)
+ # Enable all Tone Generators
+ send_sysex(midiout, [0xF0, 0x43, 0x13, 0x04, 0x08, 0x01, 0xF7], verbose, "Enable TG2, TG3, TG4")
+ play_chord(midiout, chord_notes, verbose=verbose)
+ time.sleep(1)
+
+ # Send all notes off after each test program loop
+ all_notes_off(midiout, verbose=verbose)
+ except Exception as e:
+ print(f"Error: {e}")
+ sys.exit(1)
\ No newline at end of file
diff --git a/syslogserver.py b/syslogserver.py
index c07c1ea..feffaa6 100644
--- a/syslogserver.py
+++ b/syslogserver.py
@@ -48,7 +48,10 @@ class SyslogServer:
print(f"{relative_time} {message}")
def wait_for_input(self):
- input("Press any key to exit...")
+ try:
+ input("Press any key to exit...")
+ except EOFError:
+ pass
self.running = False
if __name__ == "__main__":
diff --git a/updater.py b/updater.py
index 7ee0960..33a9b0c 100644
--- a/updater.py
+++ b/updater.py
@@ -85,21 +85,85 @@ if __name__ == "__main__":
args = parser.parse_args()
import time
+ # Check for local kernel*.img files
+ local_kernel_dir = os.path.join(os.path.dirname(__file__), 'src')
+ local_kernel_imgs = [f for f in os.listdir(local_kernel_dir) if f.startswith('kernel') and f.endswith('.img')]
+ has_local_build = len(local_kernel_imgs) > 0
+
# Ask user which release to download (numbered choices)
release_options = [
("Latest official release", "https://github.com/probonopd/MiniDexed/releases/expanded_assets/latest"),
("Continuous (experimental) build", "https://github.com/probonopd/MiniDexed/releases/expanded_assets/continuous")
]
- print("Which release do you want to download?")
+ if has_local_build:
+ release_options.append(("Local build (from src/)", None))
+ print("Which release do you want to update?")
for idx, (desc, _) in enumerate(release_options):
print(f" [{idx+1}] {desc}")
while True:
choice = input(f"Enter the number of your choice (1-{len(release_options)}): ").strip()
if choice.isdigit() and 1 <= int(choice) <= len(release_options):
- github_url = release_options[int(choice)-1][1]
+ selected_idx = int(choice)-1
+ github_url = release_options[selected_idx][1]
break
print("Invalid selection. Please enter a valid number.")
+ # If local build is selected, skip all GitHub/zip logic and do not register cleanup
+ use_local_build = has_local_build and selected_idx == len(release_options)-1
+ if use_local_build:
+ # Remove cleanup function if registered
+ atexit.unregister(cleanup_temp_files)
+ print("Using local build: src/kernel*.img will be uploaded.")
+ extract_path = None
+ else:
+ # Use the selected GitHub URL for release
+ def get_release_url(github_url):
+ print(f"Fetching release page: {github_url}")
+ response = requests.get(github_url)
+ print(f"HTTP status code: {response.status_code}")
+ if response.status_code == 200:
+ print("Successfully fetched release page. Scanning for MiniDexed*.zip links...")
+ # Find all tags with a MiniDexed*.zip
+ pattern = re.compile(r']+href=["\']([^"\']+\.zip)["\'][^>]*>\s*]*class=["\']Truncate-text text-bold["\'][^>]*>(MiniDexed[^<]*?\.zip)', re.IGNORECASE)
+ matches = pattern.findall(response.text)
+ print(f"Found {len(matches)} candidate .zip links.")
+ for href, filename in matches:
+ print(f"Examining link: href={href}, filename={filename}")
+ if filename.startswith("MiniDexed") and filename.endswith(".zip"):
+ if href.startswith('http'):
+ print(f"Selected direct link: {href}")
+ return href
+ else:
+ full_url = f"https://github.com{href}"
+ print(f"Selected relative link, full URL: {full_url}")
+ return full_url
+ print("No valid MiniDexed*.zip link found.")
+ else:
+ print(f"Failed to fetch release page. Status code: {response.status_code}")
+ return None
+
+ latest_release_url = get_release_url(github_url)
+ if latest_release_url:
+ print(f"Release URL: {latest_release_url}")
+ zip_path = download_latest_release(latest_release_url)
+ if zip_path:
+ print(f"Downloaded to: {zip_path}")
+ extract_path = extract_zip(zip_path)
+ print(f"Extracted to: {extract_path}")
+ else:
+ print("Failed to download the release.")
+ sys.exit(1)
+ else:
+ print("Failed to get the release URL.")
+ sys.exit(1)
+
+ # Ask user if they want to update Performances (default no)
+ if not use_local_build:
+ update_perf = input("Do you want to update the Performances? This will OVERWRITE all existing performances. [y/N]: ").strip().lower()
+ update_performances = update_perf == 'y'
+ else:
+ update_performances = False
+
# Using mDNS to find the IP address of the device(s) that advertise the FTP service "_ftp._tcp."
ip_addresses = []
device_names = []
@@ -127,51 +191,6 @@ if __name__ == "__main__":
zeroconf.close()
print("Devices found:", list(zip(device_names, ip_addresses)))
- # Use the selected GitHub URL for release
- def get_release_url(github_url):
- print(f"Fetching release page: {github_url}")
- response = requests.get(github_url)
- print(f"HTTP status code: {response.status_code}")
- if response.status_code == 200:
- print("Successfully fetched release page. Scanning for MiniDexed*.zip links...")
- # Find all tags with a MiniDexed*.zip
- pattern = re.compile(r']+href=["\']([^"\']+\.zip)["\'][^>]*>\s*]*class=["\']Truncate-text text-bold["\'][^>]*>(MiniDexed[^<]*?\.zip)', re.IGNORECASE)
- matches = pattern.findall(response.text)
- print(f"Found {len(matches)} candidate .zip links.")
- for href, filename in matches:
- print(f"Examining link: href={href}, filename={filename}")
- if filename.startswith("MiniDexed") and filename.endswith(".zip"):
- if href.startswith('http'):
- print(f"Selected direct link: {href}")
- return href
- else:
- full_url = f"https://github.com{href}"
- print(f"Selected relative link, full URL: {full_url}")
- return full_url
- print("No valid MiniDexed*.zip link found.")
- else:
- print(f"Failed to fetch release page. Status code: {response.status_code}")
- return None
-
- latest_release_url = get_release_url(github_url)
- if latest_release_url:
- print(f"Release URL: {latest_release_url}")
- zip_path = download_latest_release(latest_release_url)
- if zip_path:
- print(f"Downloaded to: {zip_path}")
- extract_path = extract_zip(zip_path)
- print(f"Extracted to: {extract_path}")
- else:
- print("Failed to download the release.")
- sys.exit(1)
- else:
- print("Failed to get the release URL.")
- sys.exit(1)
-
- # Ask user if they want to update Performances (default no)
- update_perf = input("Do you want to update the Performances? This will OVERWRITE all existing performances. [y/N]: ").strip().lower()
- update_performances = update_perf == 'y'
-
# Log into the selected device and upload the new version of MiniDexed
print(f"Connecting to {selected_name} ({selected_ip})...")
try:
@@ -183,7 +202,7 @@ if __name__ == "__main__":
ftp.set_pasv(True)
print(f"Connected to {selected_ip} (passive mode).")
# --- Performances update logic ---
- if update_performances:
+ if update_performances and not use_local_build:
print("Updating Performance: recursively deleting and uploading /SD/performance directory...")
def ftp_rmdirs(ftp, path):
try:
@@ -248,32 +267,58 @@ if __name__ == "__main__":
else:
print("No extracted performance.ini found, skipping upload.")
# Upload kernel files
- for root, dirs, files in os.walk(extract_path):
- for file in files:
- if file.startswith("kernel") and file.endswith(".img"):
- local_path = os.path.join(root, file)
- remote_path = f"/SD/{file}"
- # Check if file exists on FTP server
+ if use_local_build:
+ for file in local_kernel_imgs:
+ local_path = os.path.join(local_kernel_dir, file)
+ remote_path = f"/SD/{file}"
+ # Check if file exists on FTP server
+ file_exists = False
+ try:
+ ftp.cwd("/SD")
+ if file in ftp.nlst():
+ file_exists = True
+ except Exception as e:
+ print(f"Error checking for {file} on FTP server: {e}")
file_exists = False
- try:
- ftp.cwd("/SD")
- if file in ftp.nlst():
- file_exists = True
- except Exception as e:
- print(f"Error checking for {file} on FTP server: {e}")
+ if not file_exists:
+ print(f"Skipping {file}: does not exist on device.")
+ continue
+ filesize = os.path.getsize(local_path)
+ uploaded = [0]
+ def progress_callback(data):
+ uploaded[0] += len(data)
+ percent = uploaded[0] * 100 // filesize
+ print(f"\rUploading {file}: {percent}%", end="", flush=True)
+ with open(local_path, 'rb') as f:
+ ftp.storbinary(f'STOR {remote_path}', f, 8192, callback=progress_callback)
+ print(f"\nUploaded {file} to {selected_ip}.")
+ else:
+ for root, dirs, files in os.walk(extract_path):
+ for file in files:
+ if file.startswith("kernel") and file.endswith(".img"):
+ local_path = os.path.join(root, file)
+ remote_path = f"/SD/{file}"
+ # Check if file exists on FTP server
file_exists = False
- if not file_exists:
- print(f"Skipping {file}: does not exist on device.")
- continue
- filesize = os.path.getsize(local_path)
- uploaded = [0]
- def progress_callback(data):
- uploaded[0] += len(data)
- percent = uploaded[0] * 100 // filesize
- print(f"\rUploading {file}: {percent}%", end="", flush=True)
- with open(local_path, 'rb') as f:
- ftp.storbinary(f'STOR {remote_path}', f, 8192, callback=progress_callback)
- print(f"\nUploaded {file} to {selected_ip}.")
+ try:
+ ftp.cwd("/SD")
+ if file in ftp.nlst():
+ file_exists = True
+ except Exception as e:
+ print(f"Error checking for {file} on FTP server: {e}")
+ file_exists = False
+ if not file_exists:
+ print(f"Skipping {file}: does not exist on device.")
+ continue
+ filesize = os.path.getsize(local_path)
+ uploaded = [0]
+ def progress_callback(data):
+ uploaded[0] += len(data)
+ percent = uploaded[0] * 100 // filesize
+ print(f"\rUploading {file}: {percent}%", end="", flush=True)
+ with open(local_path, 'rb') as f:
+ ftp.storbinary(f'STOR {remote_path}', f, 8192, callback=progress_callback)
+ print(f"\nUploaded {file} to {selected_ip}.")
ftp.sendcmd("BYE")
print(f"Disconnected from {selected_ip}.")
except ftplib.all_errors as e: