├── HeadTrackerApp ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── HeadTrackerApp.swift ├── HeadTrackerApp.entitlements ├── Info.plist ├── LaunchScreen.storyboard └── ContentView.swift ├── HeadTrackerAppTests ├── HeadTrackerAppTests.swift └── Info.plist ├── HeadTrackerAppUITests ├── Info.plist ├── HeadTrackerAppUITestsLaunchTests.swift └── HeadTrackerAppUITests.swift ├── project.yml ├── README.md └── .gitignore /HeadTrackerApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HeadTrackerApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HeadTrackerApp/HeadTrackerApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct HeadTrackerApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /HeadTrackerApp/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 | -------------------------------------------------------------------------------- /HeadTrackerApp/HeadTrackerApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | <\!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /HeadTrackerAppTests/HeadTrackerAppTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadTrackerAppTests.swift 3 | // HeadTrackerAppTests 4 | // 5 | // Created by omata on 2025/04/02. 6 | // 7 | 8 | import Testing 9 | @testable import HeadTrackerApp 10 | 11 | struct HeadTrackerAppTests { 12 | 13 | @Test func example() async throws { 14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /HeadTrackerAppTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | <\!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | 4 | 5 | CFBundleName 6 | $(PRODUCT_NAME) 7 | CFBundleIdentifier 8 | $(PRODUCT_BUNDLE_IDENTIFIER) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleVersion 12 | $(CURRENT_PROJECT_VERSION) 13 | CFBundleShortVersionString 14 | $(MARKETING_VERSION) 15 | 16 | 17 | -------------------------------------------------------------------------------- /HeadTrackerAppUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | <\!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | 4 | 5 | CFBundleName 6 | $(PRODUCT_NAME) 7 | CFBundleIdentifier 8 | $(PRODUCT_BUNDLE_IDENTIFIER) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleVersion 12 | $(CURRENT_PROJECT_VERSION) 13 | CFBundleShortVersionString 14 | $(MARKETING_VERSION) 15 | 16 | 17 | -------------------------------------------------------------------------------- /HeadTrackerApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: HeadTrackerApp 2 | options: 3 | bundleIdPrefix: com.omata 4 | deploymentTarget: 5 | iOS: 17.0 6 | targets: 7 | HeadTrackerApp: 8 | type: application 9 | platform: iOS 10 | sources: 11 | - HeadTrackerApp 12 | info: 13 | path: HeadTrackerApp/Info.plist 14 | properties: 15 | CFBundleDisplayName: HeadTracker 16 | UILaunchStoryboardName: LaunchScreen 17 | UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] 18 | NSMotionUsageDescription: This app needs access to motion data from your AirPods Pro to display head orientation. 19 | settings: 20 | base: 21 | GENERATE_INFOPLIST_FILE: NO 22 | INFOPLIST_FILE: HeadTrackerApp/Info.plist 23 | PRODUCT_BUNDLE_IDENTIFIER: com.omata.HeadTrackerApp 24 | attributes: 25 | SystemCapabilities: 26 | com.apple.BackgroundModes: 27 | enabled: true -------------------------------------------------------------------------------- /HeadTrackerAppUITests/HeadTrackerAppUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadTrackerAppUITestsLaunchTests.swift 3 | // HeadTrackerAppUITests 4 | // 5 | // Created by omata on 2025/04/02. 6 | // 7 | 8 | import XCTest 9 | 10 | final class HeadTrackerAppUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testLaunch() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | // Insert steps here to perform after app launch but before taking a screenshot, 26 | // such as logging into a test account or navigating somewhere in the app 27 | 28 | let attachment = XCTAttachment(screenshot: app.screenshot()) 29 | attachment.name = "Launch Screen" 30 | attachment.lifetime = .keepAlways 31 | add(attachment) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /HeadTrackerApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | HeadTracker 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | NSMotionUsageDescription 24 | This app needs access to motion data from your AirPods Pro to display head orientation. 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UISupportedInterfaceOrientations 28 | 29 | UIInterfaceOrientationPortrait 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /HeadTrackerAppUITests/HeadTrackerAppUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadTrackerAppUITests.swift 3 | // HeadTrackerAppUITests 4 | // 5 | // Created by omata on 2025/04/02. 6 | // 7 | 8 | import XCTest 9 | 10 | final class HeadTrackerAppUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTApplicationLaunchMetric()]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HeadTrackerApp 2 | 3 | A Swift iOS application that visualizes head orientation data from AirPods Pro using CoreMotion's CMHeadphoneMotionManager. 4 | 5 | ## Features 6 | 7 | - Real-time head orientation tracking with AirPods Pro 8 | - 3D visual representation of head position 9 | - Displays pitch, roll, and yaw values with intuitive visualizations 10 | - Automatic connection detection for AirPods Pro 11 | - Works with AirPods Pro connected before or after app launch 12 | - Debug information for troubleshooting connections 13 | 14 | ## Requirements 15 | 16 | - iOS 17.0+ 17 | - Xcode 15.0+ 18 | - AirPods Pro (1st or 2nd generation) 19 | - iPhone compatible with AirPods Pro 20 | 21 | ## Installation 22 | 23 | 1. Clone the repository: 24 | ```bash 25 | git clone https://github.com/ctxzz/HeadTrackerApp.git 26 | ``` 27 | 28 | 2. Open the project with Xcode: 29 | ```bash 30 | cd HeadTrackerApp 31 | xcodegen # Generate the Xcode project from project.yml 32 | open HeadTrackerApp.xcodeproj 33 | ``` 34 | 35 | 3. Build and run the app on your iOS device 36 | 37 | ## Usage 38 | 39 | 1. Connect your AirPods Pro to your iPhone 40 | 2. Launch the app 41 | 3. The app will automatically detect your AirPods Pro 42 | 4. Once connected, you'll see a 3D face that rotates to match your head movements 43 | 5. The numerical values for pitch, roll, and yaw will be displayed below the visualization 44 | 45 | If your AirPods Pro aren't detected automatically, you can tap the "Retry Connection" button. 46 | 47 | ## Understanding Head Orientation 48 | 49 | - **Pitch**: Up and down movements (nodding "yes") 50 | - **Roll**: Side-to-side tilt (tilting your head toward your shoulders) 51 | - **Yaw**: Left and right rotation (shaking your head "no") 52 | 53 | ## Troubleshooting 54 | 55 | If the app doesn't detect your AirPods Pro: 56 | 57 | 1. Make sure your AirPods Pro are connected to your iPhone via Bluetooth 58 | 2. Check that your AirPods Pro have sufficient battery 59 | 3. Try tapping the "Retry Connection" button 60 | 4. Toggle the debug info (tap the info icon in the bottom right) to see connection status 61 | 62 | ## Technologies Used 63 | 64 | - Swift 65 | - SwiftUI 66 | - CoreMotion 67 | - Combine 68 | 69 | ## License 70 | 71 | [MIT License](LICENSE) 72 | 73 | ## Acknowledgments 74 | 75 | - Built using CMHeadphoneMotionManager for AirPods Pro motion data 76 | - SwiftUI for the user interface 77 | - Project structure generated using XcodeGen -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | Packages/ 41 | Package.pins 42 | Package.resolved 43 | *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | # CocoaPods 50 | # 51 | # We recommend against adding the Pods directory to your .gitignore. However 52 | # you should judge for yourself, the pros and cons are mentioned at: 53 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 54 | # 55 | # Pods/ 56 | # 57 | # Add this line if you want to avoid checking in source code from the Xcode workspace 58 | # *.xcworkspace 59 | 60 | # Carthage 61 | # 62 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 63 | # Carthage/Checkouts 64 | 65 | Carthage/Build/ 66 | 67 | # Accio dependency management 68 | Dependencies/ 69 | .accio/ 70 | 71 | # fastlane 72 | # 73 | # It is recommended to not store the screenshots in the git repo. 74 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 75 | # For more information about the recommended setup visit: 76 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 77 | 78 | fastlane/report.xml 79 | fastlane/Preview.html 80 | fastlane/screenshots/**/*.png 81 | fastlane/test_output 82 | 83 | # Code Injection 84 | # 85 | # After new code Injection tools there's a generated folder /iOSInjectionProject 86 | # https://github.com/johnno1962/injectionforxcode 87 | 88 | iOSInjectionProject/ 89 | 90 | # macOS specific 91 | .DS_Store 92 | .AppleDouble 93 | .LSOverride 94 | 95 | # Thumbnails 96 | ._* 97 | 98 | # Files that might appear in the root of a volume 99 | .DocumentRevisions-V100 100 | .fseventsd 101 | .Spotlight-V100 102 | .TemporaryItems 103 | .Trashes 104 | .VolumeIcon.icns 105 | .com.apple.timemachine.donotpresent 106 | 107 | # Directories potentially created on remote AFP share 108 | .AppleDB 109 | .AppleDesktop 110 | Network Trash Folder 111 | Temporary Items 112 | .apdisk 113 | 114 | # XcodeGen 115 | *.xcodeproj -------------------------------------------------------------------------------- /HeadTrackerApp/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /HeadTrackerApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreMotion 3 | import Combine 4 | 5 | struct ContentView: View { 6 | @StateObject private var headphoneMotionManager = HeadphoneMotionManager() 7 | @State private var connectionAttempts = 0 8 | @State private var showDebugInfo = false 9 | 10 | var body: some View { 11 | ZStack { 12 | Color(UIColor.systemBackground) 13 | .ignoresSafeArea() 14 | 15 | VStack(spacing: 15) { 16 | Text("AirPods Pro Head Tracking") 17 | .font(.title) 18 | .fontWeight(.bold) 19 | .padding(.top, 10) 20 | 21 | if headphoneMotionManager.isDeviceConnected { 22 | // Visual head tracker 23 | HeadVisualization( 24 | pitch: headphoneMotionManager.pitch, 25 | roll: headphoneMotionManager.roll, 26 | yaw: headphoneMotionManager.yaw 27 | ) 28 | .frame(height: 180) 29 | .padding(.vertical, 10) 30 | 31 | // Numerical data 32 | VStack(alignment: .leading, spacing: 15) { 33 | Text("Head Orientation") 34 | .font(.headline) 35 | .frame(maxWidth: .infinity, alignment: .center) 36 | 37 | Divider() 38 | 39 | OrientationRow(label: "Pitch", value: headphoneMotionManager.pitch, description: "Up/Down") 40 | OrientationRow(label: "Roll", value: headphoneMotionManager.roll, description: "Tilt Left/Right") 41 | OrientationRow(label: "Yaw", value: headphoneMotionManager.yaw, description: "Turn Left/Right") 42 | } 43 | .padding() 44 | .background(Color(UIColor.secondarySystemBackground)) 45 | .cornerRadius(12) 46 | .shadow(radius: 1) 47 | .padding(.horizontal) 48 | } else { 49 | Spacer() 50 | 51 | VStack(spacing: 25) { 52 | Image(systemName: "airpodspro") 53 | .font(.system(size: 60)) 54 | .foregroundColor(.blue) 55 | 56 | Text("Waiting for AirPods Pro...") 57 | .font(.title3) 58 | .foregroundColor(.secondary) 59 | 60 | Button(action: { 61 | connectionAttempts += 1 62 | headphoneMotionManager.restart() 63 | }) { 64 | Text("Retry Connection") 65 | .fontWeight(.semibold) 66 | .padding(.horizontal, 20) 67 | .padding(.vertical, 10) 68 | .background(Color.blue.opacity(0.2)) 69 | .cornerRadius(8) 70 | } 71 | 72 | if showDebugInfo { 73 | Text("Connection Status: \(headphoneMotionManager.connectionStatus)") 74 | .font(.caption) 75 | .foregroundColor(.secondary) 76 | .padding(.top, 10) 77 | .multilineTextAlignment(.center) 78 | .frame(maxWidth: .infinity) 79 | } 80 | } 81 | .padding() 82 | .background(Color(UIColor.secondarySystemBackground)) 83 | .cornerRadius(12) 84 | .shadow(radius: 1) 85 | .padding(.horizontal) 86 | 87 | Spacer() 88 | } 89 | 90 | Spacer() 91 | 92 | // Debug info footer 93 | HStack { 94 | if showDebugInfo { 95 | Text("Attempts: \(connectionAttempts)") 96 | .font(.caption) 97 | .foregroundColor(.secondary) 98 | } 99 | 100 | Spacer() 101 | 102 | Button(action: { 103 | showDebugInfo.toggle() 104 | }) { 105 | Image(systemName: showDebugInfo ? "info.circle.fill" : "info.circle") 106 | .foregroundColor(.gray) 107 | } 108 | } 109 | .padding(.horizontal) 110 | .padding(.bottom, 5) 111 | } 112 | .padding(.horizontal, 5) 113 | } 114 | .onAppear { 115 | headphoneMotionManager.start() 116 | } 117 | .onDisappear { 118 | headphoneMotionManager.stop() 119 | } 120 | } 121 | } 122 | 123 | struct HeadVisualization: View { 124 | let pitch: Double 125 | let roll: Double 126 | let yaw: Double 127 | 128 | @State private var isAnimating = false 129 | 130 | var body: some View { 131 | ZStack { 132 | // Direction indicators 133 | DirectionIndicators() 134 | .foregroundColor(.gray.opacity(0.7)) 135 | 136 | // Head outline and face 137 | ZStack { 138 | // Head outline 139 | Circle() 140 | .stroke(Color.gray, lineWidth: 1.5) 141 | .frame(width: 120, height: 120) 142 | 143 | // Face representation 144 | HeadShape() 145 | .fill(Color.blue.opacity(0.5)) 146 | .frame(width: 110, height: 110) 147 | .rotation3DEffect( 148 | .degrees(pitch), 149 | axis: (x: 1.0, y: 0.0, z: 0.0) 150 | ) 151 | .rotation3DEffect( 152 | .degrees(roll), 153 | axis: (x: 0.0, y: 0.0, z: 1.0) 154 | ) 155 | .rotation3DEffect( 156 | .degrees(yaw), 157 | axis: (x: 0.0, y: 1.0, z: 0.0) 158 | ) 159 | } 160 | } 161 | .onAppear { 162 | withAnimation(Animation.easeInOut(duration: 0.5)) { 163 | isAnimating = true 164 | } 165 | } 166 | } 167 | } 168 | 169 | struct DirectionIndicators: View { 170 | var body: some View { 171 | ZStack { 172 | // Horizontal line 173 | Rectangle() 174 | .frame(width: 180, height: 1) 175 | .opacity(0.3) 176 | 177 | // Vertical line 178 | Rectangle() 179 | .frame(width: 1, height: 180) 180 | .opacity(0.3) 181 | 182 | // Direction labels 183 | VStack { 184 | Text("Front") 185 | .font(.system(size: 11)) 186 | .padding(.bottom, 160) 187 | 188 | HStack { 189 | Text("Left") 190 | .font(.system(size: 11)) 191 | .padding(.trailing, 160) 192 | 193 | Text("Right") 194 | .font(.system(size: 11)) 195 | .padding(.leading, 160) 196 | } 197 | 198 | Text("Back") 199 | .font(.system(size: 11)) 200 | .padding(.top, 160) 201 | } 202 | } 203 | } 204 | } 205 | 206 | struct HeadShape: Shape { 207 | func path(in rect: CGRect) -> Path { 208 | var path = Path() 209 | 210 | let width = rect.width 211 | let height = rect.height 212 | 213 | // Head shape 214 | path.addEllipse(in: rect) 215 | 216 | // Eyes 217 | let eyeWidth = width * 0.12 218 | let eyeHeight = height * 0.08 219 | let eyeY = height * 0.35 220 | 221 | let leftEyeX = width * 0.3 222 | let rightEyeX = width * 0.7 - eyeWidth 223 | 224 | path.addEllipse(in: CGRect(x: leftEyeX, y: eyeY, width: eyeWidth, height: eyeHeight)) 225 | path.addEllipse(in: CGRect(x: rightEyeX, y: eyeY, width: eyeWidth, height: eyeHeight)) 226 | 227 | // Nose 228 | let noseWidth = width * 0.07 229 | let noseHeight = height * 0.15 230 | let noseX = width / 2 - noseWidth / 2 231 | let noseY = height * 0.45 232 | 233 | path.move(to: CGPoint(x: noseX, y: noseY)) 234 | path.addLine(to: CGPoint(x: noseX + noseWidth, y: noseY)) 235 | path.addLine(to: CGPoint(x: noseX + noseWidth / 2, y: noseY + noseHeight)) 236 | path.addLine(to: CGPoint(x: noseX, y: noseY)) 237 | 238 | // Mouth 239 | let mouthWidth = width * 0.4 240 | let mouthHeight = height * 0.05 241 | let mouthX = width / 2 - mouthWidth / 2 242 | let mouthY = height * 0.65 243 | 244 | path.move(to: CGPoint(x: mouthX, y: mouthY)) 245 | path.addQuadCurve( 246 | to: CGPoint(x: mouthX + mouthWidth, y: mouthY), 247 | control: CGPoint(x: mouthX + mouthWidth / 2, y: mouthY + mouthHeight) 248 | ) 249 | 250 | return path 251 | } 252 | } 253 | 254 | struct OrientationRow: View { 255 | let label: String 256 | let value: Double 257 | let description: String 258 | 259 | var body: some View { 260 | VStack(alignment: .leading, spacing: 5) { 261 | HStack { 262 | Text(label) 263 | .fontWeight(.medium) 264 | 265 | Text(description) 266 | .font(.caption) 267 | .foregroundColor(.secondary) 268 | 269 | Spacer() 270 | 271 | Text(String(format: "%.1f°", value)) 272 | .fontWeight(.bold) 273 | .monospacedDigit() 274 | } 275 | 276 | // Progress bar visualization 277 | GeometryReader { geometry in 278 | ZStack(alignment: .leading) { 279 | // Background 280 | RoundedRectangle(cornerRadius: 2) 281 | .fill(Color.gray.opacity(0.2)) 282 | .frame(height: 6) 283 | 284 | // Value indicator 285 | let normalizedValue = ((value + 180) / 360).clamped(to: 0...1) 286 | let width = normalizedValue * geometry.size.width 287 | 288 | RoundedRectangle(cornerRadius: 2) 289 | .fill(Color.blue) 290 | .frame(width: max(0, width), height: 6) 291 | } 292 | } 293 | .frame(height: 6) 294 | } 295 | } 296 | } 297 | 298 | extension Comparable { 299 | func clamped(to limits: ClosedRange) -> Self { 300 | return min(max(self, limits.lowerBound), limits.upperBound) 301 | } 302 | } 303 | 304 | class HeadphoneMotionManager: ObservableObject { 305 | private let motionManager = CMHeadphoneMotionManager() 306 | private var timer: Timer? 307 | private var availabilityTimer: Timer? 308 | private var updateTimer: Timer? 309 | private var lastMotionData: CMDeviceMotion? 310 | 311 | @Published var pitch: Double = 0.0 312 | @Published var roll: Double = 0.0 313 | @Published var yaw: Double = 0.0 314 | @Published var isDeviceConnected: Bool = false 315 | @Published var connectionStatus: String = "Not started" 316 | 317 | func start() { 318 | connectionStatus = "Starting headphone motion tracking" 319 | 320 | // Attempt immediate connection (for already connected AirPods) 321 | immediateConnectionAttempt() 322 | 323 | // Set up polling to check for connections periodically (for AirPods connected later) 324 | availabilityTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in 325 | self?.checkDeviceAvailability() 326 | } 327 | } 328 | 329 | private func immediateConnectionAttempt() { 330 | // Check if device is already connected 331 | if motionManager.isDeviceMotionAvailable { 332 | connectionStatus = "Device already connected at launch" 333 | startMotionUpdates() 334 | } else { 335 | connectionStatus = "No device connected at launch - waiting" 336 | } 337 | } 338 | 339 | private func checkDeviceAvailability() { 340 | if motionManager.isDeviceMotionAvailable && !motionManager.isDeviceMotionActive { 341 | connectionStatus = "Device motion available" 342 | startMotionUpdates() 343 | } else if !motionManager.isDeviceMotionAvailable && isDeviceConnected { 344 | // Device was connected but is now disconnected 345 | connectionStatus = "Device disconnected" 346 | isDeviceConnected = false 347 | } 348 | } 349 | 350 | func restart() { 351 | stop() 352 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 353 | self.start() 354 | } 355 | } 356 | 357 | private func startMotionUpdates() { 358 | connectionStatus = "Starting motion updates" 359 | 360 | motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in 361 | guard let self = self else { return } 362 | 363 | if let error = error { 364 | self.connectionStatus = "Error: \(error.localizedDescription)" 365 | self.isDeviceConnected = false 366 | return 367 | } 368 | 369 | if let motion = motion { 370 | self.lastMotionData = motion 371 | self.isDeviceConnected = true 372 | 373 | // Update UI with motion data 374 | self.updateMotionData(motion) 375 | } 376 | } 377 | 378 | // Set up a backup timer to check if we're still getting data 379 | timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in 380 | guard let self = self else { return } 381 | 382 | if !self.motionManager.isDeviceMotionAvailable { 383 | self.connectionStatus = "Device no longer available" 384 | self.isDeviceConnected = false 385 | } else if !self.motionManager.isDeviceMotionActive { 386 | self.connectionStatus = "Motion updates stopped unexpectedly" 387 | self.startMotionUpdates() // Restart updates 388 | } 389 | } 390 | } 391 | 392 | private func updateMotionData(_ motion: CMDeviceMotion) { 393 | // Convert radians to degrees 394 | self.pitch = motion.attitude.pitch * 180 / .pi 395 | self.roll = motion.attitude.roll * 180 / .pi 396 | self.yaw = motion.attitude.yaw * 180 / .pi 397 | 398 | if !isDeviceConnected { 399 | connectionStatus = "Receiving motion data" 400 | } 401 | 402 | isDeviceConnected = true 403 | } 404 | 405 | func stop() { 406 | timer?.invalidate() 407 | timer = nil 408 | 409 | availabilityTimer?.invalidate() 410 | availabilityTimer = nil 411 | 412 | updateTimer?.invalidate() 413 | updateTimer = nil 414 | 415 | if motionManager.isDeviceMotionActive { 416 | motionManager.stopDeviceMotionUpdates() 417 | connectionStatus = "Motion updates stopped" 418 | } 419 | } 420 | 421 | deinit { 422 | stop() 423 | } 424 | } --------------------------------------------------------------------------------