19 Commits
1.0.1 ... main

Author SHA1 Message Date
samhenrigold
d8f11f8a58 Update README.md 2025-09-08 17:30:26 -04:00
samhenrigold
d88e103891 Merge pull request #28 from singularitti/patch-1
Add installation section to README
2025-09-08 17:23:39 -04:00
samhenrigold
3510d76bbe Update .gitignore 2025-09-08 09:33:46 -04:00
Qi Zhang
3ecf71093f Add installation section to README
See https://github.com/Homebrew/homebrew-cask/pull/226959
2025-09-08 01:05:31 -05:00
samhenrigold
7134e72d40 Update README.md 2025-09-07 15:28:12 -04:00
samhenrigold
9e500b7c4d Update README.md 2025-09-07 15:18:29 -04:00
samhenrigold
12ac51fe6f Create LICENSE 2025-09-07 10:23:39 -04:00
samhenrigold
1752fa12f3 Update README.md 2025-09-07 10:09:16 -04:00
samhenrigold
d0492d9fa0 Update README.md 2025-09-06 21:27:56 -04:00
samhenrigold
ff2ed4198a Update README.md 2025-09-06 21:27:24 -04:00
samhenrigold
580a5f06ef Merge pull request #5 from toorusr/add-external-instructions
Add instructions for setup
2025-09-06 21:18:07 -04:00
toorusr
2c3f7bb4f6 add good instructions that mr gold should've added 2025-09-07 02:16:07 +02:00
samhenrigold
152d12ce90 Update README.md 2025-09-06 19:34:20 -04:00
samhenrigold
65feeef9b9 Update README.md 2025-09-06 19:23:08 -04:00
samhenrigold
f6a36a226d Update project.pbxproj 2025-09-06 19:03:01 -04:00
samhenrigold
ce0432c5ef Theremin mode, fuckers 2025-09-06 19:02:52 -04:00
samhenrigold
d31e8e49e0 Update project.pbxproj 2025-09-06 18:34:57 -04:00
samhenrigold
f608105ce7 fix my screwed up sensor data parsing 2025-09-06 18:34:06 -04:00
samhenrigold
d590174cdd Update project.pbxproj 2025-09-06 18:12:29 -04:00
12 changed files with 739 additions and 35 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build
.DS_Store

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -268,7 +268,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.5;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -310,7 +310,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.5;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;

View File

