mirror of
https://github.com/samhenrigold/LidAngleSensor.git
synced 2026-01-12 09:17:43 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a36a226d | ||
|
|
ce0432c5ef | ||
|
|
d31e8e49e0 | ||
|
|
f608105ce7 | ||
|
|
d590174cdd | ||
|
|
82189fa0b3 | ||
|
|
1ee1d846db |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -268,7 +268,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -310,7 +310,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -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;
|
||||
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";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// CreakAudioEngine.h
|
||||
// LidAngleSensor
|
||||
//
|
||||
// Created by Sam on 2025-01-16.
|
||||
// Created by Sam on 2025-09-06.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// CreakAudioEngine.m
|
||||
// LidAngleSensor
|
||||
//
|
||||
// Created by Sam on 2025-01-16.
|
||||
// Created by Sam on 2025-09-06.
|
||||
//
|
||||
|
||||
#import "CreakAudioEngine.h"
|
||||
|
||||
@@ -48,11 +48,13 @@
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Use Generic Desktop + Mouse criteria to find candidate devices
|
||||
// This broader search allows us to find the lid sensor among other HID devices
|
||||
// Match specifically for the lid angle sensor to avoid permission prompts
|
||||
// Target: Sensor page (0x0020), Orientation usage (0x008A)
|
||||
NSDictionary *matchingDict = @{
|
||||
@"UsagePage": @(0x0001), // Generic Desktop
|
||||
@"Usage": @(0x0003), // Mouse
|
||||
@"VendorID": @(0x05AC), // Apple
|
||||
@"ProductID": @(0x8104), // Specific product
|
||||
@"UsagePage": @(0x0020), // Sensor page
|
||||
@"Usage": @(0x008A), // Orientation usage
|
||||
};
|
||||
|
||||
IOHIDManagerSetDeviceMatching(manager, (__bridge CFDictionaryRef)matchingDict);
|
||||
@@ -60,33 +62,38 @@
|
||||
IOHIDDeviceRef device = NULL;
|
||||
|
||||
if (devices && CFSetGetCount(devices) > 0) {
|
||||
NSLog(@"[LidAngleSensor] Found %ld devices, looking for sensor...", CFSetGetCount(devices));
|
||||
NSLog(@"[LidAngleSensor] Found %ld matching lid angle sensor device(s)", CFSetGetCount(devices));
|
||||
|
||||
const void **deviceArray = malloc(sizeof(void*) * CFSetGetCount(devices));
|
||||
CFSetGetValues(devices, deviceArray);
|
||||
|
||||
// Search for the specific lid angle sensor device
|
||||
// Discovered through reverse engineering: Apple device with Sensor page
|
||||
// Test each matching device to find the one that actually works
|
||||
for (CFIndex i = 0; i < CFSetGetCount(devices); i++) {
|
||||
IOHIDDeviceRef currentDevice = (IOHIDDeviceRef)deviceArray[i];
|
||||
IOHIDDeviceRef testDevice = (IOHIDDeviceRef)deviceArray[i];
|
||||
|
||||
CFNumberRef vendorID = IOHIDDeviceGetProperty(currentDevice, CFSTR("VendorID"));
|
||||
CFNumberRef productID = IOHIDDeviceGetProperty(currentDevice, CFSTR("ProductID"));
|
||||
CFNumberRef usagePage = IOHIDDeviceGetProperty(currentDevice, CFSTR("PrimaryUsagePage"));
|
||||
CFNumberRef usage = IOHIDDeviceGetProperty(currentDevice, CFSTR("PrimaryUsage"));
|
||||
// Try to open and read from this device
|
||||
if (IOHIDDeviceOpen(testDevice, kIOHIDOptionsTypeNone) == kIOReturnSuccess) {
|
||||
uint8_t testReport[8] = {0};
|
||||
CFIndex reportLength = sizeof(testReport);
|
||||
|
||||
int vid = 0, pid = 0, up = 0, u = 0;
|
||||
if (vendorID) CFNumberGetValue(vendorID, kCFNumberIntType, &vid);
|
||||
if (productID) CFNumberGetValue(productID, kCFNumberIntType, &pid);
|
||||
if (usagePage) CFNumberGetValue(usagePage, kCFNumberIntType, &up);
|
||||
if (usage) CFNumberGetValue(usage, kCFNumberIntType, &u);
|
||||
IOReturn result = IOHIDDeviceGetReport(testDevice,
|
||||
kIOHIDReportTypeFeature,
|
||||
1,
|
||||
testReport,
|
||||
&reportLength);
|
||||
|
||||
// Target the specific lid angle sensor device
|
||||
// VID=0x05AC (Apple), PID=0x8104, UsagePage=0x0020 (Sensor), Usage=0x008A (Orientation)
|
||||
if (vid == 0x05AC && pid == 0x8104 && up == 0x0020 && u == 0x008A) {
|
||||
device = (IOHIDDeviceRef)CFRetain(currentDevice);
|
||||
NSLog(@"[LidAngleSensor] Found lid angle sensor device: VID=0x%04X, PID=0x%04X", vid, pid);
|
||||
if (result == kIOReturnSuccess && reportLength >= 3) {
|
||||
// This device works! Use it.
|
||||
device = (IOHIDDeviceRef)CFRetain(testDevice);
|
||||
NSLog(@"[LidAngleSensor] Successfully found working lid angle sensor device (index %ld)", i);
|
||||
IOHIDDeviceClose(testDevice, kIOHIDOptionsTypeNone); // Close for now, will reopen in init
|
||||
break;
|
||||
} else {
|
||||
NSLog(@"[LidAngleSensor] Device %ld failed to read (result: %d, length: %ld)", i, result, reportLength);
|
||||
IOHIDDeviceClose(testDevice, kIOHIDOptionsTypeNone);
|
||||
}
|
||||
} else {
|
||||
NSLog(@"[LidAngleSensor] Failed to open device %ld", i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +126,9 @@
|
||||
|
||||
if (result == kIOReturnSuccess && reportLength >= 3) {
|
||||
// Data format: [report_id, angle_low, angle_high]
|
||||
// Example: [01 72 00] = 0x7201 centidegrees = 291.85 degrees
|
||||
uint16_t rawValue = *(uint16_t*)(report);
|
||||
double angle = rawValue * 0.01; // Convert centidegrees to degrees
|
||||
// Parse the 16-bit value from bytes 1-2 (skipping report ID)
|
||||
uint16_t rawValue = (report[2] << 8) | report[1]; // High byte, low byte
|
||||
double angle = (double)rawValue; // Raw value is already in degrees
|
||||
|
||||
return angle;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// NSLabel.h
|
||||
// LidAngleSensor
|
||||
//
|
||||
// Created by Sam on 2025-01-16.
|
||||
// Created by Sam on 2025-09-06.
|
||||
//
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// NSLabel.m
|
||||
// LidAngleSensor
|
||||
//
|
||||
// Created by Sam on 2025-01-16.
|
||||
// Created by Sam on 2025-09-06.
|
||||
//
|
||||
|
||||
#import "NSLabel.h"
|
||||
|
||||
62
LidAngleSensor/ThereminAudioEngine.h
Normal file
62
LidAngleSensor/ThereminAudioEngine.h
Normal file
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// ThereminAudioEngine.h
|
||||
// LidAngleSensor
|
||||
//
|
||||
// Created by Sam on 2025-09-06.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
/**
|
||||
* 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
|
||||
318
LidAngleSensor/ThereminAudioEngine.m
Normal file
318
LidAngleSensor/ThereminAudioEngine.m
Normal file
@@ -0,0 +1,318 @@
|
||||
//
|
||||
// ThereminAudioEngine.m
|
||||
// LidAngleSensor
|
||||
//
|
||||
// Created by Sam on 2025-09-06.
|
||||
//
|
||||
|
||||
#import "ThereminAudioEngine.h"
|
||||
#import <AudioToolbox/AudioToolbox.h>
|
||||
|
||||
// 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
|
||||
Reference in New Issue
Block a user