├── .gitignore ├── LidAngleSensor ├── CREAK_LOOP.wav ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── AppDelegate.h ├── main.m ├── NSLabel.h ├── NSLabel.m ├── LidAngleSensor.h ├── ThereminAudioEngine.h ├── CreakAudioEngine.h ├── LidAngleSensor.m ├── CreakAudioEngine.m ├── ThereminAudioEngine.m ├── AppDelegate.m └── Base.lproj │ └── MainMenu.xib ├── LidAngleSensor.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata ├── xcuserdata │ └── shg.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /LidAngleSensor/CREAK_LOOP.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samhenrigold/LidAngleSensor/HEAD/LidAngleSensor/CREAK_LOOP.wav -------------------------------------------------------------------------------- /LidAngleSensor/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LidAngleSensor.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LidAngleSensor/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LidAngleSensor/AppDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.h 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import 9 | 10 | @interface AppDelegate : NSObject 11 | 12 | @property (strong, nonatomic) NSWindow *window; 13 | 14 | @end 15 | 16 | -------------------------------------------------------------------------------- /LidAngleSensor/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import 9 | 10 | int main(int argc, const char * argv[]) { 11 | @autoreleasepool { 12 | // Setup code that might create autoreleased objects goes here. 13 | } 14 | return NSApplicationMain(argc, argv); 15 | } 16 | -------------------------------------------------------------------------------- /LidAngleSensor/NSLabel.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSLabel.h 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import 9 | 10 | /** 11 | * NSLabel is a simple NSTextField subclass configured as a non-editable label. 12 | * Automatically handles common label properties like bezeled, background, and editability. 13 | */ 14 | @interface NSLabel : NSTextField 15 | 16 | @end 17 | -------------------------------------------------------------------------------- /LidAngleSensor.xcodeproj/xcuserdata/shg.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | LidAngleSensor.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /LidAngleSensor/NSLabel.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSLabel.m 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import "NSLabel.h" 9 | 10 | @implementation NSLabel 11 | 12 | - (instancetype)init { 13 | self = [super init]; 14 | if (self) { 15 | [self configureAsLabel]; 16 | } 17 | return self; 18 | } 19 | 20 | - (instancetype)initWithFrame:(NSRect)frameRect { 21 | self = [super initWithFrame:frameRect]; 22 | if (self) { 23 | [self configureAsLabel]; 24 | } 25 | return self; 26 | } 27 | 28 | - (void)configureAsLabel { 29 | [self setBezeled:NO]; 30 | [self setDrawsBackground:NO]; 31 | [self setEditable:NO]; 32 | [self setSelectable:NO]; 33 | [self setTranslatesAutoresizingMaskIntoConstraints:NO]; 34 | } 35 | 36 | @end 37 | -------------------------------------------------------------------------------- /LidAngleSensor/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /LidAngleSensor/LidAngleSensor.h: -------------------------------------------------------------------------------- 1 | // 2 | // LidAngleSensor.h 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import 9 | #import 10 | #import 11 | 12 | /** 13 | * LidAngleSensor provides access to the MacBook's internal lid angle sensor. 14 | * 15 | * This class interfaces with the HID device that reports the angle between 16 | * the laptop lid and base, providing real-time angle measurements in degrees. 17 | * 18 | * Device Specifications (discovered through reverse engineering): 19 | * - Apple device: VID=0x05AC, PID=0x8104 20 | * - HID Usage: Sensor page (0x0020), Orientation usage (0x008A) 21 | * - Data format: 16-bit angle value in centidegrees (0.01° resolution) 22 | * - Range: 0-360 degrees 23 | */ 24 | @interface LidAngleSensor : NSObject 25 | 26 | @property (nonatomic, assign, readonly) IOHIDDeviceRef hidDevice; 27 | @property (nonatomic, assign, readonly) BOOL isAvailable; 28 | 29 | /** 30 | * Initialize and connect to the lid angle sensor. 31 | * @return Initialized sensor instance, or nil if sensor not available 32 | */ 33 | - (instancetype)init; 34 | 35 | /** 36 | * Read the current lid angle. 37 | * @return Angle in degrees (0-360), or -2.0 if read failed 38 | */ 39 | - (double)lidAngle; 40 | 41 | /** 42 | * Start lid angle monitoring (called automatically in init). 43 | */ 44 | - (void)startLidAngleUpdates; 45 | 46 | /** 47 | * Stop lid angle monitoring and release resources. 48 | */ 49 | - (void)stopLidAngleUpdates; 50 | 51 | @end 52 | -------------------------------------------------------------------------------- /LidAngleSensor/ThereminAudioEngine.h: -------------------------------------------------------------------------------- 1 | // 2 | // ThereminAudioEngine.h 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import 9 | #import 10 | 11 | /** 12 | * ThereminAudioEngine provides real-time theremin-like audio that responds to MacBook lid angle changes. 13 | * 14 | * Features: 15 | * - Real-time sine wave synthesis based on lid angle 16 | * - Smooth frequency transitions to avoid audio artifacts 17 | * - Volume control based on angular velocity 18 | * - Configurable frequency range mapping 19 | * - Low-latency audio generation 20 | * 21 | * Audio Behavior: 22 | * - Lid angle maps to frequency (closed = low pitch, open = high pitch) 23 | * - Movement velocity controls volume (slow movement = loud, fast = quiet) 24 | * - Smooth parameter interpolation for musical quality 25 | */ 26 | @interface ThereminAudioEngine : NSObject 27 | 28 | @property (nonatomic, assign, readonly) BOOL isEngineRunning; 29 | @property (nonatomic, assign, readonly) double currentVelocity; 30 | @property (nonatomic, assign, readonly) double currentFrequency; 31 | @property (nonatomic, assign, readonly) double currentVolume; 32 | 33 | /** 34 | * Initialize the theremin audio engine. 35 | * @return Initialized engine instance, or nil if initialization failed 36 | */ 37 | - (instancetype)init; 38 | 39 | /** 40 | * Start the audio engine and begin tone generation. 41 | */ 42 | - (void)startEngine; 43 | 44 | /** 45 | * Stop the audio engine and halt tone generation. 46 | */ 47 | - (void)stopEngine; 48 | 49 | /** 50 | * Update the theremin audio based on new lid angle measurement. 51 | * This method calculates frequency mapping and volume based on movement. 52 | * @param lidAngle Current lid angle in degrees 53 | */ 54 | - (void)updateWithLidAngle:(double)lidAngle; 55 | 56 | /** 57 | * Manually set the angular velocity (for testing purposes). 58 | * @param velocity Angular velocity in degrees per second 59 | */ 60 | - (void)setAngularVelocity:(double)velocity; 61 | 62 | @end 63 | -------------------------------------------------------------------------------- /LidAngleSensor/CreakAudioEngine.h: -------------------------------------------------------------------------------- 1 | // 2 | // CreakAudioEngine.h 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import 9 | #import 10 | 11 | /** 12 | * CreakAudioEngine provides real-time door creak audio that responds to MacBook lid angle changes. 13 | * 14 | * Features: 15 | * - Real-time angular velocity calculation with multi-stage noise filtering 16 | * - Dynamic gain and pitch/tempo mapping based on movement speed 17 | * - Smooth parameter ramping to avoid audio artifacts 18 | * - Deadzone to prevent chattering at minimal movement 19 | * - Optimized for low-latency, responsive audio feedback 20 | * 21 | * Audio Behavior: 22 | * - Slow movement (1-10 deg/s): Maximum creak volume 23 | * - Medium movement (10-100 deg/s): Gradual fade to silence 24 | * - Fast movement (100+ deg/s): Silent 25 | */ 26 | @interface CreakAudioEngine : NSObject 27 | 28 | @property (nonatomic, assign, readonly) BOOL isEngineRunning; 29 | @property (nonatomic, assign, readonly) double currentVelocity; 30 | @property (nonatomic, assign, readonly) double currentGain; 31 | @property (nonatomic, assign, readonly) double currentRate; 32 | 33 | /** 34 | * Initialize the audio engine and load audio files. 35 | * @return Initialized engine instance, or nil if initialization failed 36 | */ 37 | - (instancetype)init; 38 | 39 | /** 40 | * Start the audio engine and begin playback. 41 | */ 42 | - (void)startEngine; 43 | 44 | /** 45 | * Stop the audio engine and halt playback. 46 | */ 47 | - (void)stopEngine; 48 | 49 | /** 50 | * Update the creak audio based on new lid angle measurement. 51 | * This method calculates angular velocity, applies smoothing, and updates audio parameters. 52 | * @param lidAngle Current lid angle in degrees 53 | */ 54 | - (void)updateWithLidAngle:(double)lidAngle; 55 | 56 | /** 57 | * Manually set the angular velocity (for testing purposes). 58 | * @param velocity Angular velocity in degrees per second 59 | */ 60 | - (void)setAngularVelocity:(double)velocity; 61 | 62 | @end 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lid Angle Sensor 2 | 3 | Hi, I’m Sam Gold. Did you know that you have ~rights~ a lid angle sensor in your MacBook? [The ~Constitution~ human interface device utility says you do.](https://youtu.be/wqnHtGgVAUE?t=21) 4 | 5 | This is a little utility that shows the angle from the sensor and, optionally, plays a wooden door creaking sound if you adjust it reeaaaaaal slowly. 6 | 7 | ## FAQ 8 | 9 | **What is a lid angle sensor?** 10 | 11 | Despite what the name would have you believe, it is a sensor that detects the angle of the lid. 12 | 13 | **Which devices have a lid angle sensor?** 14 | 15 | It was introduced with the 2019 16-inch MacBook Pro. If your laptop is newer, you probably have it. [People have reported](https://github.com/samhenrigold/LidAngleSensor/issues/13) that it **does not work on M1 devices**, I have not yet figured out a fix. 16 | 17 | **My laptop should have it, why doesn't it show up?** 18 | 19 | I've only tested this on my M4 MacBook Pro and have hard-coded it to look for a specific sensor. If that doesn't work, try running [this script](https://gist.github.com/samhenrigold/42b5a92d1ee8aaf2b840be34bff28591) and report the output in [an issue](https://github.com/samhenrigold/LidAngleSensor/issues/new/choose). 20 | 21 | Known problematic models: 22 | 23 | - M1 MacBook Air 24 | - M1 MacBook Pro 25 | 26 | **Can I use this on my iMac?** 27 | 28 | ~~Not yet tested. Feel free to slam your computer into your desk and make a PR with your results.~~ 29 | 30 | [It totally works](https://github.com/samhenrigold/LidAngleSensor/issues/33). If it doesn't work for you, try slamming your computer harder? 31 | 32 | **Why?** 33 | 34 | A lot of free time. I'm open to full-time work in NYC or remote. I'm a designer/design-engineer. https://samhenri.gold 35 | 36 | **No I mean like why does my laptop need to know the exact angle of its lid?** 37 | 38 | Oh. I don't know. 39 | 40 | **Can I contribute?** 41 | 42 | I guess. 43 | 44 | **Why does it say it's by Lisa?** 45 | 46 | I signed up for my developer account when I was a kid, used my mom's name, and now it's stuck that way forever and I can't change it. That's life. 47 | 48 | **How come the audio feels kind of...weird?** 49 | 50 | I'm bad at audio. 51 | 52 | **Where did the sound effect come from?** 53 | 54 | LEGO Batman 3: Beyond Gotham. But you knew that already. 55 | 56 | **Can I turn off the sound?** 57 | 58 | Yes, never click "Start Audio". But this energy isn't encouraged. 59 | 60 | ## Building 61 | 62 | According to [this issue](https://github.com/samhenrigold/LidAngleSensor/issues/12), building requires having Xcode installed. I've only tested this on Xcode 26. YMMV. 63 | 64 | ## Installation 65 | 66 | Via Homebrew: 67 | 68 | ```shell 69 | brew install lidanglesensor 70 | ``` 71 | 72 | ## Related projects 73 | 74 | - [Python library that taps into this sensor](https://github.com/tcsenpai/pybooklid) 75 | -------------------------------------------------------------------------------- /LidAngleSensor/LidAngleSensor.m: -------------------------------------------------------------------------------- 1 | // 2 | // LidAngleSensor.m 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import "LidAngleSensor.h" 9 | 10 | @interface LidAngleSensor () 11 | @property (nonatomic, assign) IOHIDDeviceRef hidDevice; 12 | @end 13 | 14 | @implementation LidAngleSensor 15 | 16 | - (instancetype)init { 17 | self = [super init]; 18 | if (self) { 19 | _hidDevice = [self findLidAngleSensor]; 20 | if (_hidDevice) { 21 | IOHIDDeviceOpen(_hidDevice, kIOHIDOptionsTypeNone); 22 | NSLog(@"[LidAngleSensor] Successfully initialized lid angle sensor"); 23 | } else { 24 | NSLog(@"[LidAngleSensor] Failed to find lid angle sensor"); 25 | } 26 | } 27 | return self; 28 | } 29 | 30 | - (void)dealloc { 31 | [self stopLidAngleUpdates]; 32 | } 33 | 34 | - (BOOL)isAvailable { 35 | return _hidDevice != NULL; 36 | } 37 | 38 | - (IOHIDDeviceRef)findLidAngleSensor { 39 | IOHIDManagerRef manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); 40 | if (!manager) { 41 | NSLog(@"[LidAngleSensor] Failed to create IOHIDManager"); 42 | return NULL; 43 | } 44 | 45 | if (IOHIDManagerOpen(manager, kIOHIDOptionsTypeNone) != kIOReturnSuccess) { 46 | NSLog(@"[LidAngleSensor] Failed to open IOHIDManager"); 47 | CFRelease(manager); 48 | return NULL; 49 | } 50 | 51 | // Match specifically for the lid angle sensor to avoid permission prompts 52 | // Target: Sensor page (0x0020), Orientation usage (0x008A) 53 | NSDictionary *matchingDict = @{ 54 | @"VendorID": @(0x05AC), // Apple 55 | @"ProductID": @(0x8104), // Specific product 56 | @"UsagePage": @(0x0020), // Sensor page 57 | @"Usage": @(0x008A), // Orientation usage 58 | }; 59 | 60 | IOHIDManagerSetDeviceMatching(manager, (__bridge CFDictionaryRef)matchingDict); 61 | CFSetRef devices = IOHIDManagerCopyDevices(manager); 62 | IOHIDDeviceRef device = NULL; 63 | 64 | if (devices && CFSetGetCount(devices) > 0) { 65 | NSLog(@"[LidAngleSensor] Found %ld matching lid angle sensor device(s)", CFSetGetCount(devices)); 66 | 67 | const void **deviceArray = malloc(sizeof(void*) * CFSetGetCount(devices)); 68 | CFSetGetValues(devices, deviceArray); 69 | 70 | // Test each matching device to find the one that actually works 71 | for (CFIndex i = 0; i < CFSetGetCount(devices); i++) { 72 | IOHIDDeviceRef testDevice = (IOHIDDeviceRef)deviceArray[i]; 73 | 74 | // Try to open and read from this device 75 | if (IOHIDDeviceOpen(testDevice, kIOHIDOptionsTypeNone) == kIOReturnSuccess) { 76 | uint8_t testReport[8] = {0}; 77 | CFIndex reportLength = sizeof(testReport); 78 | 79 | IOReturn result = IOHIDDeviceGetReport(testDevice, 80 | kIOHIDReportTypeFeature, 81 | 1, 82 | testReport, 83 | &reportLength); 84 | 85 | if (result == kIOReturnSuccess && reportLength >= 3) { 86 | // This device works! Use it. 87 | device = (IOHIDDeviceRef)CFRetain(testDevice); 88 | NSLog(@"[LidAngleSensor] Successfully found working lid angle sensor device (index %ld)", i); 89 | IOHIDDeviceClose(testDevice, kIOHIDOptionsTypeNone); // Close for now, will reopen in init 90 | break; 91 | } else { 92 | NSLog(@"[LidAngleSensor] Device %ld failed to read (result: %d, length: %ld)", i, result, reportLength); 93 | IOHIDDeviceClose(testDevice, kIOHIDOptionsTypeNone); 94 | } 95 | } else { 96 | NSLog(@"[LidAngleSensor] Failed to open device %ld", i); 97 | } 98 | } 99 | 100 | free(deviceArray); 101 | } 102 | 103 | if (devices) CFRelease(devices); 104 | 105 | IOHIDManagerClose(manager, kIOHIDOptionsTypeNone); 106 | CFRelease(manager); 107 | 108 | return device; 109 | } 110 | 111 | - (double)lidAngle { 112 | if (!_hidDevice) { 113 | return -2.0; // Device not available 114 | } 115 | 116 | // Read lid angle using discovered parameters: 117 | // Feature Report Type 2, Report ID 1, returns 3 bytes with 16-bit angle in centidegrees 118 | uint8_t report[8] = {0}; 119 | CFIndex reportLength = sizeof(report); 120 | 121 | IOReturn result = IOHIDDeviceGetReport(_hidDevice, 122 | kIOHIDReportTypeFeature, // Type 2 123 | 1, // Report ID 1 124 | report, 125 | &reportLength); 126 | 127 | if (result == kIOReturnSuccess && reportLength >= 3) { 128 | // Data format: [report_id, angle_low, angle_high] 129 | // Parse the 16-bit value from bytes 1-2 (skipping report ID) 130 | uint16_t rawValue = (report[2] << 8) | report[1]; // High byte, low byte 131 | double angle = (double)rawValue; // Raw value is already in degrees 132 | 133 | return angle; 134 | } 135 | 136 | return -2.0; 137 | } 138 | 139 | - (void)startLidAngleUpdates { 140 | if (!_hidDevice) { 141 | _hidDevice = [self findLidAngleSensor]; 142 | if (_hidDevice) { 143 | NSLog(@"[LidAngleSensor] Starting lid angle updates"); 144 | IOHIDDeviceOpen(_hidDevice, kIOHIDOptionsTypeNone); 145 | } else { 146 | NSLog(@"[LidAngleSensor] Lid angle sensor is not supported"); 147 | } 148 | } 149 | } 150 | 151 | - (void)stopLidAngleUpdates { 152 | if (_hidDevice) { 153 | NSLog(@"[LidAngleSensor] Stopping lid angle updates"); 154 | IOHIDDeviceClose(_hidDevice, kIOHIDOptionsTypeNone); 155 | CFRelease(_hidDevice); 156 | _hidDevice = NULL; 157 | } 158 | } 159 | 160 | @end 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LidAngleSensor/CreakAudioEngine.m: -------------------------------------------------------------------------------- 1 | // 2 | // CreakAudioEngine.m 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import "CreakAudioEngine.h" 9 | 10 | // Audio parameter mapping constants 11 | static const double kDeadzone = 1.0; // deg/s - below this: treat as still 12 | static const double kVelocityFull = 10.0; // deg/s - max creak volume at/under this velocity 13 | static const double kVelocityQuiet = 100.0; // deg/s - silent by/over this velocity (fast movement) 14 | 15 | // Pitch variation constants 16 | static const double kMinRate = 0.80; // Minimum varispeed rate (lower pitch for slow movement) 17 | static const double kMaxRate = 1.10; // Maximum varispeed rate (higher pitch for fast movement) 18 | 19 | // Smoothing and timing constants 20 | static const double kAngleSmoothingFactor = 0.05; // Heavy smoothing for sensor noise (5% new, 95% old) 21 | static const double kVelocitySmoothingFactor = 0.3; // Moderate smoothing for velocity 22 | static const double kMovementThreshold = 0.5; // Minimum angle change to register as movement (degrees) 23 | static const double kGainRampTimeMs = 50.0; // Gain ramping time constant (milliseconds) 24 | static const double kRateRampTimeMs = 80.0; // Rate ramping time constant (milliseconds) 25 | static const double kMovementTimeoutMs = 50.0; // Time before aggressive velocity decay (milliseconds) 26 | static const double kVelocityDecayFactor = 0.5; // Decay rate when no movement detected 27 | static const double kAdditionalDecayFactor = 0.8; // Additional decay after timeout 28 | 29 | @interface CreakAudioEngine () 30 | 31 | // Audio engine components 32 | @property (nonatomic, strong) AVAudioEngine *audioEngine; 33 | @property (nonatomic, strong) AVAudioPlayerNode *creakPlayerNode; 34 | @property (nonatomic, strong) AVAudioUnitVarispeed *varispeadUnit; 35 | @property (nonatomic, strong) AVAudioMixerNode *mixerNode; 36 | 37 | // Audio files 38 | @property (nonatomic, strong) AVAudioFile *creakLoopFile; 39 | 40 | // State tracking 41 | @property (nonatomic, assign) double lastLidAngle; 42 | @property (nonatomic, assign) double smoothedLidAngle; 43 | @property (nonatomic, assign) double lastUpdateTime; 44 | @property (nonatomic, assign) double smoothedVelocity; 45 | @property (nonatomic, assign) double targetGain; 46 | @property (nonatomic, assign) double targetRate; 47 | @property (nonatomic, assign) double currentGain; 48 | @property (nonatomic, assign) double currentRate; 49 | @property (nonatomic, assign) BOOL isFirstUpdate; 50 | @property (nonatomic, assign) NSTimeInterval lastMovementTime; 51 | 52 | @end 53 | 54 | @implementation CreakAudioEngine 55 | 56 | - (instancetype)init { 57 | self = [super init]; 58 | if (self) { 59 | _isFirstUpdate = YES; 60 | _lastUpdateTime = CACurrentMediaTime(); 61 | _lastMovementTime = CACurrentMediaTime(); 62 | _lastLidAngle = 0.0; 63 | _smoothedLidAngle = 0.0; 64 | _smoothedVelocity = 0.0; 65 | _targetGain = 0.0; 66 | _targetRate = 1.0; 67 | _currentGain = 0.0; 68 | _currentRate = 1.0; 69 | 70 | if (![self setupAudioEngine]) { 71 | NSLog(@"[CreakAudioEngine] Failed to setup audio engine"); 72 | return nil; 73 | } 74 | 75 | if (![self loadAudioFiles]) { 76 | NSLog(@"[CreakAudioEngine] Failed to load audio files"); 77 | return nil; 78 | } 79 | } 80 | return self; 81 | } 82 | 83 | - (void)dealloc { 84 | [self stopEngine]; 85 | } 86 | 87 | #pragma mark - Audio Engine Setup 88 | 89 | - (BOOL)setupAudioEngine { 90 | self.audioEngine = [[AVAudioEngine alloc] init]; 91 | 92 | // Create audio nodes 93 | self.creakPlayerNode = [[AVAudioPlayerNode alloc] init]; 94 | self.varispeadUnit = [[AVAudioUnitVarispeed alloc] init]; 95 | self.mixerNode = self.audioEngine.mainMixerNode; 96 | 97 | // Attach nodes to engine 98 | [self.audioEngine attachNode:self.creakPlayerNode]; 99 | [self.audioEngine attachNode:self.varispeadUnit]; 100 | 101 | // Audio connections will be made after loading the file to use its native format 102 | return YES; 103 | } 104 | 105 | - (BOOL)loadAudioFiles { 106 | NSBundle *bundle = [NSBundle mainBundle]; 107 | 108 | // Load creak loop file 109 | NSString *creakPath = [bundle pathForResource:@"CREAK_LOOP" ofType:@"wav"]; 110 | if (!creakPath) { 111 | NSLog(@"[CreakAudioEngine] Could not find CREAK_LOOP.wav"); 112 | return NO; 113 | } 114 | 115 | NSError *error; 116 | NSURL *creakURL = [NSURL fileURLWithPath:creakPath]; 117 | self.creakLoopFile = [[AVAudioFile alloc] initForReading:creakURL error:&error]; 118 | if (!self.creakLoopFile) { 119 | NSLog(@"[CreakAudioEngine] Failed to load CREAK_LOOP.wav: %@", error.localizedDescription); 120 | return NO; 121 | } 122 | 123 | // Connect the audio graph using the file's native format 124 | AVAudioFormat *fileFormat = self.creakLoopFile.processingFormat; 125 | 126 | // Connect audio graph: CreakPlayer -> Varispeed -> Mixer 127 | [self.audioEngine connect:self.creakPlayerNode to:self.varispeadUnit format:fileFormat]; 128 | [self.audioEngine connect:self.varispeadUnit to:self.mixerNode format:fileFormat]; 129 | return YES; 130 | } 131 | 132 | #pragma mark - Engine Control 133 | 134 | - (void)startEngine { 135 | if (self.isEngineRunning) { 136 | return; 137 | } 138 | 139 | NSError *error; 140 | if (![self.audioEngine startAndReturnError:&error]) { 141 | NSLog(@"[CreakAudioEngine] Failed to start audio engine: %@", error.localizedDescription); 142 | return; 143 | } 144 | 145 | // Start looping the creak sound 146 | [self startCreakLoop]; 147 | } 148 | 149 | - (void)stopEngine { 150 | if (!self.isEngineRunning) { 151 | return; 152 | } 153 | 154 | [self.creakPlayerNode stop]; 155 | [self.audioEngine stop]; 156 | } 157 | 158 | - (BOOL)isEngineRunning { 159 | return self.audioEngine.isRunning; 160 | } 161 | 162 | #pragma mark - Creak Loop Management 163 | 164 | - (void)startCreakLoop { 165 | if (!self.creakPlayerNode || !self.creakLoopFile) { 166 | return; 167 | } 168 | 169 | // Reset file position to beginning 170 | self.creakLoopFile.framePosition = 0; 171 | 172 | // Schedule the creak loop to play continuously 173 | AVAudioFrameCount frameCount = (AVAudioFrameCount)self.creakLoopFile.length; 174 | AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.creakLoopFile.processingFormat 175 | frameCapacity:frameCount]; 176 | 177 | NSError *error; 178 | if (![self.creakLoopFile readIntoBuffer:buffer error:&error]) { 179 | NSLog(@"[CreakAudioEngine] Failed to read creak loop into buffer: %@", error.localizedDescription); 180 | return; 181 | } 182 | 183 | [self.creakPlayerNode scheduleBuffer:buffer atTime:nil options:AVAudioPlayerNodeBufferLoops completionHandler:nil]; 184 | [self.creakPlayerNode play]; 185 | 186 | // Set initial volume to 0 (will be controlled by gain) 187 | self.creakPlayerNode.volume = 0.0; 188 | } 189 | 190 | #pragma mark - Velocity Calculation and Parameter Mapping 191 | 192 | - (void)updateWithLidAngle:(double)lidAngle { 193 | double currentTime = CACurrentMediaTime(); 194 | 195 | if (self.isFirstUpdate) { 196 | self.lastLidAngle = lidAngle; 197 | self.smoothedLidAngle = lidAngle; 198 | self.lastUpdateTime = currentTime; 199 | self.lastMovementTime = currentTime; 200 | self.isFirstUpdate = NO; 201 | return; 202 | } 203 | 204 | // Calculate time delta 205 | double deltaTime = currentTime - self.lastUpdateTime; 206 | if (deltaTime <= 0 || deltaTime > 1.0) { 207 | // Skip if time delta is invalid or too large (likely app was backgrounded) 208 | self.lastUpdateTime = currentTime; 209 | return; 210 | } 211 | 212 | // Stage 1: Smooth the raw angle input to eliminate sensor jitter 213 | self.smoothedLidAngle = (kAngleSmoothingFactor * lidAngle) + 214 | ((1.0 - kAngleSmoothingFactor) * self.smoothedLidAngle); 215 | 216 | // Stage 2: Calculate velocity from smoothed angle data 217 | double deltaAngle = self.smoothedLidAngle - self.lastLidAngle; 218 | double instantVelocity; 219 | 220 | // Apply movement threshold to eliminate remaining noise 221 | if (fabs(deltaAngle) < kMovementThreshold) { 222 | instantVelocity = 0.0; 223 | } else { 224 | instantVelocity = fabs(deltaAngle / deltaTime); 225 | self.lastLidAngle = self.smoothedLidAngle; 226 | } 227 | 228 | // Stage 3: Apply velocity smoothing and decay 229 | if (instantVelocity > 0.0) { 230 | // Real movement detected - apply moderate smoothing 231 | self.smoothedVelocity = (kVelocitySmoothingFactor * instantVelocity) + 232 | ((1.0 - kVelocitySmoothingFactor) * self.smoothedVelocity); 233 | self.lastMovementTime = currentTime; 234 | } else { 235 | // No movement detected - apply fast decay 236 | self.smoothedVelocity *= kVelocityDecayFactor; 237 | } 238 | 239 | // Additional decay if no movement for extended period 240 | double timeSinceMovement = currentTime - self.lastMovementTime; 241 | if (timeSinceMovement > (kMovementTimeoutMs / 1000.0)) { 242 | self.smoothedVelocity *= kAdditionalDecayFactor; 243 | } 244 | 245 | // Update state for next iteration 246 | self.lastUpdateTime = currentTime; 247 | 248 | // Apply velocity-based parameter mapping 249 | [self updateAudioParametersWithVelocity:self.smoothedVelocity]; 250 | } 251 | 252 | - (void)setAngularVelocity:(double)velocity { 253 | self.smoothedVelocity = velocity; 254 | [self updateAudioParametersWithVelocity:velocity]; 255 | } 256 | 257 | - (void)updateAudioParametersWithVelocity:(double)velocity { 258 | double speed = velocity; // Velocity is already absolute 259 | 260 | // Calculate target gain: slow movement = loud creak, fast movement = quiet/silent 261 | double gain; 262 | if (speed < kDeadzone) { 263 | gain = 0.0; // Below deadzone: no sound 264 | } else { 265 | // Use inverted smoothstep curve for natural volume response 266 | double e0 = fmax(0.0, kVelocityFull - 0.5); 267 | double e1 = kVelocityQuiet + 0.5; 268 | double t = fmin(1.0, fmax(0.0, (speed - e0) / (e1 - e0))); 269 | double s = t * t * (3.0 - 2.0 * t); // smoothstep function 270 | gain = 1.0 - s; // invert: slow = loud, fast = quiet 271 | gain = fmax(0.0, fmin(1.0, gain)); 272 | } 273 | 274 | // Calculate target pitch/tempo rate based on movement speed 275 | double normalizedVelocity = fmax(0.0, fmin(1.0, speed / kVelocityQuiet)); 276 | double rate = kMinRate + normalizedVelocity * (kMaxRate - kMinRate); 277 | rate = fmax(kMinRate, fmin(kMaxRate, rate)); 278 | 279 | // Store targets for smooth ramping 280 | self.targetGain = gain; 281 | self.targetRate = rate; 282 | 283 | // Apply smooth parameter transitions 284 | [self rampToTargetParameters]; 285 | } 286 | 287 | // Helper function for parameter ramping 288 | - (double)rampValue:(double)current toward:(double)target withDeltaTime:(double)dt timeConstantMs:(double)tauMs { 289 | double alpha = fmin(1.0, dt / (tauMs / 1000.0)); // linear ramp coefficient 290 | return current + (target - current) * alpha; 291 | } 292 | 293 | - (void)rampToTargetParameters { 294 | if (!self.isEngineRunning) { 295 | return; 296 | } 297 | 298 | // Calculate delta time for ramping 299 | static double lastRampTime = 0; 300 | double currentTime = CACurrentMediaTime(); 301 | if (lastRampTime == 0) lastRampTime = currentTime; 302 | double deltaTime = currentTime - lastRampTime; 303 | lastRampTime = currentTime; 304 | 305 | // Ramp current values toward targets for smooth transitions 306 | self.currentGain = [self rampValue:self.currentGain toward:self.targetGain withDeltaTime:deltaTime timeConstantMs:kGainRampTimeMs]; 307 | self.currentRate = [self rampValue:self.currentRate toward:self.targetRate withDeltaTime:deltaTime timeConstantMs:kRateRampTimeMs]; 308 | 309 | // Apply ramped values to audio nodes (2x multiplier for audible volume) 310 | self.creakPlayerNode.volume = (float)(self.currentGain * 2.0); 311 | self.varispeadUnit.rate = (float)self.currentRate; 312 | } 313 | 314 | #pragma mark - Property Accessors 315 | 316 | - (double)currentVelocity { 317 | return self.smoothedVelocity; 318 | } 319 | 320 | - (double)currentGain { 321 | return _currentGain; 322 | } 323 | 324 | - (double)currentRate { 325 | return _currentRate; 326 | } 327 | 328 | @end 329 | 330 | -------------------------------------------------------------------------------- /LidAngleSensor/ThereminAudioEngine.m: -------------------------------------------------------------------------------- 1 | // 2 | // ThereminAudioEngine.m 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import "ThereminAudioEngine.h" 9 | #import 10 | 11 | // Theremin parameter mapping constants 12 | static const double kMinFrequency = 110.0; // Hz - A2 note (closed lid) 13 | static const double kMaxFrequency = 440.0; // Hz - A4 note (open lid) - much lower range 14 | static const double kMinAngle = 0.0; // degrees - closed lid 15 | static const double kMaxAngle = 135.0; // degrees - fully open lid 16 | 17 | // Volume control constants - continuous tone with velocity modulation 18 | static const double kBaseVolume = 0.6; // Base volume when at rest 19 | static const double kVelocityVolumeBoost = 0.4; // Additional volume boost from movement 20 | static const double kVelocityFull = 8.0; // deg/s - max volume boost at/under this velocity 21 | static const double kVelocityQuiet = 80.0; // deg/s - no volume boost over this velocity 22 | 23 | // Vibrato constants 24 | static const double kVibratoFrequency = 5.0; // Hz - vibrato rate 25 | static const double kVibratoDepth = 0.03; // Vibrato depth as fraction of frequency (3%) 26 | 27 | // Smoothing constants 28 | static const double kAngleSmoothingFactor = 0.1; // Moderate smoothing for frequency 29 | static const double kVelocitySmoothingFactor = 0.3; // Moderate smoothing for velocity 30 | static const double kFrequencyRampTimeMs = 30.0; // Frequency ramping time constant 31 | static const double kVolumeRampTimeMs = 50.0; // Volume ramping time constant 32 | static const double kMovementThreshold = 0.3; // Minimum angle change to register movement 33 | static const double kMovementTimeoutMs = 100.0; // Time before velocity decay 34 | static const double kVelocityDecayFactor = 0.7; // Decay rate when no movement 35 | static const double kAdditionalDecayFactor = 0.85; // Additional decay after timeout 36 | 37 | // Audio constants 38 | static const double kSampleRate = 44100.0; 39 | static const UInt32 kBufferSize = 512; 40 | 41 | @interface ThereminAudioEngine () 42 | 43 | // Audio engine components 44 | @property (nonatomic, strong) AVAudioEngine *audioEngine; 45 | @property (nonatomic, strong) AVAudioSourceNode *sourceNode; 46 | @property (nonatomic, strong) AVAudioMixerNode *mixerNode; 47 | 48 | // State tracking 49 | @property (nonatomic, assign) double lastLidAngle; 50 | @property (nonatomic, assign) double smoothedLidAngle; 51 | @property (nonatomic, assign) double lastUpdateTime; 52 | @property (nonatomic, assign) double smoothedVelocity; 53 | @property (nonatomic, assign) double targetFrequency; 54 | @property (nonatomic, assign) double targetVolume; 55 | @property (nonatomic, assign) double currentFrequency; 56 | @property (nonatomic, assign) double currentVolume; 57 | @property (nonatomic, assign) BOOL isFirstUpdate; 58 | @property (nonatomic, assign) NSTimeInterval lastMovementTime; 59 | 60 | // Sine wave generation 61 | @property (nonatomic, assign) double phase; 62 | @property (nonatomic, assign) double phaseIncrement; 63 | 64 | // Vibrato generation 65 | @property (nonatomic, assign) double vibratoPhase; 66 | 67 | @end 68 | 69 | @implementation ThereminAudioEngine 70 | 71 | - (instancetype)init { 72 | self = [super init]; 73 | if (self) { 74 | _isFirstUpdate = YES; 75 | _lastUpdateTime = CACurrentMediaTime(); 76 | _lastMovementTime = CACurrentMediaTime(); 77 | _lastLidAngle = 0.0; 78 | _smoothedLidAngle = 0.0; 79 | _smoothedVelocity = 0.0; 80 | _targetFrequency = kMinFrequency; 81 | _targetVolume = kBaseVolume; 82 | _currentFrequency = kMinFrequency; 83 | _currentVolume = kBaseVolume; 84 | _phase = 0.0; 85 | _vibratoPhase = 0.0; 86 | _phaseIncrement = 2.0 * M_PI * kMinFrequency / kSampleRate; 87 | 88 | if (![self setupAudioEngine]) { 89 | NSLog(@"[ThereminAudioEngine] Failed to setup audio engine"); 90 | return nil; 91 | } 92 | } 93 | return self; 94 | } 95 | 96 | - (void)dealloc { 97 | [self stopEngine]; 98 | } 99 | 100 | #pragma mark - Audio Engine Setup 101 | 102 | - (BOOL)setupAudioEngine { 103 | self.audioEngine = [[AVAudioEngine alloc] init]; 104 | self.mixerNode = self.audioEngine.mainMixerNode; 105 | 106 | // Create audio format for our sine wave 107 | AVAudioFormat *format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 108 | sampleRate:kSampleRate 109 | channels:1 110 | interleaved:NO]; 111 | 112 | // Create source node for sine wave generation 113 | __weak typeof(self) weakSelf = self; 114 | self.sourceNode = [[AVAudioSourceNode alloc] initWithFormat:format renderBlock:^OSStatus(BOOL * _Nonnull isSilence, const AudioTimeStamp * _Nonnull timestamp, AVAudioFrameCount frameCount, AudioBufferList * _Nonnull outputData) { 115 | return [weakSelf renderSineWave:isSilence timestamp:timestamp frameCount:frameCount outputData:outputData]; 116 | }]; 117 | 118 | // Attach and connect the source node 119 | [self.audioEngine attachNode:self.sourceNode]; 120 | [self.audioEngine connect:self.sourceNode to:self.mixerNode format:format]; 121 | 122 | return YES; 123 | } 124 | 125 | #pragma mark - Engine Control 126 | 127 | - (void)startEngine { 128 | if (self.isEngineRunning) { 129 | return; 130 | } 131 | 132 | NSError *error; 133 | if (![self.audioEngine startAndReturnError:&error]) { 134 | NSLog(@"[ThereminAudioEngine] Failed to start audio engine: %@", error.localizedDescription); 135 | return; 136 | } 137 | 138 | NSLog(@"[ThereminAudioEngine] Started theremin engine"); 139 | } 140 | 141 | - (void)stopEngine { 142 | if (!self.isEngineRunning) { 143 | return; 144 | } 145 | 146 | [self.audioEngine stop]; 147 | NSLog(@"[ThereminAudioEngine] Stopped theremin engine"); 148 | } 149 | 150 | - (BOOL)isEngineRunning { 151 | return self.audioEngine.isRunning; 152 | } 153 | 154 | #pragma mark - Sine Wave Generation 155 | 156 | - (OSStatus)renderSineWave:(BOOL *)isSilence 157 | timestamp:(const AudioTimeStamp *)timestamp 158 | frameCount:(AVAudioFrameCount)frameCount 159 | outputData:(AudioBufferList *)outputData { 160 | 161 | float *output = (float *)outputData->mBuffers[0].mData; 162 | 163 | // Always generate sound (continuous tone) 164 | *isSilence = NO; 165 | 166 | // Calculate vibrato phase increment 167 | double vibratoPhaseIncrement = 2.0 * M_PI * kVibratoFrequency / kSampleRate; 168 | 169 | // Generate sine wave samples with vibrato 170 | for (AVAudioFrameCount i = 0; i < frameCount; i++) { 171 | // Calculate vibrato modulation 172 | double vibratoModulation = sin(self.vibratoPhase) * kVibratoDepth; 173 | double modulatedFrequency = self.currentFrequency * (1.0 + vibratoModulation); 174 | 175 | // Update phase increment for modulated frequency 176 | self.phaseIncrement = 2.0 * M_PI * modulatedFrequency / kSampleRate; 177 | 178 | // Generate sample with vibrato and current volume 179 | output[i] = (float)(sin(self.phase) * self.currentVolume * 0.25); // 0.25 to prevent clipping 180 | 181 | // Update phases 182 | self.phase += self.phaseIncrement; 183 | self.vibratoPhase += vibratoPhaseIncrement; 184 | 185 | // Wrap phases to prevent accumulation of floating point errors 186 | if (self.phase >= 2.0 * M_PI) { 187 | self.phase -= 2.0 * M_PI; 188 | } 189 | if (self.vibratoPhase >= 2.0 * M_PI) { 190 | self.vibratoPhase -= 2.0 * M_PI; 191 | } 192 | } 193 | 194 | return noErr; 195 | } 196 | 197 | #pragma mark - Lid Angle Processing 198 | 199 | - (void)updateWithLidAngle:(double)lidAngle { 200 | double currentTime = CACurrentMediaTime(); 201 | 202 | if (self.isFirstUpdate) { 203 | self.lastLidAngle = lidAngle; 204 | self.smoothedLidAngle = lidAngle; 205 | self.lastUpdateTime = currentTime; 206 | self.lastMovementTime = currentTime; 207 | self.isFirstUpdate = NO; 208 | 209 | // Set initial frequency based on angle 210 | [self updateTargetParametersWithAngle:lidAngle velocity:0.0]; 211 | return; 212 | } 213 | 214 | // Calculate time delta 215 | double deltaTime = currentTime - self.lastUpdateTime; 216 | if (deltaTime <= 0 || deltaTime > 1.0) { 217 | // Skip if time delta is invalid or too large 218 | self.lastUpdateTime = currentTime; 219 | return; 220 | } 221 | 222 | // Stage 1: Smooth the raw angle input 223 | self.smoothedLidAngle = (kAngleSmoothingFactor * lidAngle) + 224 | ((1.0 - kAngleSmoothingFactor) * self.smoothedLidAngle); 225 | 226 | // Stage 2: Calculate velocity from smoothed angle data 227 | double deltaAngle = self.smoothedLidAngle - self.lastLidAngle; 228 | double instantVelocity; 229 | 230 | // Apply movement threshold 231 | if (fabs(deltaAngle) < kMovementThreshold) { 232 | instantVelocity = 0.0; 233 | } else { 234 | instantVelocity = fabs(deltaAngle / deltaTime); 235 | self.lastLidAngle = self.smoothedLidAngle; 236 | } 237 | 238 | // Stage 3: Apply velocity smoothing and decay 239 | if (instantVelocity > 0.0) { 240 | self.smoothedVelocity = (kVelocitySmoothingFactor * instantVelocity) + 241 | ((1.0 - kVelocitySmoothingFactor) * self.smoothedVelocity); 242 | self.lastMovementTime = currentTime; 243 | } else { 244 | self.smoothedVelocity *= kVelocityDecayFactor; 245 | } 246 | 247 | // Additional decay if no movement for extended period 248 | double timeSinceMovement = currentTime - self.lastMovementTime; 249 | if (timeSinceMovement > (kMovementTimeoutMs / 1000.0)) { 250 | self.smoothedVelocity *= kAdditionalDecayFactor; 251 | } 252 | 253 | // Update state for next iteration 254 | self.lastUpdateTime = currentTime; 255 | 256 | // Update target parameters 257 | [self updateTargetParametersWithAngle:self.smoothedLidAngle velocity:self.smoothedVelocity]; 258 | 259 | // Apply smooth parameter transitions 260 | [self rampToTargetParameters]; 261 | } 262 | 263 | - (void)setAngularVelocity:(double)velocity { 264 | self.smoothedVelocity = velocity; 265 | [self updateTargetParametersWithAngle:self.smoothedLidAngle velocity:velocity]; 266 | [self rampToTargetParameters]; 267 | } 268 | 269 | - (void)updateTargetParametersWithAngle:(double)angle velocity:(double)velocity { 270 | // Map angle to frequency using exponential curve for musical feel 271 | double normalizedAngle = fmax(0.0, fmin(1.0, (angle - kMinAngle) / (kMaxAngle - kMinAngle))); 272 | 273 | // Use exponential mapping for more musical frequency distribution 274 | double frequencyRatio = pow(normalizedAngle, 0.7); // Slight compression for better control 275 | self.targetFrequency = kMinFrequency + frequencyRatio * (kMaxFrequency - kMinFrequency); 276 | 277 | // Calculate continuous volume with velocity-based boost 278 | double velocityBoost = 0.0; 279 | if (velocity > 0.0) { 280 | // Use smoothstep curve for natural volume boost response 281 | double e0 = 0.0; 282 | double e1 = kVelocityQuiet; 283 | double t = fmin(1.0, fmax(0.0, (velocity - e0) / (e1 - e0))); 284 | double s = t * t * (3.0 - 2.0 * t); // smoothstep function 285 | velocityBoost = (1.0 - s) * kVelocityVolumeBoost; // invert: slow = more boost, fast = less boost 286 | } 287 | 288 | // Combine base volume with velocity boost 289 | self.targetVolume = kBaseVolume + velocityBoost; 290 | self.targetVolume = fmax(0.0, fmin(1.0, self.targetVolume)); 291 | } 292 | 293 | // Helper function for parameter ramping 294 | - (double)rampValue:(double)current toward:(double)target withDeltaTime:(double)dt timeConstantMs:(double)tauMs { 295 | double alpha = fmin(1.0, dt / (tauMs / 1000.0)); 296 | return current + (target - current) * alpha; 297 | } 298 | 299 | - (void)rampToTargetParameters { 300 | // Calculate delta time for ramping 301 | static double lastRampTime = 0; 302 | double currentTime = CACurrentMediaTime(); 303 | if (lastRampTime == 0) lastRampTime = currentTime; 304 | double deltaTime = currentTime - lastRampTime; 305 | lastRampTime = currentTime; 306 | 307 | // Ramp current values toward targets for smooth transitions 308 | self.currentFrequency = [self rampValue:self.currentFrequency toward:self.targetFrequency withDeltaTime:deltaTime timeConstantMs:kFrequencyRampTimeMs]; 309 | self.currentVolume = [self rampValue:self.currentVolume toward:self.targetVolume withDeltaTime:deltaTime timeConstantMs:kVolumeRampTimeMs]; 310 | } 311 | 312 | #pragma mark - Property Accessors 313 | 314 | - (double)currentVelocity { 315 | return self.smoothedVelocity; 316 | } 317 | 318 | @end 319 | -------------------------------------------------------------------------------- /LidAngleSensor.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | EA6BE6462E6CA1E1003F66D4 /* LidAngleSensor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LidAngleSensor.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 14 | EA6BE6482E6CA1E1003F66D4 /* LidAngleSensor */ = { 15 | isa = PBXFileSystemSynchronizedRootGroup; 16 | path = LidAngleSensor; 17 | sourceTree = ""; 18 | }; 19 | /* End PBXFileSystemSynchronizedRootGroup section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | EA6BE6432E6CA1E1003F66D4 /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | ); 27 | runOnlyForDeploymentPostprocessing = 0; 28 | }; 29 | /* End PBXFrameworksBuildPhase section */ 30 | 31 | /* Begin PBXGroup section */ 32 | EA6BE63D2E6CA1E1003F66D4 = { 33 | isa = PBXGroup; 34 | children = ( 35 | EA6BE6482E6CA1E1003F66D4 /* LidAngleSensor */, 36 | EA6BE6472E6CA1E1003F66D4 /* Products */, 37 | ); 38 | sourceTree = ""; 39 | }; 40 | EA6BE6472E6CA1E1003F66D4 /* Products */ = { 41 | isa = PBXGroup; 42 | children = ( 43 | EA6BE6462E6CA1E1003F66D4 /* LidAngleSensor.app */, 44 | ); 45 | name = Products; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXGroup section */ 49 | 50 | /* Begin PBXNativeTarget section */ 51 | EA6BE6452E6CA1E1003F66D4 /* LidAngleSensor */ = { 52 | isa = PBXNativeTarget; 53 | buildConfigurationList = EA6BE6552E6CA1E1003F66D4 /* Build configuration list for PBXNativeTarget "LidAngleSensor" */; 54 | buildPhases = ( 55 | EA6BE6422E6CA1E1003F66D4 /* Sources */, 56 | EA6BE6432E6CA1E1003F66D4 /* Frameworks */, 57 | EA6BE6442E6CA1E1003F66D4 /* Resources */, 58 | ); 59 | buildRules = ( 60 | ); 61 | dependencies = ( 62 | ); 63 | fileSystemSynchronizedGroups = ( 64 | EA6BE6482E6CA1E1003F66D4 /* LidAngleSensor */, 65 | ); 66 | name = LidAngleSensor; 67 | packageProductDependencies = ( 68 | ); 69 | productName = LidAngleSensor; 70 | productReference = EA6BE6462E6CA1E1003F66D4 /* LidAngleSensor.app */; 71 | productType = "com.apple.product-type.application"; 72 | }; 73 | /* End PBXNativeTarget section */ 74 | 75 | /* Begin PBXProject section */ 76 | EA6BE63E2E6CA1E1003F66D4 /* Project object */ = { 77 | isa = PBXProject; 78 | attributes = { 79 | BuildIndependentTargetsInParallel = 1; 80 | LastUpgradeCheck = 2600; 81 | TargetAttributes = { 82 | EA6BE6452E6CA1E1003F66D4 = { 83 | CreatedOnToolsVersion = 26.0; 84 | }; 85 | }; 86 | }; 87 | buildConfigurationList = EA6BE6412E6CA1E1003F66D4 /* Build configuration list for PBXProject "LidAngleSensor" */; 88 | developmentRegion = en; 89 | hasScannedForEncodings = 0; 90 | knownRegions = ( 91 | en, 92 | Base, 93 | ); 94 | mainGroup = EA6BE63D2E6CA1E1003F66D4; 95 | minimizedProjectReferenceProxies = 1; 96 | preferredProjectObjectVersion = 77; 97 | productRefGroup = EA6BE6472E6CA1E1003F66D4 /* Products */; 98 | projectDirPath = ""; 99 | projectRoot = ""; 100 | targets = ( 101 | EA6BE6452E6CA1E1003F66D4 /* LidAngleSensor */, 102 | ); 103 | }; 104 | /* End PBXProject section */ 105 | 106 | /* Begin PBXResourcesBuildPhase section */ 107 | EA6BE6442E6CA1E1003F66D4 /* Resources */ = { 108 | isa = PBXResourcesBuildPhase; 109 | buildActionMask = 2147483647; 110 | files = ( 111 | ); 112 | runOnlyForDeploymentPostprocessing = 0; 113 | }; 114 | /* End PBXResourcesBuildPhase section */ 115 | 116 | /* Begin PBXSourcesBuildPhase section */ 117 | EA6BE6422E6CA1E1003F66D4 /* Sources */ = { 118 | isa = PBXSourcesBuildPhase; 119 | buildActionMask = 2147483647; 120 | files = ( 121 | ); 122 | runOnlyForDeploymentPostprocessing = 0; 123 | }; 124 | /* End PBXSourcesBuildPhase section */ 125 | 126 | /* Begin XCBuildConfiguration section */ 127 | EA6BE6532E6CA1E1003F66D4 /* Debug */ = { 128 | isa = XCBuildConfiguration; 129 | buildSettings = { 130 | ALWAYS_SEARCH_USER_PATHS = NO; 131 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 132 | CLANG_ANALYZER_NONNULL = YES; 133 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 134 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 135 | CLANG_ENABLE_MODULES = YES; 136 | CLANG_ENABLE_OBJC_ARC = YES; 137 | CLANG_ENABLE_OBJC_WEAK = YES; 138 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 139 | CLANG_WARN_BOOL_CONVERSION = YES; 140 | CLANG_WARN_COMMA = YES; 141 | CLANG_WARN_CONSTANT_CONVERSION = YES; 142 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 143 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 144 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 145 | CLANG_WARN_EMPTY_BODY = YES; 146 | CLANG_WARN_ENUM_CONVERSION = YES; 147 | CLANG_WARN_INFINITE_RECURSION = YES; 148 | CLANG_WARN_INT_CONVERSION = YES; 149 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 150 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 151 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 152 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 153 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 154 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 155 | CLANG_WARN_STRICT_PROTOTYPES = YES; 156 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 157 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 158 | CLANG_WARN_UNREACHABLE_CODE = YES; 159 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 160 | COPY_PHASE_STRIP = NO; 161 | DEBUG_INFORMATION_FORMAT = dwarf; 162 | DEVELOPMENT_TEAM = CG56CG5WCQ; 163 | ENABLE_STRICT_OBJC_MSGSEND = YES; 164 | ENABLE_TESTABILITY = YES; 165 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu17; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 181 | MACOSX_DEPLOYMENT_TARGET = 26.0; 182 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 183 | MTL_FAST_MATH = YES; 184 | ONLY_ACTIVE_ARCH = YES; 185 | SDKROOT = macosx; 186 | }; 187 | name = Debug; 188 | }; 189 | EA6BE6542E6CA1E1003F66D4 /* Release */ = { 190 | isa = XCBuildConfiguration; 191 | buildSettings = { 192 | ALWAYS_SEARCH_USER_PATHS = NO; 193 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 194 | CLANG_ANALYZER_NONNULL = YES; 195 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 196 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 197 | CLANG_ENABLE_MODULES = YES; 198 | CLANG_ENABLE_OBJC_ARC = YES; 199 | CLANG_ENABLE_OBJC_WEAK = YES; 200 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 201 | CLANG_WARN_BOOL_CONVERSION = YES; 202 | CLANG_WARN_COMMA = YES; 203 | CLANG_WARN_CONSTANT_CONVERSION = YES; 204 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 205 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 206 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 207 | CLANG_WARN_EMPTY_BODY = YES; 208 | CLANG_WARN_ENUM_CONVERSION = YES; 209 | CLANG_WARN_INFINITE_RECURSION = YES; 210 | CLANG_WARN_INT_CONVERSION = YES; 211 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 212 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 213 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 215 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 216 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 217 | CLANG_WARN_STRICT_PROTOTYPES = YES; 218 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 219 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 220 | CLANG_WARN_UNREACHABLE_CODE = YES; 221 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 222 | COPY_PHASE_STRIP = NO; 223 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 224 | DEVELOPMENT_TEAM = CG56CG5WCQ; 225 | ENABLE_NS_ASSERTIONS = NO; 226 | ENABLE_STRICT_OBJC_MSGSEND = YES; 227 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 228 | GCC_C_LANGUAGE_STANDARD = gnu17; 229 | GCC_NO_COMMON_BLOCKS = YES; 230 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 231 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 232 | GCC_WARN_UNDECLARED_SELECTOR = YES; 233 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 234 | GCC_WARN_UNUSED_FUNCTION = YES; 235 | GCC_WARN_UNUSED_VARIABLE = YES; 236 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 237 | MACOSX_DEPLOYMENT_TARGET = 26.0; 238 | MTL_ENABLE_DEBUG_INFO = NO; 239 | MTL_FAST_MATH = YES; 240 | SDKROOT = macosx; 241 | }; 242 | name = Release; 243 | }; 244 | EA6BE6562E6CA1E1003F66D4 /* Debug */ = { 245 | isa = XCBuildConfiguration; 246 | buildSettings = { 247 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 248 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 249 | AUTOMATION_APPLE_EVENTS = NO; 250 | CODE_SIGN_STYLE = Automatic; 251 | COMBINE_HIDPI_IMAGES = YES; 252 | CURRENT_PROJECT_VERSION = 1; 253 | DEVELOPMENT_TEAM = CG56CG5WCQ; 254 | ENABLE_APP_SANDBOX = NO; 255 | ENABLE_HARDENED_RUNTIME = YES; 256 | ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; 257 | ENABLE_RESOURCE_ACCESS_CALENDARS = NO; 258 | ENABLE_RESOURCE_ACCESS_CAMERA = NO; 259 | ENABLE_RESOURCE_ACCESS_CONTACTS = NO; 260 | ENABLE_RESOURCE_ACCESS_LOCATION = NO; 261 | ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; 262 | GENERATE_INFOPLIST_FILE = YES; 263 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 264 | INFOPLIST_KEY_NSMainNibFile = MainMenu; 265 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 266 | LD_RUNPATH_SEARCH_PATHS = ( 267 | "$(inherited)", 268 | "@executable_path/../Frameworks", 269 | ); 270 | MACOSX_DEPLOYMENT_TARGET = 11.5; 271 | MARKETING_VERSION = 1.0.3; 272 | PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor; 273 | PRODUCT_NAME = "$(TARGET_NAME)"; 274 | REGISTER_APP_GROUPS = YES; 275 | RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; 276 | RUNTIME_EXCEPTION_ALLOW_JIT = NO; 277 | RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; 278 | RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; 279 | RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; 280 | RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; 281 | STRING_CATALOG_GENERATE_SYMBOLS = YES; 282 | SWIFT_EMIT_LOC_STRINGS = YES; 283 | }; 284 | name = Debug; 285 | }; 286 | EA6BE6572E6CA1E1003F66D4 /* Release */ = { 287 | isa = XCBuildConfiguration; 288 | buildSettings = { 289 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 290 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 291 | AUTOMATION_APPLE_EVENTS = NO; 292 | CODE_SIGN_STYLE = Automatic; 293 | COMBINE_HIDPI_IMAGES = YES; 294 | CURRENT_PROJECT_VERSION = 1; 295 | DEVELOPMENT_TEAM = CG56CG5WCQ; 296 | ENABLE_APP_SANDBOX = NO; 297 | ENABLE_HARDENED_RUNTIME = YES; 298 | ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; 299 | ENABLE_RESOURCE_ACCESS_CALENDARS = NO; 300 | ENABLE_RESOURCE_ACCESS_CAMERA = NO; 301 | ENABLE_RESOURCE_ACCESS_CONTACTS = NO; 302 | ENABLE_RESOURCE_ACCESS_LOCATION = NO; 303 | ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; 304 | GENERATE_INFOPLIST_FILE = YES; 305 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 306 | INFOPLIST_KEY_NSMainNibFile = MainMenu; 307 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 308 | LD_RUNPATH_SEARCH_PATHS = ( 309 | "$(inherited)", 310 | "@executable_path/../Frameworks", 311 | ); 312 | MACOSX_DEPLOYMENT_TARGET = 11.5; 313 | MARKETING_VERSION = 1.0.3; 314 | PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor; 315 | PRODUCT_NAME = "$(TARGET_NAME)"; 316 | REGISTER_APP_GROUPS = YES; 317 | RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; 318 | RUNTIME_EXCEPTION_ALLOW_JIT = NO; 319 | RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; 320 | RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; 321 | RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; 322 | RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; 323 | STRING_CATALOG_GENERATE_SYMBOLS = YES; 324 | SWIFT_EMIT_LOC_STRINGS = YES; 325 | }; 326 | name = Release; 327 | }; 328 | /* End XCBuildConfiguration section */ 329 | 330 | /* Begin XCConfigurationList section */ 331 | EA6BE6412E6CA1E1003F66D4 /* Build configuration list for PBXProject "LidAngleSensor" */ = { 332 | isa = XCConfigurationList; 333 | buildConfigurations = ( 334 | EA6BE6532E6CA1E1003F66D4 /* Debug */, 335 | EA6BE6542E6CA1E1003F66D4 /* Release */, 336 | ); 337 | defaultConfigurationIsVisible = 0; 338 | defaultConfigurationName = Release; 339 | }; 340 | EA6BE6552E6CA1E1003F66D4 /* Build configuration list for PBXNativeTarget "LidAngleSensor" */ = { 341 | isa = XCConfigurationList; 342 | buildConfigurations = ( 343 | EA6BE6562E6CA1E1003F66D4 /* Debug */, 344 | EA6BE6572E6CA1E1003F66D4 /* Release */, 345 | ); 346 | defaultConfigurationIsVisible = 0; 347 | defaultConfigurationName = Release; 348 | }; 349 | /* End XCConfigurationList section */ 350 | }; 351 | rootObject = EA6BE63E2E6CA1E1003F66D4 /* Project object */; 352 | } 353 | -------------------------------------------------------------------------------- /LidAngleSensor/AppDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.m 3 | // LidAngleSensor 4 | // 5 | // Created by Sam on 2025-09-06. 6 | // 7 | 8 | #import "AppDelegate.h" 9 | #import "LidAngleSensor.h" 10 | #import "CreakAudioEngine.h" 11 | #import "ThereminAudioEngine.h" 12 | #import "NSLabel.h" 13 | 14 | typedef NS_ENUM(NSInteger, AudioMode) { 15 | AudioModeCreak, 16 | AudioModeTheremin 17 | }; 18 | 19 | @interface AppDelegate () 20 | @property (strong, nonatomic) LidAngleSensor *lidSensor; 21 | @property (strong, nonatomic) CreakAudioEngine *creakAudioEngine; 22 | @property (strong, nonatomic) ThereminAudioEngine *thereminAudioEngine; 23 | @property (strong, nonatomic) NSLabel *angleLabel; 24 | @property (strong, nonatomic) NSLabel *statusLabel; 25 | @property (strong, nonatomic) NSLabel *velocityLabel; 26 | @property (strong, nonatomic) NSLabel *audioStatusLabel; 27 | @property (strong, nonatomic) NSButton *audioToggleButton; 28 | @property (strong, nonatomic) NSSegmentedControl *modeSelector; 29 | @property (strong, nonatomic) NSLabel *modeLabel; 30 | @property (strong, nonatomic) NSTimer *updateTimer; 31 | @property (nonatomic, assign) AudioMode currentAudioMode; 32 | @end 33 | 34 | @implementation AppDelegate 35 | 36 | - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { 37 | self.currentAudioMode = AudioModeCreak; // Default to creak mode 38 | [self createWindow]; 39 | [self initializeLidSensor]; 40 | [self initializeAudioEngines]; 41 | [self startUpdatingDisplay]; 42 | } 43 | 44 | - (void)applicationWillTerminate:(NSNotification *)aNotification { 45 | [self.updateTimer invalidate]; 46 | [self.lidSensor stopLidAngleUpdates]; 47 | [self.creakAudioEngine stopEngine]; 48 | [self.thereminAudioEngine stopEngine]; 49 | } 50 | 51 | - (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { 52 | return YES; 53 | } 54 | 55 | - (void)createWindow { 56 | // Create the main window (taller to accommodate mode selection and audio controls) 57 | NSRect windowFrame = NSMakeRect(100, 100, 450, 480); 58 | self.window = [[NSWindow alloc] initWithContentRect:windowFrame 59 | styleMask:NSWindowStyleMaskTitled | 60 | NSWindowStyleMaskClosable | 61 | NSWindowStyleMaskMiniaturizable 62 | backing:NSBackingStoreBuffered 63 | defer:NO]; 64 | 65 | [self.window setTitle:@"MacBook Lid Angle Sensor"]; 66 | [self.window makeKeyAndOrderFront:nil]; 67 | [self.window center]; 68 | 69 | // Create the content view 70 | NSView *contentView = [[NSView alloc] initWithFrame:windowFrame]; 71 | [self.window setContentView:contentView]; 72 | 73 | // Create angle display label with tabular numbers (larger, light font) 74 | self.angleLabel = [[NSLabel alloc] init]; 75 | [self.angleLabel setStringValue:@"Initializing..."]; 76 | [self.angleLabel setFont:[NSFont monospacedDigitSystemFontOfSize:48 weight:NSFontWeightLight]]; 77 | [self.angleLabel setAlignment:NSTextAlignmentCenter]; 78 | [self.angleLabel setTextColor:[NSColor systemBlueColor]]; 79 | [contentView addSubview:self.angleLabel]; 80 | 81 | // Create velocity display label with tabular numbers 82 | self.velocityLabel = [[NSLabel alloc] init]; 83 | [self.velocityLabel setStringValue:@"Velocity: 00 deg/s"]; 84 | [self.velocityLabel setFont:[NSFont monospacedDigitSystemFontOfSize:14 weight:NSFontWeightRegular]]; 85 | [self.velocityLabel setAlignment:NSTextAlignmentCenter]; 86 | [contentView addSubview:self.velocityLabel]; 87 | 88 | // Create status label 89 | self.statusLabel = [[NSLabel alloc] init]; 90 | [self.statusLabel setStringValue:@"Detecting sensor..."]; 91 | [self.statusLabel setFont:[NSFont systemFontOfSize:14]]; 92 | [self.statusLabel setAlignment:NSTextAlignmentCenter]; 93 | [self.statusLabel setTextColor:[NSColor secondaryLabelColor]]; 94 | [contentView addSubview:self.statusLabel]; 95 | 96 | // Create audio toggle button 97 | self.audioToggleButton = [[NSButton alloc] init]; 98 | [self.audioToggleButton setTitle:@"Start Audio"]; 99 | [self.audioToggleButton setBezelStyle:NSBezelStyleRounded]; 100 | [self.audioToggleButton setTarget:self]; 101 | [self.audioToggleButton setAction:@selector(toggleAudio:)]; 102 | [self.audioToggleButton setTranslatesAutoresizingMaskIntoConstraints:NO]; 103 | [contentView addSubview:self.audioToggleButton]; 104 | 105 | // Create audio status label 106 | self.audioStatusLabel = [[NSLabel alloc] init]; 107 | [self.audioStatusLabel setStringValue:@""]; 108 | [self.audioStatusLabel setFont:[NSFont systemFontOfSize:14]]; 109 | [self.audioStatusLabel setAlignment:NSTextAlignmentCenter]; 110 | [self.audioStatusLabel setTextColor:[NSColor secondaryLabelColor]]; 111 | [contentView addSubview:self.audioStatusLabel]; 112 | 113 | // Create mode label 114 | self.modeLabel = [[NSLabel alloc] init]; 115 | [self.modeLabel setStringValue:@"Audio Mode:"]; 116 | [self.modeLabel setFont:[NSFont systemFontOfSize:14 weight:NSFontWeightMedium]]; 117 | [self.modeLabel setAlignment:NSTextAlignmentCenter]; 118 | [self.modeLabel setTextColor:[NSColor labelColor]]; 119 | [contentView addSubview:self.modeLabel]; 120 | 121 | // Create mode selector 122 | self.modeSelector = [[NSSegmentedControl alloc] init]; 123 | [self.modeSelector setSegmentCount:2]; 124 | [self.modeSelector setLabel:@"Creak" forSegment:0]; 125 | [self.modeSelector setLabel:@"Theremin" forSegment:1]; 126 | [self.modeSelector setSelectedSegment:0]; // Default to creak 127 | [self.modeSelector setTarget:self]; 128 | [self.modeSelector setAction:@selector(modeChanged:)]; 129 | [self.modeSelector setTranslatesAutoresizingMaskIntoConstraints:NO]; 130 | [contentView addSubview:self.modeSelector]; 131 | 132 | // Set up auto layout constraints 133 | [NSLayoutConstraint activateConstraints:@[ 134 | // Angle label (main display, now at top) 135 | [self.angleLabel.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:40], 136 | [self.angleLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 137 | [self.angleLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], 138 | 139 | // Velocity label 140 | [self.velocityLabel.topAnchor constraintEqualToAnchor:self.angleLabel.bottomAnchor constant:15], 141 | [self.velocityLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 142 | [self.velocityLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], 143 | 144 | // Status label 145 | [self.statusLabel.topAnchor constraintEqualToAnchor:self.velocityLabel.bottomAnchor constant:15], 146 | [self.statusLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 147 | [self.statusLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], 148 | 149 | // Audio toggle button 150 | [self.audioToggleButton.topAnchor constraintEqualToAnchor:self.statusLabel.bottomAnchor constant:25], 151 | [self.audioToggleButton.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 152 | [self.audioToggleButton.widthAnchor constraintEqualToConstant:120], 153 | [self.audioToggleButton.heightAnchor constraintEqualToConstant:32], 154 | 155 | // Audio status label 156 | [self.audioStatusLabel.topAnchor constraintEqualToAnchor:self.audioToggleButton.bottomAnchor constant:15], 157 | [self.audioStatusLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 158 | [self.audioStatusLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], 159 | 160 | // Mode label 161 | [self.modeLabel.topAnchor constraintEqualToAnchor:self.audioStatusLabel.bottomAnchor constant:25], 162 | [self.modeLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 163 | [self.modeLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40], 164 | 165 | // Mode selector 166 | [self.modeSelector.topAnchor constraintEqualToAnchor:self.modeLabel.bottomAnchor constant:10], 167 | [self.modeSelector.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor], 168 | [self.modeSelector.widthAnchor constraintEqualToConstant:200], 169 | [self.modeSelector.heightAnchor constraintEqualToConstant:28], 170 | [self.modeSelector.bottomAnchor constraintLessThanOrEqualToAnchor:contentView.bottomAnchor constant:-20] 171 | ]]; 172 | } 173 | 174 | - (void)initializeLidSensor { 175 | self.lidSensor = [[LidAngleSensor alloc] init]; 176 | 177 | if (self.lidSensor.isAvailable) { 178 | [self.statusLabel setStringValue:@"Sensor detected - Reading angle..."]; 179 | [self.statusLabel setTextColor:[NSColor systemGreenColor]]; 180 | } else { 181 | [self.statusLabel setStringValue:@"Lid angle sensor not available on this device"]; 182 | [self.statusLabel setTextColor:[NSColor systemRedColor]]; 183 | [self.angleLabel setStringValue:@"Not Available"]; 184 | [self.angleLabel setTextColor:[NSColor systemRedColor]]; 185 | } 186 | } 187 | 188 | - (void)initializeAudioEngines { 189 | self.creakAudioEngine = [[CreakAudioEngine alloc] init]; 190 | self.thereminAudioEngine = [[ThereminAudioEngine alloc] init]; 191 | 192 | if (self.creakAudioEngine && self.thereminAudioEngine) { 193 | [self.audioStatusLabel setStringValue:@""]; 194 | } else { 195 | [self.audioStatusLabel setStringValue:@"Audio initialization failed"]; 196 | [self.audioStatusLabel setTextColor:[NSColor systemRedColor]]; 197 | [self.audioToggleButton setEnabled:NO]; 198 | } 199 | } 200 | 201 | - (IBAction)toggleAudio:(id)sender { 202 | id currentEngine = [self currentAudioEngine]; 203 | if (!currentEngine) { 204 | return; 205 | } 206 | 207 | if ([currentEngine isEngineRunning]) { 208 | [currentEngine stopEngine]; 209 | [self.audioToggleButton setTitle:@"Start Audio"]; 210 | [self.audioStatusLabel setStringValue:@""]; 211 | } else { 212 | [currentEngine startEngine]; 213 | [self.audioToggleButton setTitle:@"Stop Audio"]; 214 | [self.audioStatusLabel setStringValue:@""]; 215 | } 216 | } 217 | 218 | - (IBAction)modeChanged:(id)sender { 219 | NSSegmentedControl *control = (NSSegmentedControl *)sender; 220 | AudioMode newMode = (AudioMode)control.selectedSegment; 221 | 222 | // Stop current engine if running 223 | id currentEngine = [self currentAudioEngine]; 224 | BOOL wasRunning = [currentEngine isEngineRunning]; 225 | if (wasRunning) { 226 | [currentEngine stopEngine]; 227 | } 228 | 229 | // Update mode 230 | self.currentAudioMode = newMode; 231 | 232 | // Start new engine if the previous one was running 233 | if (wasRunning) { 234 | id newEngine = [self currentAudioEngine]; 235 | [newEngine startEngine]; 236 | [self.audioToggleButton setTitle:@"Stop Audio"]; 237 | } else { 238 | [self.audioToggleButton setTitle:@"Start Audio"]; 239 | } 240 | 241 | [self.audioStatusLabel setStringValue:@""]; 242 | } 243 | 244 | - (id)currentAudioEngine { 245 | switch (self.currentAudioMode) { 246 | case AudioModeCreak: 247 | return self.creakAudioEngine; 248 | case AudioModeTheremin: 249 | return self.thereminAudioEngine; 250 | default: 251 | return self.creakAudioEngine; 252 | } 253 | } 254 | 255 | - (void)startUpdatingDisplay { 256 | // Update every 16ms (60Hz) for smooth real-time audio and display updates 257 | self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.016 258 | target:self 259 | selector:@selector(updateAngleDisplay) 260 | userInfo:nil 261 | repeats:YES]; 262 | } 263 | 264 | - (void)updateAngleDisplay { 265 | if (!self.lidSensor.isAvailable) { 266 | return; 267 | } 268 | 269 | double angle = [self.lidSensor lidAngle]; 270 | 271 | if (angle == -2.0) { 272 | [self.angleLabel setStringValue:@"Read Error"]; 273 | [self.angleLabel setTextColor:[NSColor systemOrangeColor]]; 274 | [self.statusLabel setStringValue:@"Failed to read sensor data"]; 275 | [self.statusLabel setTextColor:[NSColor systemOrangeColor]]; 276 | } else { 277 | [self.angleLabel setStringValue:[NSString stringWithFormat:@"%.1f°", angle]]; 278 | [self.angleLabel setTextColor:[NSColor systemBlueColor]]; 279 | 280 | // Update current audio engine with new angle 281 | id currentEngine = [self currentAudioEngine]; 282 | if (currentEngine) { 283 | [currentEngine updateWithLidAngle:angle]; 284 | 285 | // Update velocity display with leading zero and whole numbers 286 | double velocity = [currentEngine currentVelocity]; 287 | int roundedVelocity = (int)round(velocity); 288 | if (roundedVelocity < 100) { 289 | [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %02d deg/s", roundedVelocity]]; 290 | } else { 291 | [self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %d deg/s", roundedVelocity]]; 292 | } 293 | 294 | // Show audio parameters when running 295 | if ([currentEngine isEngineRunning]) { 296 | if (self.currentAudioMode == AudioModeCreak) { 297 | double gain = [currentEngine currentGain]; 298 | double rate = [currentEngine currentRate]; 299 | [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Gain: %.2f, Rate: %.2f", gain, rate]]; 300 | } else if (self.currentAudioMode == AudioModeTheremin) { 301 | double frequency = [currentEngine currentFrequency]; 302 | double volume = [currentEngine currentVolume]; 303 | [self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Freq: %.1f Hz, Vol: %.2f", frequency, volume]]; 304 | } 305 | } 306 | } 307 | 308 | // Provide contextual status based on angle 309 | NSString *status; 310 | if (angle < 5.0) { 311 | status = @"Lid is closed"; 312 | } else if (angle < 45.0) { 313 | status = @"Lid slightly open"; 314 | } else if (angle < 90.0) { 315 | status = @"Lid partially open"; 316 | } else if (angle < 120.0) { 317 | status = @"Lid mostly open"; 318 | } else { 319 | status = @"Lid fully open"; 320 | } 321 | 322 | [self.statusLabel setStringValue:status]; 323 | [self.statusLabel setTextColor:[NSColor secondaryLabelColor]]; 324 | } 325 | } 326 | 327 | @end 328 | -------------------------------------------------------------------------------- /LidAngleSensor/Base.lproj/MainMenu.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | Default 534 | 535 | 536 | 537 | 538 | 539 | 540 | Left to Right 541 | 542 | 543 | 544 | 545 | 546 | 547 | Right to Left 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | Default 559 | 560 | 561 | 562 | 563 | 564 | 565 | Left to Right 566 | 567 | 568 | 569 | 570 | 571 | 572 | Right to Left 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | --------------------------------------------------------------------------------