@@ -8,32 +8,44 @@
#import "AppDelegate.h"
#import "LidAngleSensor.h"
#import "CreakAudioEngine.h"
#import "ThereminAudioEngine.h"
#import "NSLabel.h"
typedef NS_ENUM(NSInteger, AudioMode) {
AudioModeCreak,
AudioModeTheremin
};
@interface AppDelegate ()
@property (strong, nonatomic) LidAngleSensor *lidSensor;
@property (strong, nonatomic) CreakAudioEngine *audioEngine;
@property (strong, nonatomic) CreakAudioEngine *creakAudioEngine;
@property (strong, nonatomic) ThereminAudioEngine *thereminAudioEngine;
@property (strong, nonatomic) NSLabel *angleLabel;
@property (strong, nonatomic) NSLabel *statusLabel;
@property (strong, nonatomic) NSLabel *velocityLabel;
@property (strong, nonatomic) NSLabel *audioStatusLabel;
@property (strong, nonatomic) NSButton *audioToggleButton;
@property (strong, nonatomic) NSSegmentedControl *modeSelector;
@property (strong, nonatomic) NSLabel *modeLabel;
@property (strong, nonatomic) NSTimer *updateTimer;
@property (nonatomic, assign) AudioMode currentAudioMode;
@end
@implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
self.currentAudioMode = AudioModeCreak; // Default to creak mode
[self createWindow];
[self initializeLidSensor];
[self initializeAudioEngine];
[self initializeAudioEngines];
[self startUpdatingDisplay];
}
- (void)applicationWillTerminate:(NSNotification *)aNotification {
[self.updateTimer invalidate];
[self.lidSensor stopLidAngleUpdates];
[self.audioEngine stopEngine];
[self.creakAudioEngine stopEngine];
[self.thereminAudioEngine stopEngine];
}
- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app {
@@ -41,8 +53,8 @@
}
- (void)createWindow {
// Create the main window (taller to accommodate audio controls)
NSRect windowFrame = NSMakeRect(100, 100, 450, 420);
// Create the main window (taller to accommodate mode selection and audio controls)
NSRect windowFrame = NSMakeRect(100, 100, 450, 480);
self.window = [[NSWindow alloc] initWithContentRect:windowFrame
styleMask:NSWindowStyleMaskTitled |
NSWindowStyleMaskClosable |
@@ -98,6 +110,25 @@
[self.audioStatusLabel setTextColor:[NSColor secondaryLabelColor]];
[contentView addSubview:self.audioStatusLabel];
// Create mode label
self.modeLabel = [[NSLabel alloc] init];
[self.modeLabel setStringValue:@"Audio Mode:"];
[self.modeLabel setFont:[NSFont systemFontOfSize:14 weight:NSFontWeightMedium]];
[self.modeLabel setAlignment:NSTextAlignmentCenter];
[self.modeLabel setTextColor:[NSColor labelColor]];
[contentView addSubview:self.modeLabel];
// Create mode selector
self.modeSelector = [[NSSegmentedControl alloc] init];
[self.modeSelector setSegmentCount:2];
[self.modeSelector setLabel:@"Creak" forSegment:0];
[self.modeSelector setLabel:@"Theremin" forSegment:1];
[self.modeSelector setSelectedSegment:0]; // Default to creak
[self.modeSelector setTarget:self];
[self.modeSelector setAction:@selector(modeChanged:)];
[self.modeSelector setTranslatesAutoresizingMaskIntoConstraints:NO];
[contentView addSubview:self.modeSelector];
// Set up auto layout constraints
[NSLayoutConstraint activateConstraints:@[
// Angle label (main display, now at top)
@@ -125,7 +156,18 @@
[self.audioStatusLabel.topAnchor constraintEqualToAnchor:self.audioToggleButton.bottomAnchor constant:15],
[self.audioStatusLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor],
[self.audioStatusLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40],
[self.audioStatusLabel.bottomAnchor constraintLessThanOrEqualToAnchor:contentView.bottomAnchor constant:-20]
// Mode label
[self.modeLabel.topAnchor constraintEqualToAnchor:self.audioStatusLabel.bottomAnchor constant:25],
[self.modeLabel.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor],
[self.modeLabel.widthAnchor constraintLessThanOrEqualToAnchor:contentView.widthAnchor constant:-40],
// Mode selector
[self.modeSelector.topAnchor constraintEqualToAnchor:self.modeLabel.bottomAnchor constant:10],
[self.modeSelector.centerXAnchor constraintEqualToAnchor:contentView.centerXAnchor],
[self.modeSelector.widthAnchor constraintEqualToConstant:200],
[self.modeSelector.heightAnchor constraintEqualToConstant:28],
[self.modeSelector.bottomAnchor constraintLessThanOrEqualToAnchor:contentView.bottomAnchor constant:-20]
]];
}
@@ -143,10 +185,11 @@
}
}
- (void)initializeAudioEngine {
self.audioEngine = [[CreakAudioEngine alloc] init];
- (void)initializeAudioEngines {
self.creakAudioEngine = [[CreakAudioEngine alloc] init];
self.thereminAudioEngine = [[ThereminAudioEngine alloc] init];
if (self.audioEngine) {
if (self.creakAudioEngine && self.thereminAudioEngine) {
[self.audioStatusLabel setStringValue:@""];
} else {
[self.audioStatusLabel setStringValue:@"Audio initialization failed"];
@@ -156,21 +199,59 @@
}
- (IBAction)toggleAudio:(id)sender {
if (!self.audioEngine) {
id currentEngine = [self currentAudioEngine];
if (!currentEngine) {
return;
}
if (self.audioEngine.isEngineRunning) {
[self.audioEngine stopEngine];
if ([currentEngine isEngineRunning]) {
[currentEngine stopEngine];
[self.audioToggleButton setTitle:@"Start Audio"];
[self.audioStatusLabel setStringValue:@""];
} else {
[self.audioEngine startEngine];
[currentEngine startEngine];
[self.audioToggleButton setTitle:@"Stop Audio"];
[self.audioStatusLabel setStringValue:@""];
}
}
- (IBAction)modeChanged:(id)sender {
NSSegmentedControl *control = (NSSegmentedControl *)sender;
AudioMode newMode = (AudioMode)control.selectedSegment;
// Stop current engine if running
id currentEngine = [self currentAudioEngine];
BOOL wasRunning = [currentEngine isEngineRunning];
if (wasRunning) {
[currentEngine stopEngine];
}
// Update mode
self.currentAudioMode = newMode;
// Start new engine if the previous one was running
if (wasRunning) {
id newEngine = [self currentAudioEngine];
[newEngine startEngine];
[self.audioToggleButton setTitle:@"Stop Audio"];
} else {
[self.audioToggleButton setTitle:@"Start Audio"];
}
[self.audioStatusLabel setStringValue:@""];
}
- (id)currentAudioEngine {
switch (self.currentAudioMode) {
case AudioModeCreak:
return self.creakAudioEngine;
case AudioModeTheremin:
return self.thereminAudioEngine;
default:
return self.creakAudioEngine;
}
}
- (void)startUpdatingDisplay {
// Update every 16ms (60Hz) for smooth real-time audio and display updates
self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.016
@@ -196,12 +277,13 @@
[self.angleLabel setStringValue:[NSString stringWithFormat:@"%.1f°", angle]];
[self.angleLabel setTextColor:[NSColor systemBlueColor]];
// Update audio engine with new angle
if (self.audioEngine) {
[self.audioEngine updateWithLidAngle:angle];
// Update current audio engine with new angle
id currentEngine = [self currentAudioEngine];
if (currentEngine) {
[currentEngine updateWithLidAngle:angle];
// Update velocity display with leading zero and whole numbers
double velocity = self.audioEngine.currentVelocity;
double velocity = [currentEngine currentVelocity];
int roundedVelocity = (int)round(velocity);
if (roundedVelocity < 100) {
[self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %02d deg/s", roundedVelocity]];
@@ -209,13 +291,17 @@
[self.velocityLabel setStringValue:[NSString stringWithFormat:@"Velocity: %d deg/s", roundedVelocity]];
}
// Keep velocity label color consistent
// Show audio parameters when running
if (self.audioEngine.isEngineRunning) {
double gain = self.audioEngine.currentGain;
double rate = self.audioEngine.currentRate;
if ([currentEngine isEngineRunning]) {
if (self.currentAudioMode == AudioModeCreak) {
double gain = [currentEngine currentGain];
double rate = [currentEngine currentRate];
[self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Gain: %.2f, Rate: %.2f", gain, rate]];
} else if (self.currentAudioMode == AudioModeTheremin) {
double frequency = [currentEngine currentFrequency];
double volume = [currentEngine currentVolume];
[self.audioStatusLabel setStringValue:[NSString stringWithFormat:@"Freq: %.1f Hz, Vol: %.2f", frequency, volume]];
}
}
}
@@ -227,7 +313,7 @@
status = @"Lid slightly open";
} else if (angle < 90.0) {
status = @"Lid partially open";
} else if (angle < 135.0) {
} else if (angle < 120.0) {
status = @"Lid mostly open";
} else {
status = @"Lid fully open";

View File

@@ -2,7 +2,7 @@
// CreakAudioEngine.h
// LidAngleSensor
//
// Created by Sam on 2025-01-16.
// Created by Sam on 2025-09-06.
//
#import <Foundation/Foundation.h>

View File

@@ -2,7 +2,7 @@
// CreakAudioEngine.m
// LidAngleSensor
//
// Created by Sam on 2025-01-16.
// Created by Sam on 2025-09-06.
//
#import "CreakAudioEngine.h"

View File

@@ -126,9 +126,9 @@
if (result == kIOReturnSuccess && reportLength >= 3) {
// Data format: [report_id, angle_low, angle_high]
// Example: [01 72 00] = 0x7201 centidegrees = 291.85 degrees
uint16_t rawValue = *(uint16_t*)(report);
double angle = rawValue * 0.01; // Convert centidegrees to degrees
// Parse the 16-bit value from bytes 1-2 (skipping report ID)
uint16_t rawValue = (report[2] << 8) | report[1]; // High byte, low byte
double angle = (double)rawValue; // Raw value is already in degrees
return angle;
}

View File

@@ -2,7 +2,7 @@
// NSLabel.h
// LidAngleSensor
//
// Created by Sam on 2025-01-16.
// Created by Sam on 2025-09-06.
//
#import <Cocoa/Cocoa.h>

View File

@@ -2,7 +2,7 @@
// NSLabel.m
// LidAngleSensor
//
// Created by Sam on 2025-01-16.
// Created by Sam on 2025-09-06.
//
#import "NSLabel.h"

View File

@@ -0,0 +1,62 @@
//
// ThereminAudioEngine.h
// LidAngleSensor
//
// Created by Sam on 2025-09-06.
//
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
/**
* ThereminAudioEngine provides real-time theremin-like audio that responds to MacBook lid angle changes.
*
* Features:
* - Real-time sine wave synthesis based on lid angle
* - Smooth frequency transitions to avoid audio artifacts
* - Volume control based on angular velocity
* - Configurable frequency range mapping
* - Low-latency audio generation
*
* Audio Behavior:
* - Lid angle maps to frequency (closed = low pitch, open = high pitch)
* - Movement velocity controls volume (slow movement = loud, fast = quiet)
* - Smooth parameter interpolation for musical quality
*/
@interface ThereminAudioEngine : NSObject
@property (nonatomic, assign, readonly) BOOL isEngineRunning;
@property (nonatomic, assign, readonly) double currentVelocity;
@property (nonatomic, assign, readonly) double currentFrequency;
@property (nonatomic, assign, readonly) double currentVolume;
/**
* Initialize the theremin audio engine.
* @return Initialized engine instance, or nil if initialization failed
*/
- (instancetype)init;
/**
* Start the audio engine and begin tone generation.
*/
- (void)startEngine;
/**
* Stop the audio engine and halt tone generation.
*/
- (void)stopEngine;
/**
* Update the theremin audio based on new lid angle measurement.
* This method calculates frequency mapping and volume based on movement.
* @param lidAngle Current lid angle in degrees
*/
- (void)updateWithLidAngle:(double)lidAngle;
/**
* Manually set the angular velocity (for testing purposes).
* @param velocity Angular velocity in degrees per second
*/
- (void)setAngularVelocity:(double)velocity;
@end

View File

@@ -0,0 +1,318 @@
//
// ThereminAudioEngine.m
// LidAngleSensor
//
// Created by Sam on 2025-09-06.
//
#import "ThereminAudioEngine.h"
#import <AudioToolbox/AudioToolbox.h>
// Theremin parameter mapping constants
static const double kMinFrequency = 110.0; // Hz - A2 note (closed lid)
static const double kMaxFrequency = 440.0; // Hz - A4 note (open lid) - much lower range
static const double kMinAngle = 0.0; // degrees - closed lid
static const double kMaxAngle = 135.0; // degrees - fully open lid
// Volume control constants - continuous tone with velocity modulation
static const double kBaseVolume = 0.6; // Base volume when at rest
static const double kVelocityVolumeBoost = 0.4; // Additional volume boost from movement
static const double kVelocityFull = 8.0; // deg/s - max volume boost at/under this velocity
static const double kVelocityQuiet = 80.0; // deg/s - no volume boost over this velocity
// Vibrato constants
static const double kVibratoFrequency = 5.0; // Hz - vibrato rate
static const double kVibratoDepth = 0.03; // Vibrato depth as fraction of frequency (3%)
// Smoothing constants
static const double kAngleSmoothingFactor = 0.1; // Moderate smoothing for frequency
static const double kVelocitySmoothingFactor = 0.3; // Moderate smoothing for velocity
static const double kFrequencyRampTimeMs = 30.0; // Frequency ramping time constant
static const double kVolumeRampTimeMs = 50.0; // Volume ramping time constant
static const double kMovementThreshold = 0.3; // Minimum angle change to register movement
static const double kMovementTimeoutMs = 100.0; // Time before velocity decay
static const double kVelocityDecayFactor = 0.7; // Decay rate when no movement
static const double kAdditionalDecayFactor = 0.85; // Additional decay after timeout
// Audio constants
static const double kSampleRate = 44100.0;
static const UInt32 kBufferSize = 512;
@interface ThereminAudioEngine ()
// Audio engine components
@property (nonatomic, strong) AVAudioEngine *audioEngine;
@property (nonatomic, strong) AVAudioSourceNode *sourceNode;
@property (nonatomic, strong) AVAudioMixerNode *mixerNode;
// State tracking
@property (nonatomic, assign) double lastLidAngle;
@property (nonatomic, assign) double smoothedLidAngle;
@property (nonatomic, assign) double lastUpdateTime;
@property (nonatomic, assign) double smoothedVelocity;
@property (nonatomic, assign) double targetFrequency;
@property (nonatomic, assign) double targetVolume;
@property (nonatomic, assign) double currentFrequency;
@property (nonatomic, assign) double currentVolume;
@property (nonatomic, assign) BOOL isFirstUpdate;
@property (nonatomic, assign) NSTimeInterval lastMovementTime;
// Sine wave generation
@property (nonatomic, assign) double phase;
@property (nonatomic, assign) double phaseIncrement;
// Vibrato generation
@property (nonatomic, assign) double vibratoPhase;
@end
@implementation ThereminAudioEngine
- (instancetype)init {
self = [super init];
if (self) {
_isFirstUpdate = YES;
_lastUpdateTime = CACurrentMediaTime();
_lastMovementTime = CACurrentMediaTime();
_lastLidAngle = 0.0;
_smoothedLidAngle = 0.0;
_smoothedVelocity = 0.0;
_targetFrequency = kMinFrequency;
_targetVolume = kBaseVolume;
_currentFrequency = kMinFrequency;
_currentVolume = kBaseVolume;
_phase = 0.0;
_vibratoPhase = 0.0;
_phaseIncrement = 2.0 * M_PI * kMinFrequency / kSampleRate;
if (![self setupAudioEngine]) {
NSLog(@"[ThereminAudioEngine] Failed to setup audio engine");
return nil;
}
}
return self;
}
- (void)dealloc {
[self stopEngine];
}
#pragma mark - Audio Engine Setup
- (BOOL)setupAudioEngine {
self.audioEngine = [[AVAudioEngine alloc] init];
self.mixerNode = self.audioEngine.mainMixerNode;
// Create audio format for our sine wave
AVAudioFormat *format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
sampleRate:kSampleRate
channels:1
interleaved:NO];
// Create source node for sine wave generation
__weak typeof(self) weakSelf = self;
self.sourceNode = [[AVAudioSourceNode alloc] initWithFormat:format renderBlock:^OSStatus(BOOL * _Nonnull isSilence, const AudioTimeStamp * _Nonnull timestamp, AVAudioFrameCount frameCount, AudioBufferList * _Nonnull outputData) {
return [weakSelf renderSineWave:isSilence timestamp:timestamp frameCount:frameCount outputData:outputData];
}];
// Attach and connect the source node
[self.audioEngine attachNode:self.sourceNode];
[self.audioEngine connect:self.sourceNode to:self.mixerNode format:format];
return YES;
}
#pragma mark - Engine Control
- (void)startEngine {
if (self.isEngineRunning) {
return;
}
NSError *error;
if (![self.audioEngine startAndReturnError:&error]) {
NSLog(@"[ThereminAudioEngine] Failed to start audio engine: %@", error.localizedDescription);
return;
}
NSLog(@"[ThereminAudioEngine] Started theremin engine");
}
- (void)stopEngine {
if (!self.isEngineRunning) {
return;
}
[self.audioEngine stop];
NSLog(@"[ThereminAudioEngine] Stopped theremin engine");
}
- (BOOL)isEngineRunning {
return self.audioEngine.isRunning;
}
#pragma mark - Sine Wave Generation
- (OSStatus)renderSineWave:(BOOL *)isSilence
timestamp:(const AudioTimeStamp *)timestamp
frameCount:(AVAudioFrameCount)frameCount
outputData:(AudioBufferList *)outputData {
float *output = (float *)outputData->mBuffers[0].mData;
// Always generate sound (continuous tone)
*isSilence = NO;
// Calculate vibrato phase increment
double vibratoPhaseIncrement = 2.0 * M_PI * kVibratoFrequency / kSampleRate;
// Generate sine wave samples with vibrato
for (AVAudioFrameCount i = 0; i < frameCount; i++) {
// Calculate vibrato modulation
double vibratoModulation = sin(self.vibratoPhase) * kVibratoDepth;
double modulatedFrequency = self.currentFrequency * (1.0 + vibratoModulation);
// Update phase increment for modulated frequency
self.phaseIncrement = 2.0 * M_PI * modulatedFrequency / kSampleRate;
// Generate sample with vibrato and current volume
output[i] = (float)(sin(self.phase) * self.currentVolume * 0.25); // 0.25 to prevent clipping
// Update phases
self.phase += self.phaseIncrement;
self.vibratoPhase += vibratoPhaseIncrement;
// Wrap phases to prevent accumulation of floating point errors
if (self.phase >= 2.0 * M_PI) {
self.phase -= 2.0 * M_PI;
}
if (self.vibratoPhase >= 2.0 * M_PI) {
self.vibratoPhase -= 2.0 * M_PI;
}
}
return noErr;
}
#pragma mark - Lid Angle Processing
- (void)updateWithLidAngle:(double)lidAngle {
double currentTime = CACurrentMediaTime();
if (self.isFirstUpdate) {
self.lastLidAngle = lidAngle;
self.smoothedLidAngle = lidAngle;
self.lastUpdateTime = currentTime;
self.lastMovementTime = currentTime;
self.isFirstUpdate = NO;
// Set initial frequency based on angle
[self updateTargetParametersWithAngle:lidAngle velocity:0.0];
return;
}
// Calculate time delta
double deltaTime = currentTime - self.lastUpdateTime;
if (deltaTime <= 0 || deltaTime > 1.0) {
// Skip if time delta is invalid or too large
self.lastUpdateTime = currentTime;
return;
}
// Stage 1: Smooth the raw angle input
self.smoothedLidAngle = (kAngleSmoothingFactor * lidAngle) +
((1.0 - kAngleSmoothingFactor) * self.smoothedLidAngle);
// Stage 2: Calculate velocity from smoothed angle data
double deltaAngle = self.smoothedLidAngle - self.lastLidAngle;
double instantVelocity;
// Apply movement threshold
if (fabs(deltaAngle) < kMovementThreshold) {
instantVelocity = 0.0;
} else {
instantVelocity = fabs(deltaAngle / deltaTime);
self.lastLidAngle = self.smoothedLidAngle;
}
// Stage 3: Apply velocity smoothing and decay
if (instantVelocity > 0.0) {
self.smoothedVelocity = (kVelocitySmoothingFactor * instantVelocity) +
((1.0 - kVelocitySmoothingFactor) * self.smoothedVelocity);
self.lastMovementTime = currentTime;
} else {
self.smoothedVelocity *= kVelocityDecayFactor;
}
// Additional decay if no movement for extended period
double timeSinceMovement = currentTime - self.lastMovementTime;
if (timeSinceMovement > (kMovementTimeoutMs / 1000.0)) {
self.smoothedVelocity *= kAdditionalDecayFactor;
}
// Update state for next iteration
self.lastUpdateTime = currentTime;
// Update target parameters
[self updateTargetParametersWithAngle:self.smoothedLidAngle velocity:self.smoothedVelocity];
// Apply smooth parameter transitions
[self rampToTargetParameters];
}
- (void)setAngularVelocity:(double)velocity {
self.smoothedVelocity = velocity;
[self updateTargetParametersWithAngle:self.smoothedLidAngle velocity:velocity];
[self rampToTargetParameters];
}
- (void)updateTargetParametersWithAngle:(double)angle velocity:(double)velocity {
// Map angle to frequency using exponential curve for musical feel
double normalizedAngle = fmax(0.0, fmin(1.0, (angle - kMinAngle) / (kMaxAngle - kMinAngle)));
// Use exponential mapping for more musical frequency distribution
double frequencyRatio = pow(normalizedAngle, 0.7); // Slight compression for better control
self.targetFrequency = kMinFrequency + frequencyRatio * (kMaxFrequency - kMinFrequency);
// Calculate continuous volume with velocity-based boost
double velocityBoost = 0.0;
if (velocity > 0.0) {
// Use smoothstep curve for natural volume boost response
double e0 = 0.0;
double e1 = kVelocityQuiet;
double t = fmin(1.0, fmax(0.0, (velocity - e0) / (e1 - e0)));
double s = t * t * (3.0 - 2.0 * t); // smoothstep function
velocityBoost = (1.0 - s) * kVelocityVolumeBoost; // invert: slow = more boost, fast = less boost
}
// Combine base volume with velocity boost
self.targetVolume = kBaseVolume + velocityBoost;
self.targetVolume = fmax(0.0, fmin(1.0, self.targetVolume));
}
// Helper function for parameter ramping
- (double)rampValue:(double)current toward:(double)target withDeltaTime:(double)dt timeConstantMs:(double)tauMs {
double alpha = fmin(1.0, dt / (tauMs / 1000.0));
return current + (target - current) * alpha;
}
- (void)rampToTargetParameters {
// Calculate delta time for ramping
static double lastRampTime = 0;
double currentTime = CACurrentMediaTime();
if (lastRampTime == 0) lastRampTime = currentTime;
double deltaTime = currentTime - lastRampTime;
lastRampTime = currentTime;
// Ramp current values toward targets for smooth transitions
self.currentFrequency = [self rampValue:self.currentFrequency toward:self.targetFrequency withDeltaTime:deltaTime timeConstantMs:kFrequencyRampTimeMs];
self.currentVolume = [self rampValue:self.currentVolume toward:self.targetVolume withDeltaTime:deltaTime timeConstantMs:kVolumeRampTimeMs];
}
#pragma mark - Property Accessors
- (double)currentVelocity {
return self.smoothedVelocity;
}
@end

View File

@@ -12,11 +12,22 @@ Despite what the name would have you believe, it is a sensor that detects the an
**Which devices have a lid angle sensor?**
It was introduced with the 2019 16-inch MacBook Pro. If your laptop is newer, you probably have it.
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.
**My laptop should have it, why doesn't it show up?**
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).
Known problematic models:
- M1 MacBook Air
- M1 MacBook Pro
**Can I use this on my iMac?**
Not yet tested. Feel free to slam your computer into your desk and make a PR with your results.
~~Not yet tested. Feel free to slam your computer into your desk and make a PR with your results.~~
[It totally works](https://github.com/samhenrigold/LidAngleSensor/issues/33). If it doesn't work for you, try slamming your computer harder?
**Why?**
@@ -30,6 +41,10 @@ Oh. I don't know.
I guess.
**Why does it say it's by Lisa?**
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.
**How come the audio feels kind of...weird?**
I'm bad at audio.
@@ -37,3 +52,23 @@ I'm bad at audio.
**Where did the sound effect come from?**
LEGO Batman 3: Beyond Gotham. But you knew that already.
**Can I turn off the sound?**
Yes, never click "Start Audio". But this energy isn't encouraged.
## Building
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.
## Installation
Via Homebrew:
```shell
brew install lidanglesensor
```
## Related projects
- [Python library that taps into this sensor](https://github.com/tcsenpai/pybooklid)