Audio SFX

This commit is contained in:
samhenrigold
2025-09-06 16:00:05 -04:00
parent e964eac899
commit 4b1159aa34
13 changed files with 528 additions and 12 deletions

View File

@@ -7,11 +7,16 @@
#import "AppDelegate.h"
#import "LidAngleSensor.h"
#import "CreakAudioEngine.h"
@interface AppDelegate ()
@property (strong, nonatomic) LidAngleSensor *lidSensor;
@property (strong, nonatomic) CreakAudioEngine *audioEngine;
@property (strong, nonatomic) NSTextField *angleLabel;
@property (strong, nonatomic) NSTextField *statusLabel;
@property (strong, nonatomic) NSTextField *velocityLabel;
@property (strong, nonatomic) NSTextField *audioStatusLabel;
@property (strong, nonatomic) NSButton *audioToggleButton;
@property (strong, nonatomic) NSTimer *updateTimer;
@end
@@ -20,12 +25,14 @@
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
[self createWindow];
[self initializeLidSensor];
[self initializeAudioEngine];
[self startUpdatingDisplay];
}
- (void)applicationWillTerminate:(NSNotification *)aNotification {
[self.updateTimer invalidate];
[self.lidSensor stopLidAngleUpdates];
[self.audioEngine stopEngine];
}
- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app {
@@ -33,8 +40,8 @@
}
- (void)createWindow {
// Create the main window
NSRect windowFrame = NSMakeRect(100, 100, 400, 300);
// Create the main window (taller to accommodate audio controls)
NSRect windowFrame = NSMakeRect(100, 100, 450, 420);
self.window = [[NSWindow alloc] initWithContentRect:windowFrame
styleMask:NSWindowStyleMaskTitled |
NSWindowStyleMaskClosable |
@@ -42,7 +49,7 @@
backing:NSBackingStoreBuffered
defer:NO];
[self.window setTitle:@"MacBook Lid Angle Sensor"];
[self.window setTitle:@"MacBook Lid Creak Sensor"];
[self.window makeKeyAndOrderFront:nil];
[self.window center];
@@ -51,8 +58,8 @@
[self.window setContentView:contentView];
// Create title label
NSTextField *titleLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 220, 300, 40)];
[titleLabel setStringValue:@"MacBook Lid Angle Sensor"];
NSTextField *titleLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 360, 350, 40)];
[titleLabel setStringValue:@"MacBook Lid Creak Sensor"];
[titleLabel setFont:[NSFont boldSystemFontOfSize:18]];
[titleLabel setBezeled:NO];
[titleLabel setDrawsBackground:NO];
@@ -62,9 +69,9 @@
[contentView addSubview:titleLabel];
// Create angle display label
self.angleLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 150, 300, 50)];
self.angleLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 280, 350, 40)];
[self.angleLabel setStringValue:@"Initializing..."];
[self.angleLabel setFont:[NSFont monospacedSystemFontOfSize:24 weight:NSFontWeightMedium]];
[self.angleLabel setFont:[NSFont monospacedSystemFontOfSize:20 weight:NSFontWeightMedium]];
[self.angleLabel setBezeled:NO];
[self.angleLabel setDrawsBackground:NO];
[self.angleLabel setEditable:NO];
@@ -73,8 +80,20 @@
[self.angleLabel setTextColor:[NSColor systemBlueColor]];
[contentView addSubview:self.angleLabel];
// Create velocity display label
self.velocityLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 240, 350, 30)];
[self.velocityLabel setStringValue:@"Velocity: 0.0 deg/s"];
[self.velocityLabel setFont:[NSFont monospacedSystemFontOfSize:14 weight:NSFontWeightRegular]];
[self.velocityLabel setBezeled:NO];
[self.velocityLabel setDrawsBackground:NO];
[self.velocityLabel setEditable:NO];
[self.velocityLabel setSelectable:NO];
[self.velocityLabel setAlignment:NSTextAlignmentCenter];
[self.velocityLabel setTextColor:[NSColor systemGreenColor]];
[contentView addSubview:self.velocityLabel];
// Create status label
self.statusLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 100, 300, 30)];
self.statusLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 200, 350, 30)];
[self.statusLabel setStringValue:@"Detecting sensor..."];
[self.statusLabel setFont:[NSFont systemFontOfSize:14]];
[self.statusLabel setBezeled:NO];
@@ -85,9 +104,29 @@
[self.statusLabel setTextColor:[NSColor secondaryLabelColor]];
[contentView addSubview:self.statusLabel];
// Create audio toggle button
self.audioToggleButton = [[NSButton alloc] initWithFrame:NSMakeRect(175, 150, 100, 30)];
[self.audioToggleButton setTitle:@"Start Audio"];
[self.audioToggleButton setBezelStyle:NSBezelStyleRounded];
[self.audioToggleButton setTarget:self];
[self.audioToggleButton setAction:@selector(toggleAudio:)];
[contentView addSubview:self.audioToggleButton];
// Create audio status label
self.audioStatusLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 110, 350, 30)];
[self.audioStatusLabel setStringValue:@"Audio: Stopped"];
[self.audioStatusLabel setFont:[NSFont systemFontOfSize:14]];
[self.audioStatusLabel setBezeled:NO];
[self.audioStatusLabel setDrawsBackground:NO];
[self.audioStatusLabel setEditable:NO];
[self.audioStatusLabel setSelectable:NO];
[self.audioStatusLabel setAlignment:NSTextAlignmentCenter];
[self.audioStatusLabel setTextColor:[NSColor secondaryLabelColor]];
[contentView addSubview:self.audioStatusLabel];
// Create info label
NSTextField *infoLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 30, 300, 60)];
[infoLabel setStringValue:@"This app reads the MacBook's internal lid angle sensor.\n0° = Closed, ~180° = Fully Open"];
NSTextField *infoLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(50, 30, 350, 60)];
[infoLabel setStringValue:@"Real-time door creak audio responds to lid movement.\nSlow movement = louder creak, fast movement = silent."];
[infoLabel setFont:[NSFont systemFontOfSize:12]];
[infoLabel setBezeled:NO];
[infoLabel setDrawsBackground:NO];
@@ -112,9 +151,40 @@
}
}
- (void)initializeAudioEngine {
self.audioEngine = [[CreakAudioEngine alloc] init];
if (self.audioEngine) {
[self.audioStatusLabel setStringValue:@"Audio: Ready (stopped)"];
[self.audioStatusLabel setTextColor:[NSColor systemOrangeColor]];
} else {
[self.audioStatusLabel setStringValue:@"Audio: Failed to initialize"];
[self.audioStatusLabel setTextColor:[NSColor systemRedColor]];
[self.audioToggleButton setEnabled:NO];
}
}
- (IBAction)toggleAudio:(id)sender {
if (!self.audioEngine) {
return;
}
if (self.audioEngine.isEngineRunning) {
[self.audioEngine stopEngine];
[self.audioToggleButton setTitle:@"Start Audio"];
[self.audioStatusLabel setStringValue:@"Audio: Stopped"];
[self.audioStatusLabel setTextColor:[NSColor systemOrangeColor]];
} else {
[self.audioEngine startEngine];
[self.audioToggleButton setTitle:@"Stop Audio"];
[self.audioStatusLabel setStringValue:@"Audio: Running"];
[self.audioStatusLabel setTextColor:[NSColor systemGreenColor]];
}
}
- (void)startUpdatingDisplay {
// Update the display every 100ms for smooth real-time updates
self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.1
// Update every 16ms (60Hz) for smooth real-time audio and display updates
self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.016
target:self
selector:@selector(updateAngleDisplay)
userInfo:nil
@@ -137,6 +207,34 @@
[self.angleLabel setStringValue:[NSString stringWithFormat:@"%.1f°", angle]];
[self.angleLabel setTextColor:[NSColor systemBlueColor]];
// Update audio engine with new angle
if (self.audioEngine) {
[self.audioEngine updateWithLidAngle:angle];
// Update velocity display
double velocity = self.audioEngine.currentVelocity;
[self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %.1f deg/s", velocity]];
// Color velocity based on magnitude
double absVelocity = fabs(velocity);
if (absVelocity < 0.3) {
[self.velocityLabel setTextColor:[NSColor systemGrayColor]];
} else if (absVelocity < 2.0) {
[self.velocityLabel setTextColor:[NSColor systemGreenColor]];
} else if (absVelocity < 10.0) {
[self.velocityLabel setTextColor:[NSColor systemYellowColor]];
} else {
[self.velocityLabel setTextColor:[NSColor systemRedColor]];
}
// Update audio status with gain/rate info if running
if (self.audioEngine.isEngineRunning) {
double gain = self.audioEngine.currentGain;
double rate = self.audioEngine.currentRate;
[self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Audio: Running (Gain: %.2f, Rate: %.2f)", gain, rate]];
}
}
// Provide contextual status based on angle
NSString *status;
if (angle < 5.0) {

View File

@@ -0,0 +1,62 @@
//
// CreakAudioEngine.h
// LidAngleSensor
//
// Created by Sam on 2025-01-16.
//
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
/**
* CreakAudioEngine provides real-time door creak audio that responds to MacBook lid angle changes.
*
* Features:
* - Real-time angular velocity calculation with multi-stage noise filtering
* - Dynamic gain and pitch/tempo mapping based on movement speed
* - Smooth parameter ramping to avoid audio artifacts
* - Deadzone to prevent chattering at minimal movement
* - Optimized for low-latency, responsive audio feedback
*
* Audio Behavior:
* - Slow movement (1-10 deg/s): Maximum creak volume
* - Medium movement (10-100 deg/s): Gradual fade to silence
* - Fast movement (100+ deg/s): Silent
*/
@interface CreakAudioEngine : NSObject
@property (nonatomic, assign, readonly) BOOL isEngineRunning;
@property (nonatomic, assign, readonly) double currentVelocity;
@property (nonatomic, assign, readonly) double currentGain;
@property (nonatomic, assign, readonly) double currentRate;
/**
* Initialize the audio engine and load audio files.
* @return Initialized engine instance, or nil if initialization failed
*/
- (instancetype)init;
/**
* Start the audio engine and begin playback.
*/
- (void)startEngine;
/**
* Stop the audio engine and halt playback.
*/
- (void)stopEngine;
/**
* Update the creak audio based on new lid angle measurement.
* This method calculates angular velocity, applies smoothing, and updates audio parameters.
* @param lidAngle Current lid angle in degrees
*/
- (void)updateWithLidAngle:(double)lidAngle;
/**
* Manually set the angular velocity (for testing purposes).
* @param velocity Angular velocity in degrees per second
*/
- (void)setAngularVelocity:(double)velocity;
@end

View File

@@ -0,0 +1,356 @@
//
// CreakAudioEngine.m
// LidAngleSensor
//
// Created by Sam on 2025-01-16.
//
#import "CreakAudioEngine.h"
// Audio parameter mapping constants
static const double kDeadzone = 1.0; // deg/s - below this: treat as still
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)
// Pitch variation constants
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
@interface CreakAudioEngine ()
// Audio engine components
@property (nonatomic, strong) AVAudioEngine *audioEngine;
@property (nonatomic, strong) AVAudioPlayerNode *creakPlayerNode;
@property (nonatomic, strong) AVAudioUnitVarispeed *varispeadUnit;
@property (nonatomic, strong) AVAudioMixerNode *mixerNode;
// Audio files
@property (nonatomic, strong) AVAudioFile *creakLoopFile;
// State tracking
@property (nonatomic, assign) double lastLidAngle;
@property (nonatomic, assign) double smoothedLidAngle;
@property (nonatomic, assign) double lastUpdateTime;
@property (nonatomic, assign) double smoothedVelocity;
@property (nonatomic, assign) double targetGain;
@property (nonatomic, assign) double targetRate;
@property (nonatomic, assign) double currentGain;
@property (nonatomic, assign) double currentRate;
@property (nonatomic, assign) BOOL isFirstUpdate;
@property (nonatomic, assign) NSTimeInterval lastMovementTime;
@end
@implementation CreakAudioEngine
- (instancetype)init {
self = [super init];
if (self) {
_isFirstUpdate = YES;
_lastUpdateTime = CACurrentMediaTime();
_lastMovementTime = CACurrentMediaTime();
_lastLidAngle = 0.0;
_smoothedLidAngle = 0.0;
_smoothedVelocity = 0.0;
_targetGain = 0.0;
_targetRate = 1.0;
_currentGain = 0.0;
_currentRate = 1.0;
if (![self setupAudioEngine]) {
NSLog(@"[CreakAudioEngine] Failed to setup audio engine");
return nil;
}
if (![self loadAudioFiles]) {
NSLog(@"[CreakAudioEngine] Failed to load audio files");
return nil;
}
NSLog(@"[CreakAudioEngine] Successfully initialized");
}
return self;
}
- (void)dealloc {
[self stopEngine];
}
#pragma mark - Audio Engine Setup
- (BOOL)setupAudioEngine {
self.audioEngine = [[AVAudioEngine alloc] init];
// Create audio nodes
self.creakPlayerNode = [[AVAudioPlayerNode alloc] init];
self.varispeadUnit = [[AVAudioUnitVarispeed alloc] init];
self.mixerNode = self.audioEngine.mainMixerNode;
// Attach nodes to engine
[self.audioEngine attachNode:self.creakPlayerNode];
[self.audioEngine attachNode:self.varispeadUnit];
// Audio connections will be made after loading the file to use its native format
return YES;
}
- (BOOL)loadAudioFiles {
NSBundle *bundle = [NSBundle mainBundle];
// Load creak loop file
NSString *creakPath = [bundle pathForResource:@"CREAK_LOOP" ofType:@"wav"];
if (!creakPath) {
NSLog(@"[CreakAudioEngine] Could not find CREAK_LOOP.wav");
return NO;
}
NSError *error;
NSURL *creakURL = [NSURL fileURLWithPath:creakPath];
self.creakLoopFile = [[AVAudioFile alloc] initForReading:creakURL error:&error];
if (!self.creakLoopFile) {
NSLog(@"[CreakAudioEngine] Failed to load CREAK_LOOP.wav: %@", error.localizedDescription);
return NO;
}
// Now connect the audio graph using the file's native format
AVAudioFormat *fileFormat = self.creakLoopFile.processingFormat;
NSLog(@"[CreakAudioEngine] File format: %.0f Hz, %lu channels",
fileFormat.sampleRate, (unsigned long)fileFormat.channelCount);
// Connect audio graph: CreakPlayer -> Varispeed -> Mixer
[self.audioEngine connect:self.creakPlayerNode to:self.varispeadUnit format:fileFormat];
[self.audioEngine connect:self.varispeadUnit to:self.mixerNode format:fileFormat];
NSLog(@"[CreakAudioEngine] Successfully loaded creak loop and connected audio graph");
return YES;
}
#pragma mark - Engine Control
- (void)startEngine {
if (self.isEngineRunning) {
return;
}
NSError *error;
if (![self.audioEngine startAndReturnError:&error]) {
NSLog(@"[CreakAudioEngine] Failed to start audio engine: %@", error.localizedDescription);
return;
}
// Start looping the creak sound
[self startCreakLoop];
NSLog(@"[CreakAudioEngine] Audio engine started");
}
- (void)stopEngine {
if (!self.isEngineRunning) {
return;
}
[self.creakPlayerNode stop];
[self.audioEngine stop];
NSLog(@"[CreakAudioEngine] Audio engine stopped");
}
- (BOOL)isEngineRunning {
return self.audioEngine.isRunning;
}
#pragma mark - Creak Loop Management
- (void)startCreakLoop {
if (!self.creakPlayerNode || !self.creakLoopFile) {
return;
}
// Reset file position to beginning
self.creakLoopFile.framePosition = 0;
// Schedule the creak loop to play continuously
AVAudioFrameCount frameCount = (AVAudioFrameCount)self.creakLoopFile.length;
AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.creakLoopFile.processingFormat
frameCapacity:frameCount];
NSError *error;
if (![self.creakLoopFile readIntoBuffer:buffer error:&error]) {
NSLog(@"[CreakAudioEngine] Failed to read creak loop into buffer: %@", error.localizedDescription);
return;
}
[self.creakPlayerNode scheduleBuffer:buffer atTime:nil options:AVAudioPlayerNodeBufferLoops completionHandler:nil];
[self.creakPlayerNode play];
// Set initial volume to 0 (will be controlled by gain)
self.creakPlayerNode.volume = 0.0;
}
#pragma mark - Velocity Calculation and Parameter Mapping
- (void)updateWithLidAngle:(double)lidAngle {
double currentTime = CACurrentMediaTime();
if (self.isFirstUpdate) {
self.lastLidAngle = lidAngle;
self.smoothedLidAngle = lidAngle;
self.lastUpdateTime = currentTime;
self.lastMovementTime = currentTime;
self.isFirstUpdate = NO;
return;
}
// Calculate time delta
double deltaTime = currentTime - self.lastUpdateTime;
if (deltaTime <= 0 || deltaTime > 1.0) {
// Skip if time delta is invalid or too large (likely app was backgrounded)
self.lastUpdateTime = currentTime;
return;
}
// Apply heavy smoothing to the angle input to eliminate sensor jitter
double angleSmoothingFactor = 0.05; // Very heavy smoothing (5% new, 95% old)
self.smoothedLidAngle = (angleSmoothingFactor * lidAngle) +
((1.0 - angleSmoothingFactor) * self.smoothedLidAngle);
// Calculate angular velocity using smoothed angle
double deltaAngle = self.smoothedLidAngle - self.lastLidAngle;
// Calculate instantaneous velocity
double instantVelocity;
// Much higher threshold to eliminate jitter completely
if (fabs(deltaAngle) < 0.5) { // Ignore changes smaller than 0.5 degrees
deltaAngle = 0.0;
instantVelocity = 0.0;
} else {
instantVelocity = deltaAngle / deltaTime;
// Update last angle only when we have significant movement
self.lastLidAngle = self.smoothedLidAngle;
}
// Use absolute value early to avoid sign issues
instantVelocity = fabs(instantVelocity);
// Apply velocity smoothing only when there's actual movement
if (instantVelocity > 0.0) {
double velocitySmoothingFactor = 0.3; // Moderate smoothing for real movement
self.smoothedVelocity = (velocitySmoothingFactor * instantVelocity) +
((1.0 - velocitySmoothingFactor) * self.smoothedVelocity);
self.lastMovementTime = currentTime;
} else {
// No movement detected - aggressively decay to zero
self.smoothedVelocity *= 0.5; // Very fast decay when no movement
}
// Additional decay if we haven't seen real movement for a while
double timeSinceMovement = currentTime - self.lastMovementTime;
if (timeSinceMovement > 0.05) { // Only 50ms without movement
self.smoothedVelocity *= 0.8; // Additional decay
}
// Update state for next iteration
self.lastUpdateTime = currentTime;
// Apply velocity-based parameter mapping
[self updateAudioParametersWithVelocity:self.smoothedVelocity];
}
- (void)setAngularVelocity:(double)velocity {
self.smoothedVelocity = velocity;
[self updateAudioParametersWithVelocity:velocity];
}
- (void)updateAudioParametersWithVelocity:(double)velocity {
// Velocity is already absolute at this point
double speed = velocity;
// Calculate target gain - CORRECTED LOGIC: slow = loud, fast = quiet
double gain;
if (speed < kDeadzone) {
gain = 0.0; // truly still no sound
} else {
// inverted smoothstep: slow => loud, fast => quiet
double e0 = fmax(0.0, kVelocityFull - 0.5);
double e1 = kVelocityQuiet + 0.5;
double t = fmin(1.0, fmax(0.0, (speed - e0) / (e1 - e0)));
double s = t * t * (3.0 - 2.0 * t); // smoothstep
gain = 1.0 - s; // invert: slow = loud, fast = quiet
gain = fmax(0.0, fmin(1.0, gain)); // Clamp to [0,1]
}
// Calculate target rate (pitch/tempo) - wider range now
double normalizedVelocity = fmax(0.0, fmin(1.0, speed / kVelocityQuiet));
double rate = kMinRate + normalizedVelocity * (kMaxRate - kMinRate);
rate = fmax(kMinRate, fmin(kMaxRate, rate));
// Store targets for smooth ramping
self.targetGain = gain;
self.targetRate = rate;
// Apply smooth parameter changes
[self rampToTargetParameters];
}
// Helper function for parameter ramping
- (double)rampValue:(double)current toward:(double)target withDeltaTime:(double)dt timeConstantMs:(double)tauMs {
double alpha = fmin(1.0, dt / (tauMs / 1000.0)); // linear ramp coefficient
return current + (target - current) * alpha;
}
- (void)rampToTargetParameters {
if (!self.isEngineRunning) {
return;
}
// Calculate delta time for ramping
static double lastRampTime = 0;
double currentTime = CACurrentMediaTime();
if (lastRampTime == 0) lastRampTime = currentTime;
double deltaTime = currentTime - lastRampTime;
lastRampTime = currentTime;
// Ramp current values toward targets (much faster ramping for immediate response)
self.currentGain = [self rampValue:self.currentGain toward:self.targetGain withDeltaTime:deltaTime timeConstantMs:50.0];
self.currentRate = [self rampValue:self.currentRate toward:self.targetRate withDeltaTime:deltaTime timeConstantMs:80.0];
// Apply ramped values to audio nodes (make loop louder with 2x multiplier)
self.creakPlayerNode.volume = (float)(self.currentGain * 2.0);
self.varispeadUnit.rate = (float)self.currentRate;
// Debug logging
if (self.targetGain > 0.01) {
NSLog(@"[CreakAudioEngine] Target: %.3f/%.3f, Current: %.3f/%.3f",
self.targetGain, self.targetRate, self.currentGain, self.currentRate);
}
}
// Grain system removed - loop only
#pragma mark - Property Accessors
- (double)currentVelocity {
return self.smoothedVelocity;
}
- (double)currentGain {
return _currentGain;
}
- (double)currentRate {
return _currentRate;
}
@end

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.