diff --git a/.gitignore b/.gitignore index 796b96d..8696e49 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/build +# Build artifacts +build/ diff --git a/LidAngleSensor/AppDelegate.m b/LidAngleSensor/AppDelegate.m index f4a69de..d70ff17 100644 --- a/LidAngleSensor/AppDelegate.m +++ b/LidAngleSensor/AppDelegate.m @@ -53,47 +53,45 @@ - (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { } - (void)createWindow { - // Create the main window (taller to accommodate mode selection and audio controls) - NSRect windowFrame = NSMakeRect(100, 100, 450, 480); + // Create the main window (taller to accommodate mode + jitter controls) + NSRect windowFrame = NSMakeRect(100, 100, 500, 650); self.window = [[NSWindow alloc] initWithContentRect:windowFrame - styleMask:NSWindowStyleMaskTitled | - NSWindowStyleMaskClosable | + styleMask:NSWindowStyleMaskTitled | + NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable backing:NSBackingStoreBuffered defer:NO]; - [self.window setTitle:@"MacBook Lid Angle Sensor"]; [self.window makeKeyAndOrderFront:nil]; [self.window center]; - - // Create the content view + NSView *contentView = [[NSView alloc] initWithFrame:windowFrame]; [self.window setContentView:contentView]; - - // Create angle display label with tabular numbers (larger, light font) + + // Angle label self.angleLabel = [[NSLabel alloc] init]; [self.angleLabel setStringValue:@"Initializing..."]; [self.angleLabel setFont:[NSFont monospacedDigitSystemFontOfSize:48 weight:NSFontWeightLight]]; [self.angleLabel setAlignment:NSTextAlignmentCenter]; [self.angleLabel setTextColor:[NSColor systemBlueColor]]; [contentView addSubview:self.angleLabel]; - - // Create velocity display label with tabular numbers + + // Velocity label self.velocityLabel = [[NSLabel alloc] init]; [self.velocityLabel setStringValue:@"Velocity: 00 deg/s"]; [self.velocityLabel setFont:[NSFont monospacedDigitSystemFontOfSize:14 weight:NSFontWeightRegular]]; [self.velocityLabel setAlignment:NSTextAlignmentCenter]; [contentView addSubview:self.velocityLabel]; - - // Create status label + + // Status label self.statusLabel = [[NSLabel alloc] init]; [self.statusLabel setStringValue:@"Detecting sensor..."]; [self.statusLabel setFont:[NSFont systemFontOfSize:14]]; [self.statusLabel setAlignment:NSTextAlignmentCenter]; [self.statusLabel setTextColor:[NSColor secondaryLabelColor]]; [contentView addSubview:self.statusLabel]; - - // Create audio toggle button + + // Audio toggle self.audioToggleButton = [[NSButton alloc] init]; [self.audioToggleButton setTitle:@"Start Audio"]; [self.audioToggleButton setBezelStyle:NSBezelStyleRounded]; @@ -101,8 +99,8 @@ - (void)createWindow { [self.audioToggleButton setAction:@selector(toggleAudio:)]; [self.audioToggleButton setTranslatesAutoresizingMaskIntoConstraints:NO]; [contentView addSubview:self.audioToggleButton]; - - // Create audio status label + + // Audio status self.audioStatusLabel = [[NSLabel alloc] init]; [self.audioStatusLabel setStringValue:@""]; [self.audioStatusLabel setFont:[NSFont systemFontOfSize:14]]; @@ -130,30 +128,30 @@ - (void)createWindow { [contentView addSubview:self.modeSelector]; // Set up auto layout constraints + + // Constraints [NSLayoutConstraint activateConstraints:@[ - // Angle label (main display, now at top) + // Angle label [self.angleLabel.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:40], [self.angleLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], [self.angleLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], - + // Velocity label [self.velocityLabel.topAnchor constraintEqualToAnchor:self.angleLabel.bottomAnchor constant:15], [self.velocityLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], [self.velocityLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], - + // Status label [self.statusLabel.topAnchor constraintEqualToAnchor:self.velocityLabel.bottomAnchor constant:15], [self.statusLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], [self.statusLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], - - // Audio toggle button - [self.audioToggleButton.topAnchor constraintEqualToAnchor:self.statusLabel.bottomAnchor constant:25], + + // Audio toggle + [self.audioToggleButton.topAnchor constraintEqualToAnchor:self.statusLabel.bottomAnchor constant:15], [self.audioToggleButton.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], - [self.audioToggleButton.widthAnchor constraintEqualToConstant:120], - [self.audioToggleButton.heightAnchor constraintEqualToConstant:32], - - // Audio status label - [self.audioStatusLabel.topAnchor constraintEqualToAnchor:self.audioToggleButton.bottomAnchor constant:15], + + // Audio status + [self.audioStatusLabel.topAnchor constraintEqualToAnchor:self.audioToggleButton.bottomAnchor constant:10], [self.audioStatusLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], [self.audioStatusLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], @@ -167,13 +165,11 @@ - (void)createWindow { [self.modeSelector.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], [self.modeSelector.widthAnchor constraintEqualToConstant:200], [self.modeSelector.heightAnchor constraintEqualToConstant:28], - [self.modeSelector.bottomAnchor constraintLessThanOrEqualToAnchor:contentView.bottomAnchor constant:-20] ]]; } - (void)initializeLidSensor { self.lidSensor = [[LidAngleSensor alloc] init]; - if (self.lidSensor.isAvailable) { [self.statusLabel setStringValue:@"Sensor detected - Reading angle..."]; [self.statusLabel setTextColor:[NSColor systemGreenColor]]; @@ -188,7 +184,6 @@ - (void)initializeLidSensor { - (void)initializeAudioEngines { self.creakAudioEngine = [[CreakAudioEngine alloc] init]; self.thereminAudioEngine = [[ThereminAudioEngine alloc] init]; - if (self.creakAudioEngine && self.thereminAudioEngine) { [self.audioStatusLabel setStringValue:@""]; } else { @@ -253,75 +248,70 @@ - (id)currentAudioEngine { } - (void)startUpdatingDisplay { - // Update every 16ms (60Hz) for smooth real-time audio and display updates - self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.016 + // Faster updates (100Hz) to minimize control latency + self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.010 target:self selector:@selector(updateAngleDisplay) userInfo:nil repeats:YES]; + self.updateTimer.tolerance = 0.0; + [[NSRunLoop mainRunLoop] addTimer:self.updateTimer forMode:NSRunLoopCommonModes]; } -- (void)updateAngleDisplay { - if (!self.lidSensor.isAvailable) { - return; - } - - double angle = [self.lidSensor lidAngle]; - - if (angle == -2.0) { +-(void)updateAngleDisplay { + if (!self.lidSensor.isAvailable) return; + double rawAngle = [self.lidSensor lidAngle]; + if (rawAngle == -2.0) { [self.angleLabel setStringValue:@"Read Error"]; [self.angleLabel setTextColor:[NSColor systemOrangeColor]]; [self.statusLabel setStringValue:@"Failed to read sensor data"]; [self.statusLabel setTextColor:[NSColor systemOrangeColor]]; - } else { - [self.angleLabel setStringValue:[NSString stringWithFormat:@"%.1f°", angle]]; - [self.angleLabel setTextColor:[NSColor systemBlueColor]]; - - // Update current audio engine with new angle - id currentEngine = [self currentAudioEngine]; - if (currentEngine) { - [currentEngine updateWithLidAngle:angle]; - - // Update velocity display with leading zero and whole numbers - double velocity = [currentEngine currentVelocity]; - int roundedVelocity = (int)round(velocity); - if (roundedVelocity < 100) { - [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %02d deg/s", roundedVelocity]]; + return; + } + + // Update engine first, then display stabilized angle (creak) or raw (theremin) + double displayAngle = rawAngle; + if (self.currentAudioMode == AudioModeCreak) { + if (self.creakAudioEngine) { + [self.creakAudioEngine updateWithLidAngle:rawAngle]; + displayAngle = self.creakAudioEngine.currentStabilizedAngle; + double velocity = self.creakAudioEngine.currentVelocity; + int rv = (int)llround(velocity); + if (rv < 100) { + [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %02d deg/s", rv]]; } else { - [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %d deg/s", roundedVelocity]]; + [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %d deg/s", rv]]; } - - // Show audio parameters when running - if ([currentEngine isEngineRunning]) { - if (self.currentAudioMode == AudioModeCreak) { - double gain = [currentEngine currentGain]; - double rate = [currentEngine currentRate]; - [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Gain: %.2f, Rate: %.2f", gain, rate]]; - } else if (self.currentAudioMode == AudioModeTheremin) { - double frequency = [currentEngine currentFrequency]; - double volume = [currentEngine currentVolume]; - [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Freq: %.1f Hz, Vol: %.2f", frequency, volume]]; - } + if (self.creakAudioEngine.isEngineRunning) { + [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Gain: %.2f, Rate: %.2f", self.creakAudioEngine.currentGain, self.creakAudioEngine.currentRate]]; } } - - // Provide contextual status based on angle - NSString *status; - if (angle < 5.0) { - status = @"Lid is closed"; - } else if (angle < 45.0) { - status = @"Lid slightly open"; - } else if (angle < 90.0) { - status = @"Lid partially open"; - } else if (angle < 120.0) { - status = @"Lid mostly open"; - } else { - status = @"Lid fully open"; + } else { // Theremin + if (self.thereminAudioEngine) { + [self.thereminAudioEngine updateWithLidAngle:rawAngle]; + double velocity = self.thereminAudioEngine.currentVelocity; + int rv = (int)llround(velocity); + if (rv < 100) { + [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %02d deg/s", rv]]; + } else { + [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %d deg/s", rv]]; + } + if (self.thereminAudioEngine.isEngineRunning) { + [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Freq: %.1f Hz, Vol: %.2f", self.thereminAudioEngine.currentFrequency, self.thereminAudioEngine.currentVolume]]; + } } - - [self.statusLabel setStringValue:status]; - [self.statusLabel setTextColor:[NSColor secondaryLabelColor]]; } + [self.angleLabel setStringValue:[NSString stringWithFormat:@"%.1f°", displayAngle]]; + [self.angleLabel setTextColor:[NSColor systemBlueColor]]; + + NSString *status; + if (displayAngle < 5.0) status = @"Lid is closed"; + else if (displayAngle < 45.0) status = @"Lid slightly open"; + else if (displayAngle < 90.0) status = @"Lid partially open"; + else if (displayAngle < 135.0) status = @"Lid mostly open"; + else status = @"Lid fully open"; + [self.statusLabel setStringValue:status]; + [self.statusLabel setTextColor:[NSColor secondaryLabelColor]]; } @end diff --git a/LidAngleSensor/CreakAudioEngine.h b/LidAngleSensor/CreakAudioEngine.h index 3da450e..a13f32d 100644 --- a/LidAngleSensor/CreakAudioEngine.h +++ b/LidAngleSensor/CreakAudioEngine.h @@ -29,6 +29,14 @@ @property (nonatomic, assign, readonly) double currentVelocity; @property (nonatomic, assign, readonly) double currentGain; @property (nonatomic, assign, readonly) double currentRate; +@property (nonatomic, assign, readonly) double currentStabilizedAngle; // Angle after hysteresis filter + +// Jitter filter configuration (live‑tunable) +@property (nonatomic, assign) BOOL jitterFilterEnabled; // Enable/disable jitter suppression +@property (nonatomic, assign) double jitterAmplitudeDeg; // Peak‑to‑peak amplitude threshold (deg) +@property (nonatomic, assign) double jitterTimeWindowMs; // Time window to consider (ms) +@property (nonatomic, assign) double jitterMinDeltaDeg; // Min delta to count a sign flip (deg) +@property (nonatomic, assign) NSUInteger jitterMinSignFlips; // Required alternations in window /** * Initialize the audio engine and load audio files. @@ -59,4 +67,7 @@ */ - (void)setAngularVelocity:(double)velocity; +/** Reset internal jitter history (e.g., when toggling the filter). */ +- (void)resetJitterHistory; + @end diff --git a/LidAngleSensor/CreakAudioEngine.m b/LidAngleSensor/CreakAudioEngine.m index 02cdedd..9aa133e 100644 --- a/LidAngleSensor/CreakAudioEngine.m +++ b/LidAngleSensor/CreakAudioEngine.m @@ -6,9 +6,10 @@ // #import "CreakAudioEngine.h" +#import -// Audio parameter mapping constants -static const double kDeadzone = 1.0; // deg/s - below this: treat as still +// Audio parameter mapping constants (tuned for immediate response) +static const double kDeadzone = 0.10; // deg/s - minimal deadzone to avoid chatter static const double kVelocityFull = 10.0; // deg/s - max creak volume at/under this velocity static const double kVelocityQuiet = 100.0; // deg/s - silent by/over this velocity (fast movement) @@ -16,15 +17,25 @@ static const double kMinRate = 0.80; // Minimum varispeed rate (lower pitch for slow movement) static const double kMaxRate = 1.10; // Maximum varispeed rate (higher pitch for fast movement) -// Smoothing and timing constants -static const double kAngleSmoothingFactor = 0.05; // Heavy smoothing for sensor noise (5% new, 95% old) -static const double kVelocitySmoothingFactor = 0.3; // Moderate smoothing for velocity -static const double kMovementThreshold = 0.5; // Minimum angle change to register as movement (degrees) -static const double kGainRampTimeMs = 50.0; // Gain ramping time constant (milliseconds) -static const double kRateRampTimeMs = 80.0; // Rate ramping time constant (milliseconds) -static const double kMovementTimeoutMs = 50.0; // Time before aggressive velocity decay (milliseconds) -static const double kVelocityDecayFactor = 0.5; // Decay rate when no movement detected -static const double kAdditionalDecayFactor = 0.8; // Additional decay after timeout +// Smoothing and timing constants (reduced for low latency) +static const double kAngleSmoothingFactor = 0.85; // Favor new data heavily for instant reaction +static const double kVelocitySmoothingFactor = 0.9; // Fast velocity update with slight smoothing +static const double kMovementThreshold = 0.05; // Detect very small movements (degrees) +static const double kGainRampTimeMs = 1.0; // Near-instant gain changes +static const double kRateRampTimeMs = 1.0; // Near-instant pitch/tempo changes +static const double kMovementTimeoutMs = 30.0; // Slightly shorter timeout +static const double kVelocityDecayFactor = 0.65; // Mild decay when idle +static const double kAdditionalDecayFactor = 0.85; // Mild extra decay after timeout + +// Rapid flip/jitter suppression (defaults) +// Some sensors can rapidly flip ±4° around a plateau, producing large +// instantaneous velocities but negligible net movement. Detect this +// pattern over a short window and treat it as no movement. +static const NSUInteger kJitterWindowMaxCount = 6; // up to last 6 samples +static const double kDefaultJitterAmplitudeDeg = 8.5; // peak-to-peak ≤ 8.5° (≈±4°) +static const double kDefaultJitterTimeWindowMs = 120.0;// samples within last 120ms +static const double kDefaultJitterMinDeltaDeg = 0.3; // min delta to count a sign flip +static const NSUInteger kDefaultJitterMinSignFlips = 2;// require at least 2 alternations @interface CreakAudioEngine () @@ -49,6 +60,15 @@ @interface CreakAudioEngine () @property (nonatomic, assign) BOOL isFirstUpdate; @property (nonatomic, assign) NSTimeInterval lastMovementTime; +// History for jitter detection +@property (nonatomic, strong) NSMutableArray *angleHistory; +@property (nonatomic, strong) NSMutableArray *timeHistory; + +// Hysteresis/plateau stabilizer +@property (nonatomic, assign) double stabilizedAngle; +@property (nonatomic, assign) BOOL hasStabilizedAngle; +@property (nonatomic, assign) NSTimeInterval hysteresisOutsideStart; + @end @implementation CreakAudioEngine @@ -66,6 +86,18 @@ - (instancetype)init { _targetRate = 1.0; _currentGain = 0.0; _currentRate = 1.0; + _angleHistory = [NSMutableArray arrayWithCapacity:kJitterWindowMaxCount]; + _timeHistory = [NSMutableArray arrayWithCapacity:kJitterWindowMaxCount]; + _hasStabilizedAngle = NO; + _stabilizedAngle = 0.0; + _hysteresisOutsideStart = 0.0; + + // Jitter defaults + _jitterFilterEnabled = YES; + _jitterAmplitudeDeg = kDefaultJitterAmplitudeDeg; + _jitterTimeWindowMs = kDefaultJitterTimeWindowMs; + _jitterMinDeltaDeg = kDefaultJitterMinDeltaDeg; + _jitterMinSignFlips = kDefaultJitterMinSignFlips; if (![self setupAudioEngine]) { NSLog(@"[CreakAudioEngine] Failed to setup audio engine"); @@ -84,6 +116,11 @@ - (void)dealloc { [self stopEngine]; } +- (void)resetJitterHistory { + [self.angleHistory removeAllObjects]; + [self.timeHistory removeAllObjects]; +} + #pragma mark - Audio Engine Setup - (BOOL)setupAudioEngine { @@ -195,6 +232,8 @@ - (void)updateWithLidAngle:(double)lidAngle { if (self.isFirstUpdate) { self.lastLidAngle = lidAngle; self.smoothedLidAngle = lidAngle; + self.stabilizedAngle = lidAngle; + self.hasStabilizedAngle = YES; self.lastUpdateTime = currentTime; self.lastMovementTime = currentTime; self.isFirstUpdate = NO; @@ -208,6 +247,93 @@ - (void)updateWithLidAngle:(double)lidAngle { self.lastUpdateTime = currentTime; return; } + + BOOL isJitter = NO; + if (self.jitterFilterEnabled) { + // Maintain short history for jitter detection + [self.angleHistory addObject:@(lidAngle)]; + [self.timeHistory addObject:@(currentTime)]; + // Trim to window by time + double windowStart = currentTime - (self.jitterTimeWindowMs / 1000.0); + while (self.timeHistory.count > 0 && self.timeHistory.firstObject.doubleValue < windowStart) { + [self.angleHistory removeObjectAtIndex:0]; + [self.timeHistory removeObjectAtIndex:0]; + } + // Trim to max count + while (self.angleHistory.count > kJitterWindowMaxCount) { + [self.angleHistory removeObjectAtIndex:0]; + [self.timeHistory removeObjectAtIndex:0]; + } + + // Detect rapid flip jitter pattern and freeze angle if present + if (self.angleHistory.count >= 4) { + double minA = DBL_MAX, maxA = -DBL_MAX; + for (NSNumber *n in self.angleHistory) { + double v = n.doubleValue; + if (v < minA) minA = v; + if (v > maxA) maxA = v; + } + double peakToPeak = maxA - minA; + if (peakToPeak <= self.jitterAmplitudeDeg) { + // Count sign alternations of successive deltas + NSInteger flips = 0; + double prevSign = 0.0; + for (NSUInteger i = 1; i < self.angleHistory.count; i++) { + double d = self.angleHistory[i].doubleValue - self.angleHistory[i-1].doubleValue; + if (fabs(d) < self.jitterMinDeltaDeg) continue; + double s = (d > 0.0) ? 1.0 : -1.0; + if (prevSign == 0.0) { + prevSign = s; + } else if (s != prevSign) { + flips++; + prevSign = s; + } + } + if (flips >= (NSInteger)self.jitterMinSignFlips) { + isJitter = YES; + } + } + } + if (isJitter) { + // Treat as no movement: freeze to last stable (hysteresis) angle + lidAngle = self.hasStabilizedAngle ? self.stabilizedAngle : self.lastLidAngle; + } + } + + // Apply hysteresis/plateau stabilization to suppress ±small back-and-forth around a center + // Schmitt-trigger style with persistence + static const double kHystInnerDeg = 2.0; // within this band -> clamp to stable + static const double kHystOuterDeg = 5.0; // outside this band -> update immediately + static const double kHystPersistMs = 80.0; // otherwise require persistence before updating + + if (!self.hasStabilizedAngle) { + self.stabilizedAngle = lidAngle; + self.hasStabilizedAngle = YES; + } else { + double diff = lidAngle - self.stabilizedAngle; + double ad = fabs(diff); + if (ad <= kHystInnerDeg) { + // Inside inner band: keep stable, reset persistence timer + self.hysteresisOutsideStart = 0.0; + lidAngle = self.stabilizedAngle; + } else if (ad >= kHystOuterDeg) { + // Large change: accept immediately + self.stabilizedAngle = lidAngle; + self.hysteresisOutsideStart = 0.0; + } else { + // Between inner and outer: require persistence + if (self.hysteresisOutsideStart == 0.0) { + self.hysteresisOutsideStart = currentTime; + } + double elapsed = currentTime - self.hysteresisOutsideStart; + if (elapsed >= (kHystPersistMs / 1000.0)) { + self.stabilizedAngle = lidAngle; + self.hysteresisOutsideStart = 0.0; + } else { + lidAngle = self.stabilizedAngle; + } + } + } // Stage 1: Smooth the raw angle input to eliminate sensor jitter self.smoothedLidAngle = (kAngleSmoothingFactor * lidAngle) + @@ -226,7 +352,7 @@ - (void)updateWithLidAngle:(double)lidAngle { } // Stage 3: Apply velocity smoothing and decay - if (instantVelocity > 0.0) { + if (!isJitter && instantVelocity > 0.0) { // Real movement detected - apply moderate smoothing self.smoothedVelocity = (kVelocitySmoothingFactor * instantVelocity) + ((1.0 - kVelocitySmoothingFactor) * self.smoothedVelocity); @@ -325,5 +451,8 @@ - (double)currentRate { return _currentRate; } -@end +- (double)currentStabilizedAngle { + return self.hasStabilizedAngle ? self.stabilizedAngle : self.smoothedLidAngle; +} +@end