From ce0432c5ef9275ce4196b0f60f6c296b6623f23c Mon Sep 17 00:00:00 2001 From: samhenrigold <49251320+samhenrigold@users.noreply.github.com> Date: Sat, 6 Sep 2025 19:02:52 -0400 Subject: [PATCH] Theremin mode, fuckers --- LidAngleSensor/AppDelegate.m | 134 +++++++++-- LidAngleSensor/CreakAudioEngine.h | 2 +- LidAngleSensor/CreakAudioEngine.m | 2 +- LidAngleSensor/NSLabel.h | 2 +- LidAngleSensor/NSLabel.m | 2 +- LidAngleSensor/ThereminAudioEngine.h | 62 ++++++ LidAngleSensor/ThereminAudioEngine.m | 318 +++++++++++++++++++++++++++ 7 files changed, 494 insertions(+), 28 deletions(-) create mode 100644 LidAngleSensor/ThereminAudioEngine.h create mode 100644 LidAngleSensor/ThereminAudioEngine.m diff --git a/LidAngleSensor/AppDelegate.m b/LidAngleSensor/AppDelegate.m index c64f7e0..f4a69de 100644 --- a/LidAngleSensor/AppDelegate.m +++ b/LidAngleSensor/AppDelegate.m @@ -8,32 +8,44 @@ #import "AppDelegate.h" #import "LidAngleSensor.h" #import "CreakAudioEngine.h" +#import "ThereminAudioEngine.h" #import "NSLabel.h" +typedef NS_ENUM(NSInteger, AudioMode) { + AudioModeCreak, + AudioModeTheremin +}; + @interface AppDelegate () @property (strong, nonatomic) LidAngleSensor *lidSensor; -@property (strong, nonatomic) CreakAudioEngine *audioEngine; +@property (strong, nonatomic) CreakAudioEngine *creakAudioEngine; +@property (strong, nonatomic) ThereminAudioEngine *thereminAudioEngine; @property (strong, nonatomic) NSLabel *angleLabel; @property (strong, nonatomic) NSLabel *statusLabel; @property (strong, nonatomic) NSLabel *velocityLabel; @property (strong, nonatomic) NSLabel *audioStatusLabel; @property (strong, nonatomic) NSButton *audioToggleButton; +@property (strong, nonatomic) NSSegmentedControl *modeSelector; +@property (strong, nonatomic) NSLabel *modeLabel; @property (strong, nonatomic) NSTimer *updateTimer; +@property (nonatomic, assign) AudioMode currentAudioMode; @end @implementation AppDelegate - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { + self.currentAudioMode = AudioModeCreak; // Default to creak mode [self createWindow]; [self initializeLidSensor]; - [self initializeAudioEngine]; + [self initializeAudioEngines]; [self startUpdatingDisplay]; } - (void)applicationWillTerminate:(NSNotification *)aNotification { [self.updateTimer invalidate]; [self.lidSensor stopLidAngleUpdates]; - [self.audioEngine stopEngine]; + [self.creakAudioEngine stopEngine]; + [self.thereminAudioEngine stopEngine]; } - (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { @@ -41,8 +53,8 @@ } - (void)createWindow { - // Create the main window (taller to accommodate audio controls) - NSRect windowFrame = NSMakeRect(100, 100, 450, 420); + // Create the main window (taller to accommodate mode selection and audio controls) + NSRect windowFrame = NSMakeRect(100, 100, 450, 480); self.window = [[NSWindow alloc] initWithContentRect:windowFrame styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | @@ -98,6 +110,25 @@ [self.audioStatusLabel setTextColor:[NSColor secondaryLabelColor]]; [contentView addSubview:self.audioStatusLabel]; + // Create mode label + self.modeLabel = [[NSLabel alloc] init]; + [self.modeLabel setStringValue:@"Audio Mode:"]; + [self.modeLabel setFont:[NSFont systemFontOfSize:14 weight:NSFontWeightMedium]]; + [self.modeLabel setAlignment:NSTextAlignmentCenter]; + [self.modeLabel setTextColor:[NSColor labelColor]]; + [contentView addSubview:self.modeLabel]; + + // Create mode selector + self.modeSelector = [[NSSegmentedControl alloc] init]; + [self.modeSelector setSegmentCount:2]; + [self.modeSelector setLabel:@"Creak" forSegment:0]; + [self.modeSelector setLabel:@"Theremin" forSegment:1]; + [self.modeSelector setSelectedSegment:0]; // Default to creak + [self.modeSelector setTarget:self]; + [self.modeSelector setAction:@selector(modeChanged:)]; + [self.modeSelector setTranslatesAutoresizingMaskIntoConstraints:NO]; + [contentView addSubview:self.modeSelector]; + // Set up auto layout constraints [NSLayoutConstraint activateConstraints:@[ // Angle label (main display, now at top) @@ -125,7 +156,18 @@ [self.audioStatusLabel.topAnchor constraintEqualToAnchor:self.audioToggleButton.bottomAnchor constant:15], [self.audioStatusLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], [self.audioStatusLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], - [self.audioStatusLabel.bottomAnchor constraintLessThanOrEqualToAnchor:contentView.bottomAnchor constant:-20] + + // Mode label + [self.modeLabel.topAnchor constraintEqualToAnchor:self.audioStatusLabel.bottomAnchor constant:25], + [self.modeLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], + [self.modeLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], + + // Mode selector + [self.modeSelector.topAnchor constraintEqualToAnchor:self.modeLabel.bottomAnchor constant:10], + [self.modeSelector.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], + [self.modeSelector.widthAnchor constraintEqualToConstant:200], + [self.modeSelector.heightAnchor constraintEqualToConstant:28], + [self.modeSelector.bottomAnchor constraintLessThanOrEqualToAnchor:contentView.bottomAnchor constant:-20] ]]; } @@ -143,10 +185,11 @@ } } -- (void)initializeAudioEngine { - self.audioEngine = [[CreakAudioEngine alloc] init]; +- (void)initializeAudioEngines { + self.creakAudioEngine = [[CreakAudioEngine alloc] init]; + self.thereminAudioEngine = [[ThereminAudioEngine alloc] init]; - if (self.audioEngine) { + if (self.creakAudioEngine && self.thereminAudioEngine) { [self.audioStatusLabel setStringValue:@""]; } else { [self.audioStatusLabel setStringValue:@"Audio initialization failed"]; @@ -156,21 +199,59 @@ } - (IBAction)toggleAudio:(id)sender { - if (!self.audioEngine) { + id currentEngine = [self currentAudioEngine]; + if (!currentEngine) { return; } - if (self.audioEngine.isEngineRunning) { - [self.audioEngine stopEngine]; + if ([currentEngine isEngineRunning]) { + [currentEngine stopEngine]; [self.audioToggleButton setTitle:@"Start Audio"]; [self.audioStatusLabel setStringValue:@""]; } else { - [self.audioEngine startEngine]; + [currentEngine startEngine]; [self.audioToggleButton setTitle:@"Stop Audio"]; [self.audioStatusLabel setStringValue:@""]; } } +- (IBAction)modeChanged:(id)sender { + NSSegmentedControl *control = (NSSegmentedControl *)sender; + AudioMode newMode = (AudioMode)control.selectedSegment; + + // Stop current engine if running + id currentEngine = [self currentAudioEngine]; + BOOL wasRunning = [currentEngine isEngineRunning]; + if (wasRunning) { + [currentEngine stopEngine]; + } + + // Update mode + self.currentAudioMode = newMode; + + // Start new engine if the previous one was running + if (wasRunning) { + id newEngine = [self currentAudioEngine]; + [newEngine startEngine]; + [self.audioToggleButton setTitle:@"Stop Audio"]; + } else { + [self.audioToggleButton setTitle:@"Start Audio"]; + } + + [self.audioStatusLabel setStringValue:@""]; +} + +- (id)currentAudioEngine { + switch (self.currentAudioMode) { + case AudioModeCreak: + return self.creakAudioEngine; + case AudioModeTheremin: + return self.thereminAudioEngine; + default: + return self.creakAudioEngine; + } +} + - (void)startUpdatingDisplay { // Update every 16ms (60Hz) for smooth real-time audio and display updates self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.016 @@ -196,12 +277,13 @@ [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 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 = self.audioEngine.currentVelocity; + double velocity = [currentEngine currentVelocity]; int roundedVelocity = (int)round(velocity); if (roundedVelocity < 100) { [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %02d deg/s", roundedVelocity]]; @@ -209,13 +291,17 @@ [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %d deg/s", roundedVelocity]]; } - // Keep velocity label color consistent - // Show audio parameters when running - if (self.audioEngine.isEngineRunning) { - double gain = self.audioEngine.currentGain; - double rate = self.audioEngine.currentRate; - [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Gain: %.2f, Rate: %.2f", gain, rate]]; + 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]]; + } } } @@ -227,7 +313,7 @@ status = @"Lid slightly open"; } else if (angle < 90.0) { status = @"Lid partially open"; - } else if (angle < 135.0) { + } else if (angle < 120.0) { status = @"Lid mostly open"; } else { status = @"Lid fully open"; diff --git a/LidAngleSensor/CreakAudioEngine.h b/LidAngleSensor/CreakAudioEngine.h index 7208699..3da450e 100644 --- a/LidAngleSensor/CreakAudioEngine.h +++ b/LidAngleSensor/CreakAudioEngine.h @@ -2,7 +2,7 @@ // CreakAudioEngine.h // LidAngleSensor // -// Created by Sam on 2025-01-16. +// Created by Sam on 2025-09-06. // #import diff --git a/LidAngleSensor/CreakAudioEngine.m b/LidAngleSensor/CreakAudioEngine.m index 7c75634..02cdedd 100644 --- a/LidAngleSensor/CreakAudioEngine.m +++ b/LidAngleSensor/CreakAudioEngine.m @@ -2,7 +2,7 @@ // CreakAudioEngine.m // LidAngleSensor // -// Created by Sam on 2025-01-16. +// Created by Sam on 2025-09-06. // #import "CreakAudioEngine.h" diff --git a/LidAngleSensor/NSLabel.h b/LidAngleSensor/NSLabel.h index b25a215..9dc2062 100644 --- a/LidAngleSensor/NSLabel.h +++ b/LidAngleSensor/NSLabel.h @@ -2,7 +2,7 @@ // NSLabel.h // LidAngleSensor // -// Created by Sam on 2025-01-16. +// Created by Sam on 2025-09-06. // #import diff --git a/LidAngleSensor/NSLabel.m b/LidAngleSensor/NSLabel.m index 222bb96..7f45d47 100644 --- a/LidAngleSensor/NSLabel.m +++ b/LidAngleSensor/NSLabel.m @@ -2,7 +2,7 @@ // NSLabel.m // LidAngleSensor // -// Created by Sam on 2025-01-16. +// Created by Sam on 2025-09-06. // #import "NSLabel.h" diff --git a/LidAngleSensor/ThereminAudioEngine.h b/LidAngleSensor/ThereminAudioEngine.h new file mode 100644 index 0000000..a7ec7b8 --- /dev/null +++ b/LidAngleSensor/ThereminAudioEngine.h @@ -0,0 +1,62 @@ +// +// ThereminAudioEngine.h +// LidAngleSensor +// +// Created by Sam on 2025-09-06. +// + +#import +#import + +/** + * ThereminAudioEngine provides real-time theremin-like audio that responds to MacBook lid angle changes. + * + * Features: + * - Real-time sine wave synthesis based on lid angle + * - Smooth frequency transitions to avoid audio artifacts + * - Volume control based on angular velocity + * - Configurable frequency range mapping + * - Low-latency audio generation + * + * Audio Behavior: + * - Lid angle maps to frequency (closed = low pitch, open = high pitch) + * - Movement velocity controls volume (slow movement = loud, fast = quiet) + * - Smooth parameter interpolation for musical quality + */ +@interface ThereminAudioEngine : NSObject + +@property (nonatomic, assign, readonly) BOOL isEngineRunning; +@property (nonatomic, assign, readonly) double currentVelocity; +@property (nonatomic, assign, readonly) double currentFrequency; +@property (nonatomic, assign, readonly) double currentVolume; + +/** + * Initialize the theremin audio engine. + * @return Initialized engine instance, or nil if initialization failed + */ +- (instancetype)init; + +/** + * Start the audio engine and begin tone generation. + */ +- (void)startEngine; + +/** + * Stop the audio engine and halt tone generation. + */ +- (void)stopEngine; + +/** + * Update the theremin audio based on new lid angle measurement. + * This method calculates frequency mapping and volume based on movement. + * @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 diff --git a/LidAngleSensor/ThereminAudioEngine.m b/LidAngleSensor/ThereminAudioEngine.m new file mode 100644 index 0000000..227df69 --- /dev/null +++ b/LidAngleSensor/ThereminAudioEngine.m @@ -0,0 +1,318 @@ +// +// ThereminAudioEngine.m +// LidAngleSensor +// +// Created by Sam on 2025-09-06. +// + +#import "ThereminAudioEngine.h" +#import + +// Theremin parameter mapping constants +static const double kMinFrequency = 110.0; // Hz - A2 note (closed lid) +static const double kMaxFrequency = 440.0; // Hz - A4 note (open lid) - much lower range +static const double kMinAngle = 0.0; // degrees - closed lid +static const double kMaxAngle = 135.0; // degrees - fully open lid + +// Volume control constants - continuous tone with velocity modulation +static const double kBaseVolume = 0.6; // Base volume when at rest +static const double kVelocityVolumeBoost = 0.4; // Additional volume boost from movement +static const double kVelocityFull = 8.0; // deg/s - max volume boost at/under this velocity +static const double kVelocityQuiet = 80.0; // deg/s - no volume boost over this velocity + +// Vibrato constants +static const double kVibratoFrequency = 5.0; // Hz - vibrato rate +static const double kVibratoDepth = 0.03; // Vibrato depth as fraction of frequency (3%) + +// Smoothing constants +static const double kAngleSmoothingFactor = 0.1; // Moderate smoothing for frequency +static const double kVelocitySmoothingFactor = 0.3; // Moderate smoothing for velocity +static const double kFrequencyRampTimeMs = 30.0; // Frequency ramping time constant +static const double kVolumeRampTimeMs = 50.0; // Volume ramping time constant +static const double kMovementThreshold = 0.3; // Minimum angle change to register movement +static const double kMovementTimeoutMs = 100.0; // Time before velocity decay +static const double kVelocityDecayFactor = 0.7; // Decay rate when no movement +static const double kAdditionalDecayFactor = 0.85; // Additional decay after timeout + +// Audio constants +static const double kSampleRate = 44100.0; +static const UInt32 kBufferSize = 512; + +@interface ThereminAudioEngine () + +// Audio engine components +@property (nonatomic, strong) AVAudioEngine *audioEngine; +@property (nonatomic, strong) AVAudioSourceNode *sourceNode; +@property (nonatomic, strong) AVAudioMixerNode *mixerNode; + +// 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 targetFrequency; +@property (nonatomic, assign) double targetVolume; +@property (nonatomic, assign) double currentFrequency; +@property (nonatomic, assign) double currentVolume; +@property (nonatomic, assign) BOOL isFirstUpdate; +@property (nonatomic, assign) NSTimeInterval lastMovementTime; + +// Sine wave generation +@property (nonatomic, assign) double phase; +@property (nonatomic, assign) double phaseIncrement; + +// Vibrato generation +@property (nonatomic, assign) double vibratoPhase; + +@end + +@implementation ThereminAudioEngine + +- (instancetype)init { + self = [super init]; + if (self) { + _isFirstUpdate = YES; + _lastUpdateTime = CACurrentMediaTime(); + _lastMovementTime = CACurrentMediaTime(); + _lastLidAngle = 0.0; + _smoothedLidAngle = 0.0; + _smoothedVelocity = 0.0; + _targetFrequency = kMinFrequency; + _targetVolume = kBaseVolume; + _currentFrequency = kMinFrequency; + _currentVolume = kBaseVolume; + _phase = 0.0; + _vibratoPhase = 0.0; + _phaseIncrement = 2.0 * M_PI * kMinFrequency / kSampleRate; + + if (![self setupAudioEngine]) { + NSLog(@"[ThereminAudioEngine] Failed to setup audio engine"); + return nil; + } + } + return self; +} + +- (void)dealloc { + [self stopEngine]; +} + +#pragma mark - Audio Engine Setup + +- (BOOL)setupAudioEngine { + self.audioEngine = [[AVAudioEngine alloc] init]; + self.mixerNode = self.audioEngine.mainMixerNode; + + // Create audio format for our sine wave + AVAudioFormat *format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 + sampleRate:kSampleRate + channels:1 + interleaved:NO]; + + // Create source node for sine wave generation + __weak typeof(self) weakSelf = self; + self.sourceNode = [[AVAudioSourceNode alloc] initWithFormat:format renderBlock:^OSStatus(BOOL * _Nonnull isSilence, const AudioTimeStamp * _Nonnull timestamp, AVAudioFrameCount frameCount, AudioBufferList * _Nonnull outputData) { + return [weakSelf renderSineWave:isSilence timestamp:timestamp frameCount:frameCount outputData:outputData]; + }]; + + // Attach and connect the source node + [self.audioEngine attachNode:self.sourceNode]; + [self.audioEngine connect:self.sourceNode to:self.mixerNode format:format]; + + return YES; +} + +#pragma mark - Engine Control + +- (void)startEngine { + if (self.isEngineRunning) { + return; + } + + NSError *error; + if (![self.audioEngine startAndReturnError:&error]) { + NSLog(@"[ThereminAudioEngine] Failed to start audio engine: %@", error.localizedDescription); + return; + } + + NSLog(@"[ThereminAudioEngine] Started theremin engine"); +} + +- (void)stopEngine { + if (!self.isEngineRunning) { + return; + } + + [self.audioEngine stop]; + NSLog(@"[ThereminAudioEngine] Stopped theremin engine"); +} + +- (BOOL)isEngineRunning { + return self.audioEngine.isRunning; +} + +#pragma mark - Sine Wave Generation + +- (OSStatus)renderSineWave:(BOOL *)isSilence + timestamp:(const AudioTimeStamp *)timestamp + frameCount:(AVAudioFrameCount)frameCount + outputData:(AudioBufferList *)outputData { + + float *output = (float *)outputData->mBuffers[0].mData; + + // Always generate sound (continuous tone) + *isSilence = NO; + + // Calculate vibrato phase increment + double vibratoPhaseIncrement = 2.0 * M_PI * kVibratoFrequency / kSampleRate; + + // Generate sine wave samples with vibrato + for (AVAudioFrameCount i = 0; i < frameCount; i++) { + // Calculate vibrato modulation + double vibratoModulation = sin(self.vibratoPhase) * kVibratoDepth; + double modulatedFrequency = self.currentFrequency * (1.0 + vibratoModulation); + + // Update phase increment for modulated frequency + self.phaseIncrement = 2.0 * M_PI * modulatedFrequency / kSampleRate; + + // Generate sample with vibrato and current volume + output[i] = (float)(sin(self.phase) * self.currentVolume * 0.25); // 0.25 to prevent clipping + + // Update phases + self.phase += self.phaseIncrement; + self.vibratoPhase += vibratoPhaseIncrement; + + // Wrap phases to prevent accumulation of floating point errors + if (self.phase >= 2.0 * M_PI) { + self.phase -= 2.0 * M_PI; + } + if (self.vibratoPhase >= 2.0 * M_PI) { + self.vibratoPhase -= 2.0 * M_PI; + } + } + + return noErr; +} + +#pragma mark - Lid Angle Processing + +- (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; + + // Set initial frequency based on angle + [self updateTargetParametersWithAngle:lidAngle velocity:0.0]; + return; + } + + // Calculate time delta + double deltaTime = currentTime - self.lastUpdateTime; + if (deltaTime <= 0 || deltaTime > 1.0) { + // Skip if time delta is invalid or too large + self.lastUpdateTime = currentTime; + return; + } + + // Stage 1: Smooth the raw angle input + self.smoothedLidAngle = (kAngleSmoothingFactor * lidAngle) + + ((1.0 - kAngleSmoothingFactor) * self.smoothedLidAngle); + + // Stage 2: Calculate velocity from smoothed angle data + double deltaAngle = self.smoothedLidAngle - self.lastLidAngle; + double instantVelocity; + + // Apply movement threshold + if (fabs(deltaAngle) < kMovementThreshold) { + instantVelocity = 0.0; + } else { + instantVelocity = fabs(deltaAngle / deltaTime); + self.lastLidAngle = self.smoothedLidAngle; + } + + // Stage 3: Apply velocity smoothing and decay + if (instantVelocity > 0.0) { + self.smoothedVelocity = (kVelocitySmoothingFactor * instantVelocity) + + ((1.0 - kVelocitySmoothingFactor) * self.smoothedVelocity); + self.lastMovementTime = currentTime; + } else { + self.smoothedVelocity *= kVelocityDecayFactor; + } + + // Additional decay if no movement for extended period + double timeSinceMovement = currentTime - self.lastMovementTime; + if (timeSinceMovement > (kMovementTimeoutMs / 1000.0)) { + self.smoothedVelocity *= kAdditionalDecayFactor; + } + + // Update state for next iteration + self.lastUpdateTime = currentTime; + + // Update target parameters + [self updateTargetParametersWithAngle:self.smoothedLidAngle velocity:self.smoothedVelocity]; + + // Apply smooth parameter transitions + [self rampToTargetParameters]; +} + +- (void)setAngularVelocity:(double)velocity { + self.smoothedVelocity = velocity; + [self updateTargetParametersWithAngle:self.smoothedLidAngle velocity:velocity]; + [self rampToTargetParameters]; +} + +- (void)updateTargetParametersWithAngle:(double)angle velocity:(double)velocity { + // Map angle to frequency using exponential curve for musical feel + double normalizedAngle = fmax(0.0, fmin(1.0, (angle - kMinAngle) / (kMaxAngle - kMinAngle))); + + // Use exponential mapping for more musical frequency distribution + double frequencyRatio = pow(normalizedAngle, 0.7); // Slight compression for better control + self.targetFrequency = kMinFrequency + frequencyRatio * (kMaxFrequency - kMinFrequency); + + // Calculate continuous volume with velocity-based boost + double velocityBoost = 0.0; + if (velocity > 0.0) { + // Use smoothstep curve for natural volume boost response + double e0 = 0.0; + double e1 = kVelocityQuiet; + double t = fmin(1.0, fmax(0.0, (velocity - e0) / (e1 - e0))); + double s = t * t * (3.0 - 2.0 * t); // smoothstep function + velocityBoost = (1.0 - s) * kVelocityVolumeBoost; // invert: slow = more boost, fast = less boost + } + + // Combine base volume with velocity boost + self.targetVolume = kBaseVolume + velocityBoost; + self.targetVolume = fmax(0.0, fmin(1.0, self.targetVolume)); +} + +// 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)); + return current + (target - current) * alpha; +} + +- (void)rampToTargetParameters { + // 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 for smooth transitions + self.currentFrequency = [self rampValue:self.currentFrequency toward:self.targetFrequency withDeltaTime:deltaTime timeConstantMs:kFrequencyRampTimeMs]; + self.currentVolume = [self rampValue:self.currentVolume toward:self.targetVolume withDeltaTime:deltaTime timeConstantMs:kVolumeRampTimeMs]; +} + +#pragma mark - Property Accessors + +- (double)currentVelocity { + return self.smoothedVelocity; +} + +@end