├── .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 |
678 |
679 |
680 |
--------------------------------------------------------------------------------