Last active
November 23, 2025 10:16
-
-
Save g-l-i-t-c-h-o-r-s-e/7a8f5051d75e00320eed37acd35778a1 to your computer and use it in GitHub Desktop.
Quartz Composer Beat Tapper with LFO, Beat and Pulse (and Optional BPM Alignment)
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) --- | |
| # Defaults changed to match the Tap Beat QC plug-in: | |
| # • Bundle/binary name: TapBeat | |
| # • Plug-in class: TapBeatPlugIn | |
| NAME="${NAME:-TapBeat}" | |
| CLASS="${CLASS:-TapBeatPlugIn}" | |
| SRC="${SRC:-${CLASS}.m}" | |
| PLUG="$NAME.plugin" | |
| OUT="$(pwd)/build-manual" | |
| 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; } | |
| [[ -f "$SRC" ]] || { echo "Source not found: $SRC"; exit 1; } | |
| [[ -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 AppKit | |
| -framework QuartzCore | |
| -framework Quartz | |
| -framework OpenGL | |
| ) | |
| # ---- compile (track what we actually built) ---- | |
| BUILT_I386=0 | |
| BUILT_X64=0 | |
| if [[ "$DO_I386" == "1" ]]; then | |
| echo "Compiling i386…" | |
| 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…" | |
| 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> | |
| <!-- Quartz Composer plug-in setup --> | |
| <key>QCPlugInClasses</key> | |
| <array> | |
| <string>${CLASS}</string> | |
| </array> | |
| <key>NSPrincipalClass</key> <string>QCPlugIn</string> | |
| <!-- Optional: allow dark appearance --> | |
| <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.)." |
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 <Quartz/Quartz.h> | |
| @interface TapBeatPlugIn : QCPlugIn | |
| { | |
| BOOL _lastTapValue; | |
| NSTimeInterval _tapStartTime; | |
| NSTimeInterval _beatInterval; | |
| NSTimeInterval _phaseBaseTime; | |
| NSInteger _lastCycleIndex; | |
| } | |
| // Inputs | |
| @property(assign) BOOL inputTap; // boolean trigger/tap | |
| @property(assign) double inputMin; // LFO range min | |
| @property(assign) double inputMax; // LFO range max | |
| @property(assign) double inputMultiplier; // tempo multiplier | |
| // Sync helpers | |
| @property(assign) double inputBPM; // 0 = disabled (pure tap), >0 = lock to this BPM | |
| @property(assign) double inputBeatsPerCycle; // length of one LFO cycle in beats (when BPM > 0) | |
| @property(assign) double inputSmoothing; // 0..1, smoothing for tapped interval (BPM == 0) | |
| // Outputs | |
| @property(assign) BOOL outputBeat; // boolean square wave at interval | |
| @property(assign) BOOL outputPulse; // one-frame pulse each beat | |
| @property(assign) double outputLFOUpDown; // triangle LFO | |
| @property(assign) double outputLFOUp; // ramp up | |
| @property(assign) double outputLFODown; // ramp down | |
| @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 "TapBeatPlugIn.h" | |
| #import <math.h> | |
| #define kDefaultBeatInterval 0.5 // seconds (fallback if no valid tap yet) | |
| @implementation TapBeatPlugIn | |
| // Inputs | |
| @dynamic inputTap, inputMin, inputMax, inputMultiplier; | |
| @dynamic inputBPM, inputBeatsPerCycle, inputSmoothing; | |
| // Outputs | |
| @dynamic outputBeat, outputPulse, outputLFOUpDown, outputLFOUp, outputLFODown; | |
| + (NSDictionary *)attributes | |
| { | |
| return @{ | |
| QCPlugInAttributeNameKey : @"Tap Beat", | |
| QCPlugInAttributeDescriptionKey : @"Tap to set a beat interval and generate synced LFOs, with BPM lock, smoothing, and pulse output." | |
| }; | |
| } | |
| + (QCPlugInExecutionMode)executionMode | |
| { | |
| // Processor – runs when inputs or time change | |
| return kQCPlugInExecutionModeProcessor; | |
| } | |
| + (QCPlugInTimeMode)timeMode | |
| { | |
| // Use the time parameter for LFOs | |
| return kQCPlugInTimeModeTimeBase; | |
| } | |
| + (NSArray *)sortedPropertyPortKeys | |
| { | |
| return @[ | |
| // Inputs | |
| @"inputTap", | |
| @"inputMin", | |
| @"inputMax", | |
| @"inputMultiplier", | |
| @"inputBPM", | |
| @"inputBeatsPerCycle", | |
| @"inputSmoothing", | |
| // Outputs | |
| @"outputBeat", | |
| @"outputPulse", | |
| @"outputLFOUpDown", | |
| @"outputLFOUp", | |
| @"outputLFODown" | |
| ]; | |
| } | |
| + (NSDictionary *)attributesForPropertyPortWithKey:(NSString *)key | |
| { | |
| // ---- Inputs ---- | |
| if ([key isEqualToString:@"inputTap"]) | |
| { | |
| return @{ QCPortAttributeNameKey : @"Tap" }; | |
| } | |
| if ([key isEqualToString:@"inputMin"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Range Min", | |
| QCPortAttributeDefaultValueKey : @0.0 | |
| }; | |
| } | |
| if ([key isEqualToString:@"inputMax"]) | |
| { | |
| return @{ | |
| QCPortAttributeNameKey : @"Range Max", | |
| QCPortAttributeDefaultValueKey : @1.0 | |
| }; | |
| } | |
| if ([key isEqualToString:@"inputMultiplier"]) | |
| { | |
| // Multiplier: 1.0 = as tapped, 2.0 = half speed, 0.5 = double speed | |
| return @{ | |
| QCPortAttributeNameKey : @"Multiplier", | |
| QCPortAttributeDefaultValueKey : @1.0 | |
| }; | |
| } | |
| if ([key isEqualToString:@"inputBPM"]) | |
| { | |
| // 0 = disabled (pure tap mode) | |
| return @{ | |
| QCPortAttributeNameKey : @"BPM", | |
| QCPortAttributeDefaultValueKey : @0.0 | |
| }; | |
| } | |
| if ([key isEqualToString:@"inputBeatsPerCycle"]) | |
| { | |
| // How many beats one LFO cycle lasts when BPM is used | |
| return @{ | |
| QCPortAttributeNameKey : @"Beats Per Cycle", | |
| QCPortAttributeDefaultValueKey : @1.0 | |
| }; | |
| } | |
| if ([key isEqualToString:@"inputSmoothing"]) | |
| { | |
| // 0 = no smoothing (use tap exactly) | |
| // 1 = maximum smoothing (change tempo slowly) | |
| return @{ | |
| QCPortAttributeNameKey : @"Smoothing", | |
| QCPortAttributeDefaultValueKey : @0.0 | |
| }; | |
| } | |
| // ---- Outputs ---- | |
| if ([key isEqualToString:@"outputBeat"]) | |
| { | |
| return @{ QCPortAttributeNameKey : @"Beat" }; | |
| } | |
| if ([key isEqualToString:@"outputPulse"]) | |
| { | |
| // Single-frame TRUE at each beat boundary | |
| return @{ QCPortAttributeNameKey : @"Pulse" }; | |
| } | |
| if ([key isEqualToString:@"outputLFOUpDown"]) | |
| { | |
| return @{ QCPortAttributeNameKey : @"LFO UpDown" }; | |
| } | |
| if ([key isEqualToString:@"outputLFOUp"]) | |
| { | |
| return @{ QCPortAttributeNameKey : @"LFO Up" }; | |
| } | |
| if ([key isEqualToString:@"outputLFODown"]) | |
| { | |
| return @{ QCPortAttributeNameKey : @"LFO Down" }; | |
| } | |
| return nil; | |
| } | |
| - (BOOL)startExecution:(id<QCPlugInContext>)context | |
| { | |
| _lastTapValue = NO; | |
| _tapStartTime = 0.0; | |
| _beatInterval = kDefaultBeatInterval; | |
| _phaseBaseTime = 0.0; | |
| _lastCycleIndex = 0; | |
| return YES; | |
| } | |
| - (void)stopExecution:(id<QCPlugInContext>)context | |
| { | |
| // nothing special to clean up | |
| } | |
| - (BOOL)execute:(id<QCPlugInContext>)context | |
| atTime:(NSTimeInterval)time | |
| withArguments:(NSDictionary *)arguments | |
| { | |
| // NOTE: The standard QC “Enable” port is handled by the engine; | |
| // if the user disables the patch, execute: won't be called. | |
| BOOL tap = self.inputTap; | |
| double bpm = self.inputBPM; | |
| // ---- TAP LOGIC: measure time between tap ON and tap OFF ---- | |
| if (tap && !_lastTapValue) | |
| { | |
| // Rising edge: start timing | |
| _tapStartTime = time; | |
| } | |
| else if (!tap && _lastTapValue) | |
| { | |
| // Falling edge: stop timing and use dt | |
| NSTimeInterval dt = time - _tapStartTime; | |
| if (dt > 0.01) // ignore very tiny taps | |
| { | |
| if (bpm > 0.0) | |
| { | |
| // BPM-LOCKED MODE: | |
| // - ignore dt for speed (we use BPM) | |
| // - use tap just to re-phase to "now" | |
| _phaseBaseTime = time; | |
| _lastCycleIndex = 0; | |
| } | |
| else | |
| { | |
| // PURE TAP MODE: | |
| // Always use dt for the base tempo, with optional smoothing. | |
| // Clamp smoothing 0..1 | |
| double s = self.inputSmoothing; | |
| if (s < 0.0) s = 0.0; | |
| if (s > 1.0) s = 1.0; | |
| // Convert "smoothing" to blend factor for the new tap: | |
| // s = 0 -> alpha = 1 (no smoothing, use tap exactly) | |
| // s = 1 -> alpha = 0 (max smoothing, change slowly) | |
| double alpha = 1.0 - s; | |
| if (_beatInterval <= 0.0) | |
| { | |
| // First valid tap | |
| _beatInterval = dt; | |
| } | |
| else | |
| { | |
| // Exponential smoothing around the tapped value | |
| _beatInterval = _beatInterval + (dt - _beatInterval) * alpha; | |
| } | |
| _phaseBaseTime = time; | |
| _lastCycleIndex = 0; | |
| } | |
| } | |
| } | |
| _lastTapValue = tap; | |
| if (_beatInterval <= 0.0) | |
| _beatInterval = kDefaultBeatInterval; | |
| // ---- Apply multiplier and BPM logic ---- | |
| double mult = self.inputMultiplier; | |
| if (mult <= 0.0) | |
| mult = 1.0; // safety | |
| double effectiveInterval; | |
| if (bpm > 0.0) | |
| { | |
| // BPM-locked: interval is derived from BPM and BeatsPerCycle | |
| double beatsPerCycle = self.inputBeatsPerCycle; | |
| if (beatsPerCycle <= 0.0) | |
| beatsPerCycle = 1.0; | |
| double baseBeat = 60.0 / bpm; // seconds per beat | |
| effectiveInterval = baseBeat * beatsPerCycle * mult; | |
| } | |
| else | |
| { | |
| // Pure tap mode: use the (possibly smoothed) tapped interval | |
| effectiveInterval = _beatInterval * mult; | |
| } | |
| if (effectiveInterval <= 0.0) | |
| effectiveInterval = kDefaultBeatInterval; | |
| // ---- Time and phase ---- | |
| double localTime = time - _phaseBaseTime; | |
| if (localTime < 0.0) | |
| localTime = 0.0; | |
| // Phase: 0..1 over one effectiveInterval | |
| double phase = fmod(localTime, effectiveInterval) / effectiveInterval; | |
| if (phase < 0.0) | |
| phase += 1.0; // safety | |
| // ---- Beat index and pulse detection ---- | |
| NSInteger cycleIndex = (NSInteger)floor(localTime / effectiveInterval + 1e-9); | |
| BOOL pulse = NO; | |
| if (cycleIndex != _lastCycleIndex) | |
| { | |
| pulse = YES; // TRUE for this frame only | |
| _lastCycleIndex = cycleIndex; | |
| } | |
| // ---- LFO shapes ---- | |
| // Triangle: 0 -> 1 -> 0 over one cycle | |
| double tri; | |
| if (phase < 0.5) | |
| tri = phase * 2.0; | |
| else | |
| tri = (1.0 - phase) * 2.0; | |
| // Ramp up: 0 -> 1 | |
| double up = phase; | |
| // Ramp down: 1 -> 0 | |
| double down = 1.0 - phase; | |
| // Map to user range | |
| double min = self.inputMin; | |
| double max = self.inputMax; | |
| double span = max - min; | |
| double valTri = min + tri * span; | |
| double valUp = min + up * span; | |
| double valDown = min + down * span; | |
| self.outputLFOUpDown = valTri; | |
| self.outputLFOUp = valUp; | |
| self.outputLFODown = valDown; | |
| // Boolean beat: square wave, 50% duty at scaled tempo | |
| self.outputBeat = (phase < 0.5); | |
| // One-frame pulse at each beat boundary | |
| self.outputPulse = pulse; | |
| return YES; | |
| } | |
| @end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment