Skip to content

Instantly share code, notes, and snippets.

@g-l-i-t-c-h-o-r-s-e
Last active November 23, 2025 10:16
Show Gist options
  • Select an option

  • Save g-l-i-t-c-h-o-r-s-e/7a8f5051d75e00320eed37acd35778a1 to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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.)."
#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
#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