Last active
November 23, 2025 09:38
-
-
Save g-l-i-t-c-h-o-r-s-e/285addafadd417706580d8e1824ea55c to your computer and use it in GitHub Desktop.
Launchpad MIDI Input (with Light Control) in Quartz Composer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # --- config (override via env) --- | |
| NAME="${NAME:-LaunchPadMIDIControl}" | |
| CLASS="${CLASS:-LaunchPadMIDIControlPlugIn}" | |
| # Allow overriding SRC, but default to both plugin + view controller .m files | |
| # Example override: | |
| # SRC="LaunchPadMIDIControlPlugIn.m SomeOtherViewController.m" ./build.sh | |
| SRC="${SRC:-LaunchPadMIDIControlPlugIn.m LaunchPadMIDIControlPlugInViewController.m}" | |
| PLUG="$NAME.plugin" | |
| OUT="$(pwd)/build-launchpad" | |
| INST="$HOME/Library/Graphics/Quartz Composer Plug-Ins" | |
| XCODE_APP="${XCODE_APP:-/Applications/Xcode_9.4.1.app}" | |
| DEV="$XCODE_APP/Contents/Developer" | |
| SDKDIR="$DEV/Platforms/MacOSX.platform/Developer/SDKs" | |
| DO_I386="1" | |
| # Clean output directory first (set CLEAN=0 to keep) | |
| CLEAN="${CLEAN:-1}" | |
| # Prefer 10.14, fall back to 10.13, else xcrun | |
| SDK="${SDK:-}" | |
| if [[ -z "${SDK}" ]]; then | |
| if [[ -d "$SDKDIR/MacOSX10.14.sdk" ]]; then SDK="$SDKDIR/MacOSX10.14.sdk" | |
| elif [[ -d "$SDKDIR/MacOSX10.13.sdk" ]]; then SDK="$SDKDIR/MacOSX10.13.sdk" | |
| else SDK="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" | |
| fi | |
| fi | |
| [[ -d "$DEV" ]] || { echo "Xcode not found: $XCODE_APP"; exit 1; } | |
| # Check every source file in SRC | |
| for f in $SRC; do | |
| [[ -f "$f" ]] || { echo "Source not found: $f"; exit 1; } | |
| done | |
| [[ -n "${SDK:-}" && -d "$SDK" ]] || { echo "macOS SDK not found."; exit 1; } | |
| export DEVELOPER_DIR="$DEV" | |
| echo "Using SDK: $SDK" | |
| # Clean build output so there are no stale slices | |
| if [[ "$CLEAN" == "1" ]]; then | |
| echo "Cleaning: rm -rf '$OUT'" | |
| rm -rf "$OUT" | |
| fi | |
| mkdir -p "$OUT/i386" "$OUT/x86_64" "$OUT/universal/$PLUG/Contents/MacOS" | |
| # Arch toggles | |
| # Default: skip i386 on 10.14 SDK; allow override with DO_I386=1 | |
| if [[ -z "${DO_I386:-}" ]]; then | |
| if [[ "$SDK" == *"10.14.sdk"* ]]; then DO_I386=0; else DO_I386=1; fi | |
| fi | |
| DO_X64="${DO_X64:-1}" | |
| # Minimum OS (override with DEPLOY=10.10, etc.) | |
| DEPLOY="${DEPLOY:-10.14}" | |
| COMMON_CFLAGS=( | |
| -bundle -fobjc-arc -fobjc-link-runtime | |
| -isysroot "$SDK" | |
| -mmacosx-version-min="$DEPLOY" | |
| -I . | |
| ) | |
| COMMON_LIBS=( | |
| -framework Foundation | |
| -framework Quartz | |
| -framework OpenGL | |
| -framework AppKit | |
| -framework QuartzCore | |
| -framework CoreMIDI | |
| ) | |
| # ---- compile (track what we actually built) ---- | |
| BUILT_I386=0 | |
| BUILT_X64=0 | |
| if [[ "$DO_I386" == "1" ]]; then | |
| echo "Compiling i386…" | |
| # NOTE: $SRC deliberately NOT quoted so it splits into multiple .m files | |
| clang -arch i386 "${COMMON_CFLAGS[@]}" $SRC "${COMMON_LIBS[@]}" -o "$OUT/i386/$NAME" || { | |
| echo "i386 build failed (likely unsupported by this SDK/clang). Set DO_I386=0 to skip."; exit 1; } | |
| BUILT_I386=1 | |
| else | |
| echo "Skipping i386 (set DO_I386=1 to attempt, e.g. with 10.13 SDK)." | |
| fi | |
| if [[ "$DO_X64" == "1" ]]; then | |
| echo "Compiling x86_64…" | |
| # NOTE: $SRC deliberately NOT quoted so it splits into multiple .m files | |
| clang -arch x86_64 "${COMMON_CFLAGS[@]}" $SRC "${COMMON_LIBS[@]}" -o "$OUT/x86_64/$NAME" | |
| BUILT_X64=1 | |
| fi | |
| # ---- gather only slices from this run ---- | |
| BINARIES=() | |
| [[ "$BUILT_I386" == "1" && -f "$OUT/i386/$NAME" ]] && BINARIES+=("$OUT/i386/$NAME") | |
| [[ "$BUILT_X64" == "1" && -f "$OUT/x86_64/$NAME" ]] && BINARIES+=("$OUT/x86_64/$NAME") | |
| if [[ "${#BINARIES[@]}" -eq 0 ]]; then | |
| echo "No binaries were built. Aborting." | |
| exit 1 | |
| fi | |
| echo "Creating bundle…" | |
| mkdir -p "$OUT/universal/$PLUG/Contents/MacOS" | |
| lipo -create "${BINARIES[@]}" -output "$OUT/universal/$PLUG/Contents/MacOS/$NAME" | |
| # ---- Info.plist ---- | |
| cat >"$OUT/universal/$PLUG/Contents/Info.plist" <<PLIST | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"><dict> | |
| <key>CFBundleDevelopmentRegion</key> <string>English</string> | |
| <key>CFBundleExecutable</key> <string>${NAME}</string> | |
| <key>CFBundleIdentifier</key> <string>com.yourdomain.${NAME}</string> | |
| <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> | |
| <key>CFBundleName</key> <string>${NAME}</string> | |
| <key>CFBundlePackageType</key> <string>BNDL</string> | |
| <key>CFBundleShortVersionString</key> <string>1.0</string> | |
| <key>CFBundleSupportedPlatforms</key> <array><string>MacOSX</string></array> | |
| <key>CFBundleVersion</key> <string>1</string> | |
| <key>QCPlugInClasses</key> | |
| <array> | |
| <string>${CLASS}</string> | |
| </array> | |
| <key>NSPrincipalClass</key> <string>QCPlugIn</string> | |
| <key>NSRequiresAquaSystemAppearance</key> <false/> | |
| </dict></plist> | |
| PLIST | |
| echo "Final binary slices:" | |
| lipo -info "$OUT/universal/$PLUG/Contents/MacOS/$NAME" || true | |
| echo "Signing…" | |
| codesign --force -s - "$OUT/universal/$PLUG" >/dev/null || true | |
| echo "Installing to: $INST" | |
| mkdir -p "$INST" | |
| rsync -a "$OUT/universal/$PLUG" "$INST/" | |
| echo "Installed: $INST/$PLUG" | |
| echo "Restart Quartz Composer to load the new plug-in." | |
| echo | |
| echo "Notes:" | |
| echo " • CLEAN=$CLEAN (set CLEAN=0 to keep previous builds)." | |
| echo " • i386 default depends on SDK (10.14 → OFF). Override with DO_I386=0/1." | |
| echo " • DEPLOY=${DEPLOY} (override with DEPLOY=10.10, etc.)." | |
| echo " • SRC='$SRC' (space-separated list of .m files being compiled)." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // LaunchPadMIDIControlPlugIn.h | |
| #import <Quartz/Quartz.h> | |
| #import <CoreMIDI/CoreMIDI.h> | |
| #import <Foundation/Foundation.h> | |
| #import <stdint.h> | |
| @interface LaunchPadMIDIControlPlugIn : QCPlugIn | |
| { | |
| MIDIClientRef _client; | |
| MIDIPortRef _outPort; | |
| MIDIPortRef _inPort; | |
| MIDIEndpointRef _sourceEndpoint; // where we read from | |
| MIDIEndpointRef _destEndpoint; // where we send to | |
| BOOL _gateState; // cached Gate value (for MIDI thread) | |
| BOOL _exclusiveState; // cached Exclusive value (for MIDI thread) | |
| BOOL _lastGate; // for detecting Gate transitions | |
| BOOL _lastReset; // for detecting Reset rising edge | |
| // Per-note state for toggle | |
| BOOL _latched[16][128]; // [channel][note] currently toggled ON? | |
| UInt8 _latchedVelocity[16][128]; // velocity/color used when turning ON | |
| // Per-note current ON state across all modes (momentary + latched). | |
| BOOL _noteActive[16][128]; | |
| // Cached velocity values from sliders (0–127) | |
| UInt8 _onVelValue; // velocity used for "LED ON" | |
| UInt8 _offVelValue; // velocity used for "LED OFF" | |
| // Row selector (0–8). Launchpad-style: | |
| // row 0 → notes 0–7, row 1 → 16–23, row 2 → 32–39, ..., row 7 → 112–119. | |
| // Row 8 effectively selects no row. | |
| UInt8 _rowIndex; | |
| // Last seen value of the shared-exclusive bus (-1 or 0–127) | |
| SInt16 _lastSharedActiveNote; | |
| NSString *_cachedDeviceName; // last applied device name filter | |
| // -------- Formula-group ivars -------- | |
| BOOL _useFormulaGroup; // Settings tab checkbox | |
| NSString *_groupFormula; // raw string, e.g. "AB13" | |
| BOOL _groupMask[128]; // true for notes in the group | |
| NSArray *_groupNoteList; // NSArray<NSNumber*> of MIDI notes | |
| NSArray *_groupOutputKeys; // NSArray<NSString*> for dynamic group output ports | |
| } | |
| // Inputs | |
| @property (copy) NSString *inputDeviceName; // device to read/send from | |
| @property (assign) BOOL inputGate; // Gate / toggle mode | |
| @property (assign) BOOL inputExclusive; // Exclusive mode: only one latched note at a time | |
| @property (assign) double inputOnVelocity; // slider: 0–127, velocity for Note ON in Gate mode | |
| @property (assign) double inputOffVelocity; // slider: 0–127, velocity for Note OFF in Gate mode | |
| @property (assign) BOOL inputReset; // Reset: sends Note Off 0 to all notes on rising edge | |
| @property (assign) double inputRow; // Launchpad row (0–8) | |
| // Shared active note bus (0–127, or -1 if none) | |
| @property (assign) double inputSharedActive; | |
| // NOTE: | |
| // • outputNote0..outputNote7 *and* outputSharedActive are now dynamic ports only. | |
| // • They are NOT declared as properties here. | |
| @end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // LaunchPadMIDIControlPlugIn.m | |
| // | |
| #import "LaunchPadMIDIControlPlugIn.h" | |
| #import "LaunchPadMIDIControlPlugInViewController.h" | |
| #import <Quartz/Quartz.h> | |
| #import <CoreMIDI/CoreMIDI.h> | |
| #import <Foundation/Foundation.h> | |
| #import <string.h> | |
| #import <stdint.h> | |
| static void LaunchPadMIDIControlReadProc(const MIDIPacketList *pktlist, | |
| void *refCon, | |
| void *srcConnRefCon); | |
| #pragma mark - Formula helper | |
| // Parse a formula like "AB13" into an array of MIDI notes. | |
| // Rows: A..H → Launchpad rows 0..7 | |
| // Cols: 1..8 → Launchpad columns 0..7 | |
| // Note: row * 16 + colIndex | |
| // | |
| // Examples: | |
| // "A1" → [0] | |
| // "C5" → [row 2, col 4] → [36] | |
| // "AB13" → rows A..B, cols 1..3 → [0,1,2,16,17,18] | |
| static NSArray<NSNumber *> *LPMNotesForFormula(NSString *formula) | |
| { | |
| if (!formula) | |
| return @[]; | |
| NSString *trimmed = [[formula stringByTrimmingCharactersInSet: | |
| [NSCharacterSet whitespaceAndNewlineCharacterSet]] | |
| uppercaseString]; | |
| if (trimmed.length == 0) | |
| return @[]; | |
| NSMutableString *rowPart = [NSMutableString string]; | |
| NSMutableString *colPart = [NSMutableString string]; | |
| NSUInteger len = trimmed.length; | |
| BOOL seenDigit = NO; | |
| for (NSUInteger i = 0; i < len; ++i) { | |
| unichar c = [trimmed characterAtIndex:i]; | |
| if ([[NSCharacterSet whitespaceAndNewlineCharacterSet] characterIsMember:c]) { | |
| continue; | |
| } | |
| if (c >= 'A' && c <= 'H') { | |
| if (seenDigit) { | |
| // Letters after digits → invalid | |
| return @[]; | |
| } | |
| [rowPart appendFormat:@"%C", c]; | |
| } | |
| else if (c >= '0' && c <= '9') { | |
| seenDigit = YES; | |
| [colPart appendFormat:@"%C", c]; | |
| } | |
| else { | |
| // Unknown character → invalid | |
| return @[]; | |
| } | |
| } | |
| if (rowPart.length == 0 || colPart.length == 0 || | |
| rowPart.length > 2 || colPart.length > 2) { | |
| return @[]; | |
| } | |
| // Rows A–H → indices 0–7 | |
| int rowStart = (int)([rowPart characterAtIndex:0] - 'A'); | |
| int rowEnd = rowStart; | |
| if (rowPart.length == 2) { | |
| rowEnd = (int)([rowPart characterAtIndex:1] - 'A'); | |
| } | |
| if (rowStart < 0 || rowStart > 7 || rowEnd < 0 || rowEnd > 7) | |
| return @[]; | |
| if (rowStart > rowEnd) { | |
| int tmp = rowStart; rowStart = rowEnd; rowEnd = tmp; | |
| } | |
| // Columns: 1–8 → indices 0–7 | |
| int colStart = (int)([colPart characterAtIndex:0] - '0'); // 1..8 | |
| int colEnd = colStart; | |
| if (colPart.length == 2) { | |
| colEnd = (int)([colPart characterAtIndex:1] - '0'); | |
| } | |
| if (colStart < 1 || colStart > 8 || colEnd < 1 || colEnd > 8) | |
| return @[]; | |
| if (colStart > colEnd) { | |
| int tmp = colStart; colStart = colEnd; colEnd = tmp; | |
| } | |
| int colStartIdx = colStart - 1; // 0–7 | |
| int colEndIdx = colEnd - 1; // 0–7 | |
| NSMutableArray<NSNumber *> *notes = [NSMutableArray array]; | |
| for (int r = rowStart; r <= rowEnd; ++r) { | |
| for (int c = colStartIdx; c <= colEndIdx; ++c) { | |
| int note = r * 16 + c; // Launchpad mapping | |
| if (note >= 0 && note < 128) { | |
| [notes addObject:@(note)]; | |
| } | |
| } | |
| } | |
| return notes; | |
| } | |
| // Convert a MIDI note number (0–127) to a name like "C-2", "C#-2", "E-1", "G#0", "C2". | |
| // Mapping is: | |
| // 0 -> C-2 | |
| // 1 -> C#-2 | |
| // 16 -> E-1 | |
| // 32 -> G#0 | |
| // 48 -> C2 | |
| static NSString *LPMNoteNameForMIDINote(int note) | |
| { | |
| if (note < 0 || note > 127) { | |
| return @"?"; | |
| } | |
| static NSString *const kNames[12] = { | |
| @"C", @"C#", @"D", @"D#", @"E", @"F", | |
| @"F#", @"G", @"G#", @"A", @"A#", @"B" | |
| }; | |
| int pitchClass = note % 12; | |
| int octave = note / 12 - 2; // 0 → C-2, 16 → E-1, 32 → G#0, 48 → C2 | |
| return [NSString stringWithFormat:@"%@%d", kNames[pitchClass], octave]; | |
| } | |
| #pragma mark - Private extension for formula-group state | |
| @interface LaunchPadMIDIControlPlugIn () | |
| // Settings tab (NOT QC ports, internal only) | |
| @property (nonatomic, copy) NSString *groupFormula; | |
| @property (nonatomic, assign) BOOL useFormulaGroup; | |
| @end | |
| @implementation LaunchPadMIDIControlPlugIn | |
| // only property-based ports are inputs now | |
| @dynamic inputDeviceName, inputGate, inputExclusive, inputOnVelocity, inputOffVelocity, inputReset, inputRow, inputSharedActive; | |
| // synthesize private settings | |
| @synthesize groupFormula = _groupFormula; | |
| @synthesize useFormulaGroup = _useFormulaGroup; | |
| #pragma mark - QC metadata | |
| + (NSDictionary *)attributes | |
| { | |
| return @{ | |
| QCPlugInAttributeNameKey : @"Launchpad MIDI Relay", | |
| QCPlugInAttributeDescriptionKey : @"MIDI echo/relay with Gate/Exclusive plus 8 boolean outputs for a Launchpad-style row (0–7 → notes 0–119, spaced by 16). Includes a shared active-note bus to enforce global exclusivity across multiple instances, and an optional formula-based group mode for arbitrary 8x8 regions." | |
| }; | |
| } | |
| // Provider so the patch can be used as a data source with outputs | |
| + (QCPlugInExecutionMode)executionMode | |
| { | |
| return kQCPlugInExecutionModeProvider; | |
| } | |
| + (QCPlugInTimeMode)timeMode | |
| { | |
| return kQCPlugInTimeModeNone; | |
| } | |
| // QC only respects sortedPropertyPortKeys for *property-based* ports. | |
| // We expose only inputs as properties; all outputs are dynamic so we can | |
| // freely reorder them. This also avoids the "protected port" issue. | |
| + (NSArray *)sortedPropertyPortKeys | |
| { | |
| return @[ | |
| // Inputs | |
| @"inputReset", // just under Enable | |
| @"inputGate", // Gate / toggle | |
| @"inputExclusive", // exclusive/mono mode | |
| @"inputDeviceName", // device selection | |
| @"inputRow", // Launchpad row selector | |
| @"inputOnVelocity", // slider for ON velocity | |
| @"inputOffVelocity", // slider for OFF velocity | |
| @"inputSharedActive" // shared active-note input (bus) | |
| // no property-based outputs | |
| ]; | |
| } | |
| + (NSArray *)plugInKeys | |
| { | |
| // Internal settings that should be serialized with the composition | |
| // but NOT exposed as input ports. | |
| NSArray *superKeys = [super plugInKeys]; | |
| NSArray *myKeys = @[ @"groupFormula", @"useFormulaGroup" ]; | |
| return superKeys ? [superKeys arrayByAddingObjectsFromArray:myKeys] : myKeys; | |
| } | |
| // Attributes for property-based ports only (inputs) | |
| + (NSDictionary *)attributesForPropertyPortWithKey:(NSString *)key | |
| { | |
| // -------- INPUT PORTS -------- | |
| if ([key isEqualToString:@"inputDeviceName"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Device Name", | |
| QCPortAttributeTypeKey : QCPortTypeString, | |
| QCPortAttributeDefaultValueKey : @"Launchpad Mini 9" | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputGate"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Gate/Toggle", | |
| QCPortAttributeTypeKey : QCPortTypeBoolean, | |
| QCPortAttributeDefaultValueKey : @NO | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputExclusive"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Exclusive Mode", | |
| QCPortAttributeTypeKey : QCPortTypeBoolean, | |
| QCPortAttributeDefaultValueKey : @NO | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputOnVelocity"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"On Velocity", | |
| QCPortAttributeTypeKey : QCPortTypeNumber, | |
| QCPortAttributeDefaultValueKey : @127.0, | |
| QCPortAttributeMinimumValueKey : @0.0, | |
| QCPortAttributeMaximumValueKey : @127.0 | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputOffVelocity"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Off Velocity", | |
| QCPortAttributeTypeKey : QCPortTypeNumber, | |
| QCPortAttributeDefaultValueKey : @0.0, | |
| QCPortAttributeMinimumValueKey : @0.0, | |
| QCPortAttributeMaximumValueKey : @127.0 | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputReset"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Reset", | |
| QCPortAttributeTypeKey : QCPortTypeBoolean, | |
| QCPortAttributeDefaultValueKey : @NO | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputRow"]) | |
| { | |
| // 0–8: 8 rows + an optional “no row” state at 8 | |
| return @{ | |
| QCPortAttributeNameKey : @"Row", | |
| QCPortAttributeTypeKey : QCPortTypeNumber, | |
| QCPortAttributeDefaultValueKey : @0.0, | |
| QCPortAttributeMinimumValueKey : @0.0, | |
| QCPortAttributeMaximumValueKey : @8.0 | |
| }; | |
| } | |
| else if ([key isEqualToString:@"inputSharedActive"]) | |
| { | |
| // Number: -1.0 = none, 0–127 = MIDI note number | |
| return @{ | |
| QCPortAttributeNameKey : @"Shared Active In", | |
| QCPortAttributeTypeKey : QCPortTypeNumber, | |
| QCPortAttributeDefaultValueKey : @(-1.0), | |
| QCPortAttributeMinimumValueKey : @(-1.0), | |
| QCPortAttributeMaximumValueKey : @127.0 | |
| }; | |
| } | |
| return [super attributesForPropertyPortWithKey:key]; | |
| } | |
| #pragma mark - Settings view controller | |
| - (QCPlugInViewController *)createViewController | |
| { | |
| // Settings tab UI: formula text field + "Use formula group" checkbox | |
| return [[LaunchPadMIDIControlPlugInViewController alloc] initWithPlugIn:self | |
| viewNibName:nil]; | |
| } | |
| #pragma mark - Dynamic output helpers | |
| // Safe wrapper for dynamic port removal (in case QC marks something protected) | |
| - (void)_safeRemoveOutputPortForKey:(NSString *)key | |
| { | |
| @try { | |
| [self removeOutputPortForKey:key]; | |
| } @catch (__unused NSException *ex) { | |
| // Ignore (e.g. protected or doesn't exist) | |
| } | |
| } | |
| // Create the "original" 8 Note outputs as dynamic ports: | |
| // keys: outputNote0..outputNote7, names: "Note 1".."Note 8". | |
| - (void)_createRowOutputPorts | |
| { | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote0" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 1" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote1" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 2" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote2" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 3" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote3" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 4" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote4" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 5" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote5" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 6" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote6" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 7" }]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:@"outputNote7" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Note 8" }]; | |
| } | |
| // Remove the 8 row outputs (used when formula mode is enabled) | |
| - (void)_destroyRowOutputPorts | |
| { | |
| [self _safeRemoveOutputPortForKey:@"outputNote0"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote1"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote2"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote3"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote4"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote5"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote6"]; | |
| [self _safeRemoveOutputPortForKey:@"outputNote7"]; | |
| } | |
| // Ensure Shared Active Out exists and is at the very bottom by | |
| // removing and re-adding it last. | |
| - (void)_ensureSharedActiveOutputAtBottom | |
| { | |
| [self _safeRemoveOutputPortForKey:@"outputSharedActive"]; | |
| [self addOutputPortWithType:QCPortTypeNumber | |
| forKey:@"outputSharedActive" | |
| withAttributes:@{ QCPortAttributeNameKey : @"Shared Active Out" }]; | |
| } | |
| #pragma mark - Init / dealloc | |
| - (id)init | |
| { | |
| self = [super init]; | |
| if (self) { | |
| _client = 0; | |
| _outPort = 0; | |
| _inPort = 0; | |
| _sourceEndpoint = 0; | |
| _destEndpoint = 0; | |
| _gateState = NO; | |
| _exclusiveState = NO; | |
| _lastGate = NO; | |
| _lastReset = NO; | |
| _cachedDeviceName = nil; | |
| memset(_latched, 0, sizeof(_latched)); | |
| memset(_latchedVelocity, 0, sizeof(_latchedVelocity)); | |
| memset(_noteActive, 0, sizeof(_noteActive)); | |
| _onVelValue = 127; | |
| _offVelValue = 0; | |
| _rowIndex = 0; | |
| _lastSharedActiveNote = -1; | |
| // Formula-group defaults | |
| _useFormulaGroup = NO; | |
| _groupFormula = @""; | |
| memset(_groupMask, 0, sizeof(_groupMask)); | |
| _groupNoteList = @[]; | |
| _groupOutputKeys = @[]; | |
| // Start with the classic 8 row outputs as dynamic ports | |
| [self _createRowOutputPorts]; | |
| // And Shared Active Out at the very bottom | |
| [self _ensureSharedActiveOutputAtBottom]; | |
| } | |
| return self; | |
| } | |
| - (void)dealloc | |
| { | |
| if (_inPort) { | |
| if (_sourceEndpoint) { | |
| MIDIPortDisconnectSource(_inPort, _sourceEndpoint); | |
| _sourceEndpoint = 0; | |
| } | |
| MIDIPortDispose(_inPort); | |
| _inPort = 0; | |
| } | |
| if (_outPort) { | |
| _destEndpoint = 0; | |
| MIDIPortDispose(_outPort); | |
| _outPort = 0; | |
| } | |
| if (_client) { | |
| MIDIClientDispose(_client); | |
| _client = 0; | |
| } | |
| } | |
| #pragma mark - Formula-group helpers | |
| // Apply a list of notes as the current group: update mask, list, and dynamic ports | |
| - (void)_applyGroupNotes:(NSArray<NSNumber *> *)notes | |
| { | |
| // 1) Update mask + list | |
| @synchronized (self) { | |
| memset(_groupMask, 0, sizeof(_groupMask)); | |
| _groupNoteList = [notes copy]; | |
| for (NSNumber *num in _groupNoteList) { | |
| int n = num.intValue; | |
| if (n >= 0 && n < 128) { | |
| _groupMask[n] = YES; | |
| } | |
| } | |
| } | |
| // 2) Rebuild dynamic boolean output ports for each note in the group | |
| // Remove old dynamic group ports | |
| for (NSString *key in _groupOutputKeys) { | |
| [self _safeRemoveOutputPortForKey:key]; | |
| } | |
| _groupOutputKeys = @[]; | |
| if (!_useFormulaGroup || _groupNoteList.count == 0) { | |
| // Make sure Shared Active Out still exists and is at bottom even if no group | |
| [self _ensureSharedActiveOutputAtBottom]; | |
| return; | |
| } | |
| NSMutableArray *newKeys = [NSMutableArray arrayWithCapacity:_groupNoteList.count]; | |
| for (NSNumber *num in _groupNoteList) { | |
| int note = num.intValue; | |
| if (note < 0 || note >= 128) | |
| continue; | |
| // Launchpad 8x8 grid mapping: | |
| // rowIndex = 0..7 (A..H) | |
| // colIndex = 0..7 (1..8) | |
| // MIDI mapping is row * 16 + col, but pads are logically 8-wide. | |
| int rowIndex = note / 16; | |
| int colIndex = note % 16; // 0–15; we only keep 0–7 for 8x8 | |
| if (rowIndex < 0 || rowIndex > 7 || colIndex < 0 || colIndex > 7) { | |
| // Outside 8x8 grid – skip dynamic port | |
| continue; | |
| } | |
| // --- Pad index: left-to-right, row-by-row, starting at 0 --- | |
| // Row 0: indices 0–7 | |
| // Row 1: indices 8–15 | |
| // Row 2: indices 16–23 | |
| int padIndex = rowIndex * 8 + colIndex; | |
| // Proper musical note name, e.g. "C-2", "E-1", "G#0", "C2" | |
| NSString *noteName = LPMNoteNameForMIDINote(note); | |
| // Final label: | |
| // "E-1 (note 8 / midi 16)" | |
| // So "note" matches your 0..63 grid counting, and "midi" is the raw MIDI note. | |
| NSString *portName = [NSString stringWithFormat:@"%@ (note %d)", // "%@ (note %d / midi %d)" | |
| noteName, padIndex, note]; | |
| NSString *portKey = [NSString stringWithFormat:@"group_%d", note]; | |
| [self addOutputPortWithType:QCPortTypeBoolean | |
| forKey:portKey | |
| withAttributes:@{ QCPortAttributeNameKey : portName }]; | |
| [newKeys addObject:portKey]; | |
| } | |
| _groupOutputKeys = [newKeys copy]; | |
| // Ensure Shared Active Out is last after group ports | |
| [self _ensureSharedActiveOutputAtBottom]; | |
| } | |
| - (void)_rebuildGroupFromSettings | |
| { | |
| if (!_useFormulaGroup || !_groupFormula || _groupFormula.length == 0) { | |
| // When formula mode is off or formula is empty, just clear group ports/masks | |
| [self _applyGroupNotes:@[]]; | |
| return; | |
| } | |
| NSArray<NSNumber *> *notes = LPMNotesForFormula(_groupFormula); | |
| [self _applyGroupNotes:notes]; | |
| } | |
| // KVC setters, called from Settings UI | |
| - (void)setGroupFormula:(NSString *)groupFormula | |
| { | |
| if (groupFormula == _groupFormula || [groupFormula isEqualToString:_groupFormula]) { | |
| return; | |
| } | |
| _groupFormula = [groupFormula copy]; | |
| [self _rebuildGroupFromSettings]; | |
| } | |
| - (void)setUseFormulaGroup:(BOOL)useFormulaGroup | |
| { | |
| if (_useFormulaGroup == useFormulaGroup) | |
| return; | |
| _useFormulaGroup = useFormulaGroup; | |
| if (_useFormulaGroup) { | |
| // Formula mode ON: remove the row outputs so we only have formula ports + Shared Active Out | |
| [self _destroyRowOutputPorts]; | |
| } else { | |
| // Formula mode OFF: remove any formula ports and recreate the row outputs | |
| for (NSString *key in _groupOutputKeys) { | |
| [self _safeRemoveOutputPortForKey:key]; | |
| } | |
| _groupOutputKeys = @[]; | |
| [self _createRowOutputPorts]; | |
| } | |
| // Rebuild formula group (will also reposition Shared Active Out) | |
| [self _rebuildGroupFromSettings]; | |
| // In row mode, ensure Shared Active Out is at bottom after row ports | |
| if (!_useFormulaGroup) { | |
| [self _ensureSharedActiveOutputAtBottom]; | |
| } | |
| } | |
| #pragma mark - MIDI endpoint selection | |
| // Helper: match endpoint by display name substring | |
| - (BOOL)endpoint:(MIDIEndpointRef)ep matchesFilterName:(NSString *)filter | |
| { | |
| if (!ep) | |
| return NO; | |
| if (!filter || filter.length == 0) | |
| return YES; | |
| CFStringRef cfName = NULL; | |
| OSStatus err = MIDIObjectGetStringProperty(ep, kMIDIPropertyDisplayName, &cfName); | |
| if (err != noErr || !cfName) | |
| return NO; | |
| NSString *name = CFBridgingRelease(cfName); | |
| if (!name) | |
| return NO; | |
| NSRange r = [name rangeOfString:filter options:NSCaseInsensitiveSearch]; | |
| return (r.location != NSNotFound); | |
| } | |
| // Select one source and one destination for the given filter | |
| - (void)selectEndpointsForFilter:(NSString *)filter | |
| { | |
| _sourceEndpoint = 0; | |
| _destEndpoint = 0; | |
| NSString *trimmed = filter ?: @""; | |
| trimmed = [trimmed stringByTrimmingCharactersInSet: | |
| [NSCharacterSet whitespaceAndNewlineCharacterSet]]; | |
| // Pick source | |
| ItemCount srcCount = MIDIGetNumberOfSources(); | |
| for (ItemCount i = 0; i < srcCount; ++i) { | |
| MIDIEndpointRef src = MIDIGetSource(i); | |
| if (!src) | |
| continue; | |
| if (trimmed.length == 0 || [self endpoint:src matchesFilterName:trimmed]) { | |
| _sourceEndpoint = src; | |
| break; | |
| } | |
| } | |
| // Pick destination | |
| ItemCount destCount = MIDIGetNumberOfDestinations(); | |
| for (ItemCount i = 0; i < destCount; ++i) { | |
| MIDIEndpointRef dest = MIDIGetDestination(i); | |
| if (!dest) | |
| continue; | |
| if (trimmed.length == 0 || [self endpoint:dest matchesFilterName:trimmed]) { | |
| _destEndpoint = dest; | |
| break; | |
| } | |
| } | |
| // Connect input to selected source | |
| if (_inPort && _sourceEndpoint) { | |
| MIDIPortConnectSource(_inPort, _sourceEndpoint, (void *)(uintptr_t)_sourceEndpoint); | |
| } | |
| } | |
| - (void)disconnectCurrentSource | |
| { | |
| if (_inPort && _sourceEndpoint) { | |
| MIDIPortDisconnectSource(_inPort, _sourceEndpoint); | |
| _sourceEndpoint = 0; | |
| } | |
| } | |
| - (void)updateEndpointsIfNeeded | |
| { | |
| NSString *current = self.inputDeviceName ?: @""; | |
| current = [current stringByTrimmingCharactersInSet: | |
| [NSCharacterSet whitespaceAndNewlineCharacterSet]]; | |
| BOOL same = NO; | |
| if (_cachedDeviceName == nil) { | |
| same = (current.length == 0); | |
| } else { | |
| same = [_cachedDeviceName isEqualToString:current]; | |
| } | |
| if (same) | |
| return; | |
| [self disconnectCurrentSource]; | |
| _destEndpoint = 0; | |
| _cachedDeviceName = [current copy]; | |
| [self selectEndpointsForFilter:current]; | |
| } | |
| #pragma mark - MIDI send / receive | |
| // Send a simple 3-byte MIDI message (status, data1, data2) to the selected destination | |
| - (void)sendMIDIStatus:(UInt8)status data1:(UInt8)data1 data2:(UInt8)data2 | |
| { | |
| if (!_client || !_outPort) | |
| return; | |
| UInt8 bytes[3]; | |
| bytes[0] = status; | |
| bytes[1] = data1; | |
| bytes[2] = data2; | |
| MIDIPacketList packetList; | |
| MIDIPacket *packet = MIDIPacketListInit(&packetList); | |
| packet = MIDIPacketListAdd(&packetList, | |
| sizeof(packetList), | |
| packet, | |
| 0, | |
| sizeof(bytes), | |
| bytes); | |
| if (!packet) | |
| return; | |
| if (_destEndpoint) { | |
| MIDISend(_outPort, _destEndpoint, &packetList); | |
| } else { | |
| ItemCount destCount = MIDIGetNumberOfDestinations(); | |
| for (ItemCount i = 0; i < destCount; ++i) { | |
| MIDIEndpointRef dest = MIDIGetDestination(i); | |
| if (dest) { | |
| MIDISend(_outPort, dest, &packetList); | |
| } | |
| } | |
| } | |
| } | |
| // Handle a single Note message (Note On/Off) with Gate, Exclusive & group/row logic, | |
| // and keep _noteActive[] in sync for the boolean outputs. | |
| - (void)handleStatus:(UInt8)status data1:(UInt8)d1 data2:(UInt8)d2 | |
| { | |
| UInt8 type = status & 0xF0; | |
| UInt8 chan = status & 0x0F; | |
| UInt8 note = d1; | |
| if (chan >= 16 || note >= 128) | |
| return; | |
| BOOL gate, exclusive; | |
| UInt8 onVel, offVel; | |
| UInt8 rowIdx; | |
| BOOL useFormula; | |
| BOOL groupMaskLocal[128]; | |
| memset(groupMaskLocal, 0, sizeof(groupMaskLocal)); | |
| @synchronized (self) { | |
| gate = _gateState; | |
| exclusive = _exclusiveState; | |
| onVel = _onVelValue; | |
| offVel = _offVelValue; | |
| rowIdx = _rowIndex; | |
| useFormula = _useFormulaGroup; | |
| if (useFormula) { | |
| memcpy(groupMaskLocal, _groupMask, sizeof(_groupMask)); | |
| } | |
| } | |
| // Launchpad-like mapping: | |
| // rowIdx 0 → notes 0–7, rowIdx 1 → 16–23, ..., rowIdx 7 → 112–119. | |
| UInt8 rowMin = (UInt8)(rowIdx * 16); | |
| UInt8 rowMax = (UInt8)(rowMin + 7); // 8 buttons per row | |
| BOOL isNoteOnPress = (type == 0x90 && d2 > 0); | |
| BOOL isNoteOffLike = (type == 0x80) || (type == 0x90 && d2 == 0); | |
| BOOL inTarget = useFormula | |
| ? (note < 128 && groupMaskLocal[note]) | |
| : (note >= rowMin && note <= rowMax); | |
| // --- Out-of-group / out-of-row notes: ignore them completely --- | |
| if (!inTarget) { | |
| return; | |
| } | |
| // --- Gate OFF → momentary mode (no latching), | |
| // but we still track ON/OFF state in _noteActive[]. | |
| if (!gate) { | |
| if (isNoteOnPress) { | |
| UInt8 useVel = onVel; // 0–127 | |
| [self sendMIDIStatus:status data1:note data2:useVel]; | |
| @synchronized (self) { | |
| _noteActive[chan][note] = YES; | |
| } | |
| } | |
| else if (isNoteOffLike) { | |
| UInt8 useVel = offVel; // often 0 | |
| [self sendMIDIStatus:status data1:note data2:useVel]; | |
| @synchronized (self) { | |
| _noteActive[chan][note] = NO; | |
| } | |
| } | |
| return; | |
| } | |
| // --- Gate ON → per-pad toggle driven ONLY by Note On presses | |
| // No Note Off-like messages are forwarded while gate is ON. | |
| if (isNoteOnPress) { | |
| BOOL wasLatched; | |
| UInt8 lastVel; | |
| @synchronized (self) { | |
| wasLatched = _latched[chan][note]; | |
| lastVel = _latchedVelocity[chan][note]; | |
| } | |
| if (!wasLatched) { | |
| // First press → toggle ON | |
| // If Exclusive mode: turn OFF any other latched notes on this channel | |
| // within the SAME active group (row or formula). | |
| if (exclusive) { | |
| for (int otherNote = 0; otherNote < 128; ++otherNote) { | |
| if (otherNote == note) | |
| continue; | |
| BOOL otherInTarget = useFormula | |
| ? groupMaskLocal[otherNote] | |
| : (otherNote >= rowMin && otherNote <= rowMax); | |
| BOOL otherLatched = NO; | |
| @synchronized (self) { | |
| if (_latched[chan][otherNote]) { | |
| otherLatched = YES; | |
| _latched[chan][otherNote] = NO; | |
| _latchedVelocity[chan][otherNote] = 0; | |
| _noteActive[chan][otherNote] = NO; | |
| } | |
| } | |
| if (otherLatched && otherInTarget) { | |
| UInt8 offStatus = (UInt8)(0x90 | chan); | |
| [self sendMIDIStatus:offStatus | |
| data1:(UInt8)otherNote | |
| data2:offVel]; | |
| } | |
| } | |
| } | |
| UInt8 useVel = onVel; | |
| if (useVel == 0) { | |
| useVel = (lastVel > 0) ? lastVel : 127; | |
| } | |
| @synchronized (self) { | |
| _latched[chan][note] = YES; | |
| _latchedVelocity[chan][note] = useVel; | |
| _noteActive[chan][note] = YES; | |
| } | |
| UInt8 onStatus = (UInt8)(0x90 | chan); | |
| [self sendMIDIStatus:onStatus data1:note data2:useVel]; | |
| } else { | |
| // Second press → toggle OFF | |
| UInt8 useVel = offVel; // usually 0 | |
| @synchronized (self) { | |
| _latched[chan][note] = NO; | |
| _latchedVelocity[chan][note] = 0; | |
| _noteActive[chan][note] = NO; | |
| } | |
| UInt8 onStatus = (UInt8)(0x90 | chan); | |
| [self sendMIDIStatus:onStatus data1:note data2:useVel]; | |
| } | |
| } | |
| else if (isNoteOffLike) { | |
| // Ignore releases in Gate mode | |
| return; | |
| } | |
| } | |
| // CoreMIDI read callback processing | |
| - (void)processIncomingPacketList:(const MIDIPacketList *)pktlist | |
| fromSource:(MIDIEndpointRef)source | |
| { | |
| if (_sourceEndpoint && source && source != _sourceEndpoint) | |
| return; | |
| const MIDIPacket *packet = &pktlist->packet[0]; | |
| for (UInt32 p = 0; p < pktlist->numPackets; ++p) { | |
| const UInt8 *data = packet->data; | |
| UInt16 len = packet->length; | |
| UInt16 i = 0; | |
| while (i + 2 < len) { | |
| UInt8 status = data[i]; | |
| if ((status & 0x80) == 0) { | |
| i++; | |
| continue; | |
| } | |
| UInt8 type = status & 0xF0; | |
| if (type == 0x90 || type == 0x80) { | |
| UInt8 d1 = data[i + 1]; | |
| UInt8 d2 = data[i + 2]; | |
| [self handleStatus:status data1:d1 data2:d2]; | |
| i += 3; | |
| } else { | |
| i++; | |
| } | |
| } | |
| packet = MIDIPacketNext(packet); | |
| } | |
| } | |
| #pragma mark - Reset helpers | |
| // Flush all latched notes (turn LEDs off using Off Velocity in Gate mode) | |
| // and clear _noteActive[] as well. | |
| - (void)flushLatchedNotes | |
| { | |
| UInt8 offVel; | |
| @synchronized (self) { | |
| offVel = _offVelValue; | |
| } | |
| @synchronized (self) { | |
| for (int ch = 0; ch < 16; ++ch) { | |
| for (int note = 0; note < 128; ++note) { | |
| if (_latched[ch][note]) { | |
| UInt8 status = (UInt8)(0x90 | (ch & 0x0F)); | |
| [self sendMIDIStatus:status data1:(UInt8)note data2:offVel]; | |
| _latched[ch][note] = NO; | |
| _latchedVelocity[ch][note] = 0; | |
| _noteActive[ch][note] = NO; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Reset → send Note Off 0 to *all* notes on all channels | |
| - (void)sendGlobalResetNotesOff | |
| { | |
| for (int ch = 0; ch < 16; ++ch) { | |
| UInt8 status = (UInt8)(0x80 | (ch & 0x0F)); | |
| for (int note = 0; note < 128; ++note) { | |
| [self sendMIDIStatus:status data1:(UInt8)note data2:0]; | |
| } | |
| } | |
| @synchronized (self) { | |
| memset(_latched, 0, sizeof(_latched)); | |
| memset(_latchedVelocity, 0, sizeof(_latchedVelocity)); | |
| memset(_noteActive, 0, sizeof(_noteActive)); | |
| } | |
| } | |
| #pragma mark - QC execution lifecycle | |
| // Create CoreMIDI client, ports; endpoints are selected later in execute | |
| - (BOOL)startExecution:(id<QCPlugInContext>)context | |
| { | |
| OSStatus err = MIDIClientCreate(CFSTR("QC LaunchPad MIDI Control PlugIn"), | |
| NULL, | |
| NULL, | |
| &_client); | |
| if (err != noErr || !_client) { | |
| _client = 0; | |
| return NO; | |
| } | |
| err = MIDIOutputPortCreate(_client, CFSTR("Output"), &_outPort); | |
| if (err != noErr || !_outPort) { | |
| if (_client) { | |
| MIDIClientDispose(_client); | |
| _client = 0; | |
| } | |
| return NO; | |
| } | |
| err = MIDIInputPortCreate(_client, | |
| CFSTR("Input"), | |
| LaunchPadMIDIControlReadProc, | |
| (__bridge void *)self, | |
| &_inPort); | |
| if (err != noErr || !_inPort) { | |
| if (_outPort) { | |
| MIDIPortDispose(_outPort); | |
| _outPort = 0; | |
| } | |
| if (_client) { | |
| MIDIClientDispose(_client); | |
| _client = 0; | |
| } | |
| return NO; | |
| } | |
| _sourceEndpoint = 0; | |
| _destEndpoint = 0; | |
| _cachedDeviceName = nil; | |
| BOOL gateNow = self.inputGate; | |
| BOOL resetNow = self.inputReset; | |
| BOOL exclusiveNow = self.inputExclusive; | |
| double onV = self.inputOnVelocity; | |
| double offV = self.inputOffVelocity; | |
| double rowVal = self.inputRow; | |
| // Clamp row to 0–8 | |
| if (rowVal < 0.0) rowVal = 0.0; | |
| if (rowVal > 8.0) rowVal = 8.0; | |
| if (onV < 0.0) onV = 0.0; | |
| if (onV > 127.0) onV = 127.0; | |
| if (offV < 0.0) offV = 0.0; | |
| if (offV > 127.0) offV = 127.0; | |
| @synchronized (self) { | |
| _gateState = gateNow; | |
| _exclusiveState = exclusiveNow; | |
| _lastGate = gateNow; | |
| _lastReset = resetNow; | |
| _onVelValue = (UInt8)(onV + 0.5); | |
| _offVelValue = (UInt8)(offV + 0.5); | |
| _rowIndex = (UInt8)(rowVal + 0.5); | |
| _lastSharedActiveNote = -1; | |
| } | |
| memset(_latched, 0, sizeof(_latched)); | |
| memset(_latchedVelocity, 0, sizeof(_latchedVelocity)); | |
| memset(_noteActive, 0, sizeof(_noteActive)); | |
| // group mask is already cleared in init; no change here | |
| return YES; | |
| } | |
| - (void)stopExecution:(id<QCPlugInContext>)context | |
| { | |
| // IMPORTANT: do NOT send any MIDI when the patch stops or is deleted. | |
| // Use the Reset input while the patch is running if you want a global clear. | |
| [self disconnectCurrentSource]; | |
| _destEndpoint = 0; | |
| _cachedDeviceName = nil; | |
| if (_inPort) { | |
| MIDIPortDispose(_inPort); | |
| _inPort = 0; | |
| } | |
| if (_outPort) { | |
| MIDIPortDispose(_outPort); | |
| _outPort = 0; | |
| } | |
| if (_client) { | |
| MIDIClientDispose(_client); | |
| _client = 0; | |
| } | |
| // Runtime state is cleared… | |
| memset(_latched, 0, sizeof(_latched)); | |
| memset(_latchedVelocity, 0, sizeof(_latchedVelocity)); | |
| memset(_noteActive, 0, sizeof(_noteActive)); | |
| _lastSharedActiveNote = -1; | |
| // *** CRITICAL CHANGE *** | |
| // | |
| // DO NOT clear the formula group configuration here. | |
| // These are structural settings that need to survive Viewer stop/start. | |
| // | |
| // The following lines were the reason formula groups died after restart: | |
| // | |
| // memset(_groupMask, 0, sizeof(_groupMask)); | |
| // _groupNoteList = @[]; | |
| // _groupOutputKeys = @[]; | |
| // | |
| // Removing them keeps the group’s mask and dynamic ports intact so that | |
| // handleStatus:/execute: will still see notes as “in group” when the | |
| // Viewer is started again, even inside a macro with multiple instances. | |
| } | |
| - (BOOL)execute:(id<QCPlugInContext>)context | |
| atTime:(NSTimeInterval)time | |
| withArguments:(NSDictionary *)arguments | |
| { | |
| if (_client && _inPort && _outPort) { | |
| [self updateEndpointsIfNeeded]; | |
| } | |
| // Read inputs | |
| double onV = self.inputOnVelocity; | |
| double offV = self.inputOffVelocity; | |
| double rowV = self.inputRow; | |
| double sharedInRaw = self.inputSharedActive; | |
| if (onV < 0.0) onV = 0.0; | |
| if (onV > 127.0) onV = 127.0; | |
| if (offV < 0.0) offV = 0.0; | |
| if (offV > 127.0) offV = 127.0; | |
| if (rowV < 0.0) rowV = 0.0; | |
| if (rowV > 8.0) rowV = 8.0; | |
| BOOL gate = self.inputGate; | |
| BOOL reset = self.inputReset; | |
| BOOL exclusive = self.inputExclusive; | |
| BOOL lastGate, lastReset; | |
| UInt8 onVel = (UInt8)(onV + 0.5); | |
| UInt8 offVel = (UInt8)(offV + 0.5); | |
| UInt8 rowIdx = (UInt8)(rowV + 0.5); | |
| int sharedIn; | |
| if (sharedInRaw < 0.0) sharedIn = -1; | |
| else if (sharedInRaw > 127) sharedIn = 127; | |
| else sharedIn = (int)(sharedInRaw + 0.5); | |
| BOOL useFormulaGroup = NO; | |
| NSArray *groupNotesSnapshot = nil; | |
| // Update cached state and respond to shared-exclusive bus | |
| @synchronized (self) { | |
| lastGate = _lastGate; | |
| lastReset = _lastReset; | |
| _gateState = gate; | |
| _exclusiveState = exclusive; | |
| _lastGate = gate; | |
| _lastReset = reset; | |
| _onVelValue = onVel; | |
| _offVelValue = offVel; | |
| _rowIndex = rowIdx; | |
| useFormulaGroup = _useFormulaGroup; | |
| if (useFormulaGroup) { | |
| groupNotesSnapshot = [_groupNoteList copy]; | |
| } | |
| // Global exclusivity via shared bus: | |
| // When the shared active note changes (and we're in Gate+Exclusive), | |
| // clear any active notes in THIS group that are not equal to the new bus note. | |
| if (gate && exclusive) { | |
| if (sharedIn != _lastSharedActiveNote) { | |
| if (!useFormulaGroup) { | |
| UInt8 rowMin = (UInt8)(_rowIndex * 16); | |
| UInt8 rowMax = (UInt8)(rowMin + 7); | |
| for (int ch = 0; ch < 16; ++ch) { | |
| for (UInt8 note = rowMin; note <= rowMax && note < 128; ++note) { | |
| // Keep the global note if it lies in this row | |
| if (sharedIn >= 0 && note == (UInt8)sharedIn) | |
| continue; | |
| if (_noteActive[ch][note] || _latched[ch][note]) { | |
| UInt8 status = (UInt8)(0x90 | (ch & 0x0F)); | |
| [self sendMIDIStatus:status | |
| data1:note | |
| data2:_offVelValue]; | |
| _noteActive[ch][note] = NO; | |
| _latched[ch][note] = NO; | |
| _latchedVelocity[ch][note] = 0; | |
| } | |
| } | |
| } | |
| } else { | |
| for (int ch = 0; ch < 16; ++ch) { | |
| for (NSNumber *num in groupNotesSnapshot) { | |
| int n = num.intValue; | |
| if (n < 0 || n >= 128) | |
| continue; | |
| UInt8 note = (UInt8)n; | |
| // Keep the global note if it lies in this group | |
| if (sharedIn >= 0 && note == (UInt8)sharedIn) | |
| continue; | |
| if (_noteActive[ch][note] || _latched[ch][note]) { | |
| UInt8 status = (UInt8)(0x90 | (ch & 0x0F)); | |
| [self sendMIDIStatus:status | |
| data1:note | |
| data2:_offVelValue]; | |
| _noteActive[ch][note] = NO; | |
| _latched[ch][note] = NO; | |
| _latchedVelocity[ch][note] = 0; | |
| } | |
| } | |
| } | |
| } | |
| _lastSharedActiveNote = (SInt16)sharedIn; | |
| } | |
| } else { | |
| _lastSharedActiveNote = (SInt16)sharedIn; | |
| } | |
| } | |
| // Gate ON → OFF → clear toggled LEDs via Note On + Off Velocity | |
| if (!gate && lastGate) { | |
| [self flushLatchedNotes]; | |
| } | |
| // Reset OFF → ON → send Note Off 0 to all notes | |
| if (reset && !lastReset) { | |
| [self sendGlobalResetNotesOff]; | |
| } | |
| // ---- Update boolean states & local active note ---- | |
| BOOL states[8] = { NO, NO, NO, NO, NO, NO, NO, NO }; | |
| int localActiveNote = -1; | |
| // For dynamic ports created from the formula group | |
| NSArray *groupOutputKeysSnapshot = nil; | |
| BOOL dynamicStates[128]; | |
| NSUInteger dynamicCount = 0; | |
| @synchronized (self) { | |
| if (!useFormulaGroup || !groupNotesSnapshot) { | |
| // Normal row mode: 8 states for notes row*16..row*16+7 | |
| UInt8 baseNote = (UInt8)(_rowIndex * 16); | |
| for (int i = 0; i < 8; ++i) { | |
| UInt8 noteNumber = (UInt8)(baseNote + i); | |
| if (noteNumber >= 128) { | |
| states[i] = NO; | |
| continue; | |
| } | |
| BOOL active = NO; | |
| for (int ch = 0; ch < 16; ++ch) { | |
| if (_noteActive[ch][noteNumber]) { | |
| active = YES; | |
| break; | |
| } | |
| } | |
| states[i] = active; | |
| if (localActiveNote < 0 && active) { | |
| localActiveNote = (int)noteNumber; | |
| } | |
| } | |
| dynamicCount = 0; | |
| } | |
| else { | |
| // Formula mode: 8 "virtual" states = first 8 notes in group (for localActiveNote) | |
| groupOutputKeysSnapshot = [_groupOutputKeys copy]; | |
| NSUInteger groupCount = groupNotesSnapshot.count; | |
| for (int i = 0; i < 8; ++i) { | |
| if (i < (int)groupCount) { | |
| int noteNumber = [groupNotesSnapshot[i] intValue]; | |
| if (noteNumber < 0 || noteNumber >= 128) { | |
| states[i] = NO; | |
| continue; | |
| } | |
| BOOL active = NO; | |
| for (int ch = 0; ch < 16; ++ch) { | |
| if (_noteActive[ch][noteNumber]) { | |
| active = YES; | |
| break; | |
| } | |
| } | |
| states[i] = active; | |
| if (localActiveNote < 0 && active) { | |
| localActiveNote = noteNumber; | |
| } | |
| } else { | |
| states[i] = NO; | |
| } | |
| } | |
| // Dynamic outputs: one per note in the group | |
| NSUInteger count = MIN(groupNotesSnapshot.count, groupOutputKeysSnapshot.count); | |
| dynamicCount = count > 128 ? 128 : count; | |
| for (NSUInteger idx = 0; idx < dynamicCount; ++idx) { | |
| int noteNumber = [groupNotesSnapshot[idx] intValue]; | |
| BOOL active = NO; | |
| if (noteNumber >= 0 && noteNumber < 128) { | |
| for (int ch = 0; ch < 16; ++ch) { | |
| if (_noteActive[ch][noteNumber]) { | |
| active = YES; | |
| break; | |
| } | |
| } | |
| } | |
| dynamicStates[idx] = active; | |
| } | |
| } | |
| } | |
| // ---- Dynamic row outputs (original Note 1..Note 8) ---- | |
| // These only exist in non-formula (row) mode. | |
| if (!useFormulaGroup) { | |
| [self setValue:@(states[0]) forOutputKey:@"outputNote0"]; | |
| [self setValue:@(states[1]) forOutputKey:@"outputNote1"]; | |
| [self setValue:@(states[2]) forOutputKey:@"outputNote2"]; | |
| [self setValue:@(states[3]) forOutputKey:@"outputNote3"]; | |
| [self setValue:@(states[4]) forOutputKey:@"outputNote4"]; | |
| [self setValue:@(states[5]) forOutputKey:@"outputNote5"]; | |
| [self setValue:@(states[6]) forOutputKey:@"outputNote6"]; | |
| [self setValue:@(states[7]) forOutputKey:@"outputNote7"]; | |
| } | |
| // ---- Dynamic formula outputs ---- | |
| if (useFormulaGroup && groupOutputKeysSnapshot && dynamicCount > 0) { | |
| NSUInteger count = MIN(dynamicCount, groupOutputKeysSnapshot.count); | |
| for (NSUInteger idx = 0; idx < count; ++idx) { | |
| [self setValue:@(dynamicStates[idx]) forOutputKey:groupOutputKeysSnapshot[idx]]; | |
| } | |
| } | |
| // ---- Shared active note bus out (dynamic port) ---- | |
| int sharedOut = sharedIn; | |
| if (gate && exclusive && localActiveNote >= 0) { | |
| sharedOut = localActiveNote; | |
| } | |
| double sharedValue = (sharedOut >= 0) ? (double)sharedOut : -1.0; | |
| [self setValue:@(sharedValue) forOutputKey:@"outputSharedActive"]; | |
| return YES; | |
| } | |
| @end | |
| // CoreMIDI read callback | |
| static void LaunchPadMIDIControlReadProc(const MIDIPacketList *pktlist, | |
| void *refCon, | |
| void *srcConnRefCon) | |
| { | |
| LaunchPadMIDIControlPlugIn *plugin = (__bridge LaunchPadMIDIControlPlugIn *)refCon; | |
| if (!plugin) | |
| return; | |
| MIDIEndpointRef source = (MIDIEndpointRef)(uintptr_t)srcConnRefCon; | |
| [plugin processIncomingPacketList:pktlist fromSource:source]; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // LaunchPadMIDIControlPlugInViewController.h | |
| #import <Quartz/Quartz.h> | |
| #import <AppKit/AppKit.h> | |
| @interface LaunchPadMIDIControlPlugInViewController : QCPlugInViewController { | |
| NSTextField *_formulaField; | |
| NSButton *_useFormulaCheckbox; | |
| } | |
| @property(nonatomic, strong) NSTextField *formulaField; | |
| @property(nonatomic, strong) NSButton *useFormulaCheckbox; | |
| @end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #import "LaunchPadMIDIControlPlugInViewController.h" | |
| @implementation LaunchPadMIDIControlPlugInViewController | |
| @synthesize formulaField = _formulaField; | |
| @synthesize useFormulaCheckbox = _useFormulaCheckbox; | |
| - (id)initWithPlugIn:(QCPlugIn *)plugIn viewNibName:(NSString *)nibName | |
| { | |
| self = [super initWithPlugIn:plugIn viewNibName:nibName]; | |
| if (self) { | |
| // nothing special | |
| } | |
| return self; | |
| } | |
| - (void)loadView | |
| { | |
| // Root view for Settings tab | |
| NSView *root = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 360, 70)]; | |
| root.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; | |
| CGFloat padding = 8.0; | |
| CGFloat gap = 6.0; | |
| CGFloat fieldH = 22.0; | |
| // LABEL: "Grid formula:" | |
| NSTextField *label = [[NSTextField alloc] initWithFrame:NSZeroRect]; | |
| label.stringValue = @"Grid formula:"; | |
| label.bezeled = NO; | |
| label.drawsBackground = NO; | |
| label.editable = NO; | |
| label.selectable = NO; | |
| [label sizeToFit]; | |
| NSRect bounds = root.bounds; | |
| NSRect labelFrame = label.frame; | |
| labelFrame.origin.x = padding; | |
| labelFrame.origin.y = NSHeight(bounds) - padding - NSHeight(labelFrame); | |
| label.frame = labelFrame; | |
| label.autoresizingMask = NSViewMaxXMargin | NSViewMinYMargin; | |
| // TEXT FIELD: formula | |
| CGFloat fieldX = NSMaxX(labelFrame) + gap; | |
| CGFloat fieldRight = NSWidth(bounds) - padding; | |
| if (fieldRight < fieldX + 60.0) { | |
| fieldRight = fieldX + 60.0; | |
| } | |
| NSRect fieldFrame = NSMakeRect(fieldX, | |
| NSMinY(labelFrame) - 2.0, | |
| fieldRight - fieldX, | |
| fieldH); | |
| NSTextField *field = [[NSTextField alloc] initWithFrame:fieldFrame]; | |
| field.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; | |
| [field setTarget:self]; | |
| [field setAction:@selector(_formulaFieldChanged:)]; | |
| // CHECKBOX: "Use formula group" | |
| NSButton *checkbox = [[NSButton alloc] initWithFrame:NSZeroRect]; | |
| [checkbox setButtonType:NSSwitchButton]; | |
| checkbox.title = @"Use formula group"; | |
| [checkbox sizeToFit]; | |
| NSRect cbFrame = checkbox.frame; | |
| cbFrame.origin.x = padding; | |
| cbFrame.origin.y = padding; | |
| checkbox.frame = cbFrame; | |
| checkbox.autoresizingMask = NSViewMaxXMargin | NSViewMinYMargin; | |
| [checkbox setTarget:self]; | |
| [checkbox setAction:@selector(_useFormulaCheckboxChanged:)]; | |
| [root addSubview:label]; | |
| [root addSubview:field]; | |
| [root addSubview:checkbox]; | |
| self.formulaField = field; | |
| self.useFormulaCheckbox = checkbox; | |
| self.view = root; | |
| } | |
| - (void)viewWillAppear | |
| { | |
| [super viewWillAppear]; | |
| id plugin = self.plugIn; | |
| if (!plugin) | |
| return; | |
| @try { | |
| id f = [plugin valueForKey:@"groupFormula"]; | |
| if (f && f != [NSNull null]) { | |
| self.formulaField.stringValue = [f description]; | |
| } else { | |
| self.formulaField.stringValue = @""; | |
| } | |
| NSNumber *useFormula = [plugin valueForKey:@"useFormulaGroup"]; | |
| BOOL on = useFormula ? useFormula.boolValue : NO; | |
| self.useFormulaCheckbox.state = on ? NSControlStateValueOn : NSControlStateValueOff; | |
| } | |
| @catch (__unused id ex) { | |
| self.formulaField.stringValue = @""; | |
| self.useFormulaCheckbox.state = NSControlStateValueOff; | |
| } | |
| } | |
| #pragma mark - Actions | |
| - (void)_formulaFieldChanged:(id)sender | |
| { | |
| (void)sender; | |
| NSString *formula = [self.formulaField.stringValue | |
| stringByTrimmingCharactersInSet: | |
| [NSCharacterSet whitespaceAndNewlineCharacterSet]]; | |
| id plugin = self.plugIn; | |
| if (plugin) { | |
| @try { | |
| [plugin setValue:formula forKey:@"groupFormula"]; | |
| } @catch (__unused id ex) { | |
| } | |
| } | |
| } | |
| - (void)_useFormulaCheckboxChanged:(id)sender | |
| { | |
| (void)sender; | |
| BOOL on = (self.useFormulaCheckbox.state == NSControlStateValueOn); | |
| id plugin = self.plugIn; | |
| if (plugin) { | |
| @try { | |
| [plugin setValue:@(on) forKey:@"useFormulaGroup"]; | |
| } @catch (__unused id ex) { | |
| } | |
| } | |
| } | |
| @end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment