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/README.md b/README.md index 25ee4d2..cf33d17 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ This project stands on the shoulders of giants. Special thanks to: - [diyelectromusic](https://github.com/diyelectromusic/) for many [contributions](https://github.com/probonopd/MiniDexed/commits?author=diyelectromusic) - [dwhinham/mt32-pi](https://github.com/dwhinham/mt32-pi) for creating networking support for Circle - [omersiar](https://github.com/omersiar) for porting networking support to MiniDexed +- [soyersoyer](https://github.com/soyersoyer) for sound and other improvements, and for debugging ## Stargazers over time [![Stargazers over time](https://starchart.cc/probonopd/MiniDexed.svg?variant=adaptive)](https://starchart.cc/probonopd/MiniDexed) 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/src/midi.h b/src/midi.h index e106a3a..0e50f19 100644 --- a/src/midi.h +++ b/src/midi.h @@ -45,6 +45,10 @@ #define MIDI_CC_DETUNE_LEVEL 94 #define MIDI_CC_ALL_SOUND_OFF 120 #define MIDI_CC_ALL_NOTES_OFF 123 +#define MIDI_CC_OMNI_MODE_OFF 124 +#define MIDI_CC_OMNI_MODE_ON 125 +#define MIDI_CC_MONO_MODE_ON 126 +#define MIDI_CC_POLY_MODE_ON 127 #define MIDI_PROGRAM_CHANGE 0b1100 #define MIDI_PITCH_BEND 0b1110 diff --git a/src/mididevice.cpp b/src/mididevice.cpp index 55743b1..6a621ab 100644 --- a/src/mididevice.cpp +++ b/src/mididevice.cpp @@ -32,7 +32,6 @@ LOGMODULE ("mididevice"); - // MIDI "System" level (i.e. all TG) custom CC maps // Note: Even if number of TGs is not 8, there are only 8 // available to be used in the mappings here. @@ -63,6 +62,7 @@ CMIDIDevice::CMIDIDevice (CMiniDexed *pSynthesizer, CConfig *pConfig, CUserInter for (unsigned nTG = 0; nTG < CConfig::AllToneGenerators; nTG++) { m_ChannelMap[nTG] = Disabled; + m_PreviousChannelMap[nTG] = Disabled; // Initialize previous channel map } m_nMIDISystemCCVol = m_pConfig->GetMIDISystemCCVol(); @@ -111,6 +111,12 @@ CMIDIDevice::~CMIDIDevice (void) void CMIDIDevice::SetChannel (u8 ucChannel, unsigned nTG) { assert (nTG < CConfig::AllToneGenerators); + + // When changing to OMNI mode, store the previous channel + if (ucChannel == OmniMode && m_ChannelMap[nTG] != OmniMode) { + m_PreviousChannelMap[nTG] = m_ChannelMap[nTG]; + } + m_ChannelMap[nTG] = ucChannel; } @@ -575,6 +581,35 @@ void CMIDIDevice::MIDIMessageHandler (const u8 *pMessage, size_t nLength, unsign } break; + case MIDI_CC_OMNI_MODE_OFF: + // Sets to "Omni Off" mode + if (m_ChannelMap[nTG] == OmniMode) { + // Restore the previous channel if available, otherwise use current channel + u8 channelToRestore = (m_PreviousChannelMap[nTG] != Disabled) ? + m_PreviousChannelMap[nTG] : ucChannel; + m_pSynthesizer->SetMIDIChannel(channelToRestore, nTG); + LOGDBG("Omni Mode Off: TG %d restored to MIDI channel %d", nTG, channelToRestore+1); + } + break; + + case MIDI_CC_OMNI_MODE_ON: + // Sets to "Omni On" mode + m_pSynthesizer->SetMIDIChannel(OmniMode, nTG); + LOGDBG("Omni Mode On: TG %d set to OMNI", nTG); + break; + + case MIDI_CC_MONO_MODE_ON: + // Sets monophonic mode + m_pSynthesizer->setMonoMode(1, nTG); + LOGDBG("Mono Mode On: TG %d set to MONO", nTG); + break; + + case MIDI_CC_POLY_MODE_ON: + // Sets polyphonic mode + m_pSynthesizer->setMonoMode(0, nTG); + LOGDBG("Poly Mode On: TG %d set to POLY", nTG); + break; + default: // Check for system-level, cross-TG MIDI Controls, but only do it once. // Also, if successfully handled, then no need to process other TGs, diff --git a/src/mididevice.h b/src/mididevice.h index a8eae40..94b789b 100644 --- a/src/mididevice.h +++ b/src/mididevice.h @@ -70,6 +70,7 @@ private: CUserInterface *m_pUI; u8 m_ChannelMap[CConfig::AllToneGenerators]; + u8 m_PreviousChannelMap[CConfig::AllToneGenerators]; // Store previous channels for OMNI OFF restore unsigned m_nMIDISystemCCVol; unsigned m_nMIDISystemCCPan; diff --git a/src/minidexed.h b/src/minidexed.h index b1622d0..1658482 100644 --- a/src/minidexed.h +++ b/src/minidexed.h @@ -239,6 +239,7 @@ public: bool DoSavePerformance (void); void setMasterVolume (float32_t vol); + int GetMasterVolume127() const { return (int)(nMasterVolume >= 1.0f ? 127 : (nMasterVolume <= 0.0f ? 0 : sqrtf(nMasterVolume) * 127.0f)); } bool InitNetwork(); void UpdateNetwork(); diff --git a/src/uimenu.cpp b/src/uimenu.cpp index 5bf7285..dabea87 100644 --- a/src/uimenu.cpp +++ b/src/uimenu.cpp @@ -29,6 +29,7 @@ #include #include #include +#include using namespace std; LOGMODULE ("uimenu"); @@ -63,6 +64,7 @@ const CUIMenu::TMenuItem CUIMenu::s_MainMenu[] = #endif #endif {"Effects", MenuHandler, s_EffectsMenu}, + {"Master Volume", EditMasterVolume, 0, 0}, {"Performance", MenuHandler, s_PerformanceMenu}, {0} }; @@ -2016,3 +2018,29 @@ void CUIMenu::EditTGParameterModulation (CUIMenu *pUIMenu, TMenuEvent Event) nValue > rParam.Minimum, nValue < rParam.Maximum); } + +void CUIMenu::EditMasterVolume(CUIMenu *pUIMenu, TMenuEvent Event) +{ + TParameter rParam = {0, 127, 8, ToVolume}; + int nValue = pUIMenu->m_pMiniDexed->GetMasterVolume127(); + switch (Event) + { + case MenuEventUpdate: + case MenuEventUpdateParameter: + break; + case MenuEventStepDown: + nValue -= rParam.Increment; + if (nValue < rParam.Minimum) nValue = rParam.Minimum; + pUIMenu->m_pMiniDexed->setMasterVolume(nValue / 127.0f); + break; + case MenuEventStepUp: + nValue += rParam.Increment; + if (nValue > rParam.Maximum) nValue = rParam.Maximum; + pUIMenu->m_pMiniDexed->setMasterVolume(nValue / 127.0f); + break; + default: + return; + } + std::string valueStr = ToVolume(pUIMenu->m_pMiniDexed->GetMasterVolume127()); + pUIMenu->m_pUI->DisplayWrite("Master Volume", "", valueStr.c_str(), nValue > rParam.Minimum, nValue < rParam.Maximum); +} diff --git a/src/uimenu.h b/src/uimenu.h index 6ff6571..6c72f36 100644 --- a/src/uimenu.h +++ b/src/uimenu.h @@ -96,6 +96,7 @@ private: static void PerformanceMenu (CUIMenu *pUIMenu, TMenuEvent Event); static void SavePerformanceNewFile (CUIMenu *pUIMenu, TMenuEvent Event); static void EditPerformanceBankNumber (CUIMenu *pUIMenu, TMenuEvent Event); + static void EditMasterVolume (CUIMenu *pUIMenu, TMenuEvent Event); static std::string GetGlobalValueString (unsigned nParameter, int nValue); static std::string GetTGValueString (unsigned nTGParameter, int nValue); 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..3307417 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 = [] @@ -108,7 +172,7 @@ if __name__ == "__main__": browser = ServiceBrowser(zeroconf, "_ftp._tcp.local.", listener) try: print("Searching for devices...") - time.sleep(5) + time.sleep(10) if ip_addresses: print("Devices found:") for idx, (name, ip) in enumerate(zip(device_names, ip_addresses)): @@ -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: