diff --git a/.gitignore b/.gitignore index 84518a2..b88dd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ Icon # These are local to your environment and should not be shared. DerivedData/ xcuserdata/ +*.xcodeproj/xcshareddata/ # Session-specific state data — avoid polluting commits *.xcuserstate diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 9c2284b..97285b3 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = LM2TBH9R6C; + DEVELOPMENT_TEAM = 6PN64DQKLX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; @@ -508,7 +508,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = LM2TBH9R6C; + DEVELOPMENT_TEAM = 6PN64DQKLX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; diff --git a/Tiny/Managers/AudioPostProcessingManager.swift b/Tiny/Managers/AudioPostProcessingManager.swift index 3671c65..bfa5aa7 100644 --- a/Tiny/Managers/AudioPostProcessingManager.swift +++ b/Tiny/Managers/AudioPostProcessingManager.swift @@ -9,6 +9,7 @@ import Foundation import AVFoundation import AudioKit import AudioKitEX +import CoreHaptics internal import Combine class AudioPostProcessingManager: ObservableObject { @@ -17,13 +18,17 @@ class AudioPostProcessingManager: ObservableObject { private var parametricEQ: ParametricEQ? private var highShelfFilter: HighShelfFilter? private var timer: Timer? - + private var hapticManager: HapticManager? + private var amplitudeTap: AmplitudeTap? + @Published var isPlaying = false @Published var currentTime: TimeInterval = 0 @Published var duration: TimeInterval = 0 - + @Published var amplitude: Float = 0.0 + init() { engine = AudioEngine() + hapticManager = HapticManager() } func setupEQChain(input: Node) -> Node { @@ -56,17 +61,23 @@ class AudioPostProcessingManager: ObservableObject { print("Failed to create audio player") return } - - let processedOutput = setupEQChain(input: player) - - engine.output = processedOutput if let audioFile = try? AVAudioFile(forReading: fileURL) { - let sampleRate = audioFile.processingFormat.sampleRate + let sampleRate = Float(audioFile.processingFormat.sampleRate) let frameCount = Double(audioFile.length) - duration = frameCount / sampleRate + duration = frameCount / Double(sampleRate) + print("▶️ File loaded: \(sampleRate) Hz | Duration: \(String(format: "%.1f", duration))s") + } + + let processedOutput = setupEQChain(input: player) + + // Attach Amplitude tap for haptic analysis + amplitudeTap = AmplitudeTap(processedOutput) { [weak self] amplitude in + self?.processAmplitude(amplitude: amplitude) } + engine.output = processedOutput + player.completionHandler = { [weak self] in DispatchQueue.main.async { self?.isPlaying = false @@ -74,7 +85,9 @@ class AudioPostProcessingManager: ObservableObject { } } + hapticManager?.prepareHaptics() try engine.start() + amplitudeTap?.start() player.play() isPlaying = true @@ -89,7 +102,15 @@ class AudioPostProcessingManager: ObservableObject { print("❌ Error loading audio: \(error.localizedDescription)") } } - + + // Process amplitude data for haptic feedback + private func processAmplitude(amplitude: Float) { + DispatchQueue.main.async { + self.amplitude = amplitude + self.hapticManager?.playHapticFromAmplitude(amplitude) + } + } + private func startTimeTracking() { // Stop any existing timer first timer?.invalidate() @@ -115,6 +136,7 @@ class AudioPostProcessingManager: ObservableObject { // Stop time tracking when paused timer?.invalidate() timer = nil + hapticManager?.stopHaptics() } func resume() { @@ -122,7 +144,9 @@ class AudioPostProcessingManager: ObservableObject { print("❌ No player available to resume") return } - + + hapticManager?.prepareHaptics() + // Don't try to restart engine if it's already running if !engine.avEngine.isRunning { do { @@ -150,8 +174,14 @@ class AudioPostProcessingManager: ObservableObject { // Stop time tracking timer?.invalidate() timer = nil - + + amplitudeTap?.stop() + amplitudeTap = nil engine.stop() + hapticManager?.stopHaptics() + + // Reset haptic state + hapticManager?.reset() } func seek(to time: TimeInterval) { diff --git a/Tiny/Managers/HapticManager.swift b/Tiny/Managers/HapticManager.swift new file mode 100644 index 0000000..897f8a2 --- /dev/null +++ b/Tiny/Managers/HapticManager.swift @@ -0,0 +1,101 @@ +import CoreHaptics +import os + +class HapticManager { + private var engine: CHHapticEngine? + private var beatPattern: CHHapticPattern? + private var beatPlayer: CHHapticPatternPlayer? + + private let logger = Logger(subsystem: "com.example.tiny", category: "HapticManager") + + // Haptic properties + private var lastHapticTime: Date? + private let hapticDebounceInterval: TimeInterval = 0.4 + private let amplitudeThresholdLower: Float = 0.08 // Triggers for sounds above this + private let amplitudeThresholdUpper: Float = 0.2 // Does not trigger for sounds above this (too loud noise) + + init() { + prepareHaptics() + } + + func prepareHaptics() { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { + logger.info("Haptics not supported on this device.") + return + } + + do { + if engine == nil { + engine = try CHHapticEngine() + logger.info("Haptic engine created.") + + engine?.stoppedHandler = { [weak self] reason in + self?.logger.info("Haptic engine stopped for reason: \(reason.rawValue)") + self?.beatPlayer = nil + } + + engine?.resetHandler = { [weak self] in + self?.logger.info("Haptic engine reset.") + self?.beatPlayer = nil + self?.prepareHaptics() + } + } + try engine?.start() + logger.info("Haptic engine started successfully.") + + // Create the reusable pattern + let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0) + let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0) + beatPattern = try CHHapticPattern(events: [event], parameters: []) + + } catch { + logger.error("Error setting up haptic engine or pattern: \(error.localizedDescription)") + } + } + + func playHapticFromAmplitude(_ amplitude: Float) { + let now = Date() + var shouldTriggerHaptic = false + + if let lastTime = self.lastHapticTime { + if now.timeIntervalSince(lastTime) >= self.hapticDebounceInterval { + shouldTriggerHaptic = true + } + } else { + shouldTriggerHaptic = true + } + + if shouldTriggerHaptic && amplitude > self.amplitudeThresholdLower && amplitude < self.amplitudeThresholdUpper { + self.playBeatHaptic() + self.lastHapticTime = now + } + } + + private func playBeatHaptic() { + guard let engine = engine, let pattern = beatPattern else { + logger.warning("Haptic engine or pattern not available.") + return + } + + do { + if beatPlayer == nil { + beatPlayer = try engine.makePlayer(with: pattern) + logger.info("Haptic player created.") + } + try beatPlayer?.start(atTime: 0) + } catch { + logger.error("Failed to play haptic beat: \(error.localizedDescription)") + } + } + + func stopHaptics() { + engine?.stop() + beatPlayer = nil + logger.info("Haptic engine stopped.") + } + + func reset() { + lastHapticTime = nil + } +} diff --git a/Tiny/Managers/HeartbeatDetector.swift b/Tiny/Managers/HeartbeatDetector.swift index 62b4f0c..9c31455 100644 --- a/Tiny/Managers/HeartbeatDetector.swift +++ b/Tiny/Managers/HeartbeatDetector.swift @@ -6,76 +6,272 @@ // import Foundation +import Accelerate + +struct HeartbeatStats { + let avgBPM: Double + let confidence: Float + let beatCount: Int +} class HeartbeatDetector { private var heartbeatData: [HeartbeatData] = [] - + private var recentPeaks: [Date] = [] + private var sampleRate: Float + private var fftSize: Int = 0 + + // Configuration for different use cases + enum DetectionMode { + case live // Real-time microphone input + case playback // Recorded audio playback + } + + private let mode: DetectionMode + + // Configurable parameters based on mode + private var confidenceThreshold: Float + private var peakThreshold: Float + + private let s1FreqRange: ClosedRange = 20.0...150.0 // Hz + private let s2FreqRange: ClosedRange = 80.0...200.0 // Hz + private let minTimeBetweenBeats: TimeInterval = 0.3 // 200 BPM max + private let maxTimeBetweenBeats: TimeInterval = 2.0 // 30 BPM min + + // Peak detection state + private var lastPeakTime: Date? + private var peakBuffer: [Float] = [] + private let peakBufferSize = 5 + + // Initializer with configurable parameters + init(sampleRate: Float = 44100.0, mode: DetectionMode = .live) { + self.sampleRate = sampleRate + self.mode = mode + + // Adjust thresholds based on mode + switch mode { + case .live: + // More sensitive for live detection (microphone may have more noise) + self.confidenceThreshold = 0.40 + self.peakThreshold = 0.18 + case .playback: + // More precise for playback (cleaner signal) + self.confidenceThreshold = 0.25 + self.peakThreshold = 0.10 + } + } + func detectHeartbeat(from fftData: [Float]) -> HeartbeatData? { guard !fftData.isEmpty else { return nil } - - let s1Range = 20...50 - let s2Range = 50...80 - - let s1Amplitude = calculateAverageAmplitude(in: s1Range, from: fftData) - let s2Amplitude = calculateAverageAmplitude(in: s2Range, from: fftData) - - let confidence = calculateHeartbeatConfidence(s1Amplitude: s1Amplitude, s2Amplitude: s2Amplitude) - - if confidence > 0.3 { + + // Initialize FFT size on first run + if fftSize == 0 { + fftSize = fftData.count + } + + // Convert frequency ranges to FFT bin ranges + let s1BinRange = frequencyToBinRange(s1FreqRange, fftSize: fftSize, sampleRate: sampleRate) + let s2BinRange = frequencyToBinRange(s2FreqRange, fftSize: fftSize, sampleRate: sampleRate) + + // Calculate amplitudes with proper normalization + let s1Amplitude = calculatePeakAmplitude(in: s1BinRange, from: fftData) + let s2Amplitude = calculatePeakAmplitude(in: s2BinRange, from: fftData) + + // Calculate total energy in heartbeat frequency range + let totalEnergy = calculateAverageAmplitude(in: s1BinRange.lowerBound...s2BinRange.upperBound, from: fftData) + + // Detect peaks in the signal + let isPeak = detectPeak(amplitude: s1Amplitude) + + // Calculate confidence based on multiple factors + let confidence = calculateEnhancedConfidence( + s1Amplitude: s1Amplitude, + s2Amplitude: s2Amplitude, + totalEnergy: totalEnergy, + isPeak: isPeak + ) + + // Only register heartbeat if confidence is high enough and it's a peak + if confidence > confidenceThreshold && isPeak { + let now = Date() + + // Check timing constraint to avoid double-counting + if let lastPeak = lastPeakTime { + let interval = now.timeIntervalSince(lastPeak) + if interval < minTimeBetweenBeats { + return nil // Too soon, likely noise or double detection + } + } + + lastPeakTime = now + recentPeaks.append(now) + + // Keep only recent peaks for BPM calculation + let cutoffTime = now.addingTimeInterval(-10.0) + recentPeaks.removeAll { $0 < cutoffTime } + let bpm = estimateBPM() + let heartbeat = HeartbeatData( - timestamp: Date(), + timestamp: now, bpm: bpm, s1Amplitude: s1Amplitude, s2Amplitude: s2Amplitude, confidence: confidence ) - - // Track heartbeat for BPM estimation + heartbeatData.append(heartbeat) - if heartbeatData.count > 10 { + if heartbeatData.count > 20 { heartbeatData.removeFirst() } - + return heartbeat } - + return nil } - + + // Convert frequency range to FFT bin range + private func frequencyToBinRange(_ freqRange: ClosedRange, fftSize: Int, sampleRate: Float) -> ClosedRange { + let binWidth = sampleRate / Float(fftSize * 2) + let lowerBin = Int(freqRange.lowerBound / binWidth) + let upperBin = Int(freqRange.upperBound / binWidth) + return max(0, lowerBin)...min(fftSize - 1, upperBin) + } + + // Calculate peak amplitude (max value) in range + private func calculatePeakAmplitude(in range: ClosedRange, from fftData: [Float]) -> Float { + guard fftData.count > range.upperBound else { return 0.0 } + + let startIndex = max(0, range.lowerBound) + let endIndex = min(fftData.count, range.upperBound + 1) + + guard startIndex < endIndex else { return 0.0 } + + let slice = fftData[startIndex.., from fftData: [Float]) -> Float { guard fftData.count > range.upperBound else { return 0.0 } - + let startIndex = max(0, range.lowerBound) - let endIndex = min(fftData.count, range.upperBound) - + let endIndex = min(fftData.count, range.upperBound + 1) + + guard startIndex < endIndex else { return 0.0 } + let slice = fftData[startIndex.. Float { + + // Detect if current amplitude is a peak + private func detectPeak(amplitude: Float) -> Bool { + peakBuffer.append(amplitude) + if peakBuffer.count > peakBufferSize { + peakBuffer.removeFirst() + } + + guard peakBuffer.count == peakBufferSize else { return false } + + // Check if middle value is a local maximum + let middleIndex = peakBufferSize / 2 + let middleValue = peakBuffer[middleIndex] + + // Must exceed threshold + guard middleValue > peakThreshold else { return false } + + // Must be higher than neighbors + for (index, value) in peakBuffer.enumerated() { + if index != middleIndex && value >= middleValue { + return false + } + } + + return true + } + + // Enhanced confidence calculation + private func calculateEnhancedConfidence( + s1Amplitude: Float, + s2Amplitude: Float, + totalEnergy: Float, + isPeak: Bool + ) -> Float { + // 1. Amplitude strength (0.0 - 1.0) + let amplitudeStrength = min(1.0, s1Amplitude * 5.0) + + // 2. S1/S2 ratio score (S1 should be stronger than S2) let ratio = s2Amplitude / max(s1Amplitude, 0.001) - let idealRatio: Float = 0.6 - let ratioScore = 1.0 - abs(ratio - idealRatio) - - let amplitudeScore = min(1.0, (s1Amplitude + s2Amplitude) / 2.0) - - return (ratioScore * 0.7 + amplitudeScore * 0.3) + let idealRatio: Float = 0.5 // S2 is typically 50% of S1 + let ratioScore = max(0.0, 1.0 - abs(ratio - idealRatio) * 2.0) + + // 3. Energy concentration (heartbeat energy should be concentrated in expected range) + let energyScore = min(1.0, totalEnergy * 3.0) + + // 4. Peak bonus + let peakBonus: Float = isPeak ? 0.2 : 0.0 + + // Weighted combination + let confidence = ( + amplitudeStrength * 0.35 + + ratioScore * 0.25 + + energyScore * 0.20 + + peakBonus + ) + + return min(1.0, confidence) } - + + // Improved BPM estimation using inter-beat intervals private func estimateBPM() -> Double { - guard heartbeatData.count >= 2 else { return 0.0 } - - let recentData = Array(heartbeatData.suffix(5)) - guard recentData.count >= 2 else { return 0.0 } - - let timeDifferences = zip(recentData.dropFirst(), recentData.dropLast()) - .map { newer, older in - newer.timestamp.timeIntervalSince(older.timestamp) + guard recentPeaks.count >= 2 else { return 0.0 } + + // Calculate intervals between consecutive peaks + var intervals: [TimeInterval] = [] + for index in 1..= minTimeBetweenBeats && interval <= maxTimeBetweenBeats { + intervals.append(interval) } - - let averageInterval = timeDifferences.reduce(0, +) / Double(timeDifferences.count) - - return averageInterval > 0 ? 60.0 / averageInterval : 0.0 + } + + guard !intervals.isEmpty else { return 0.0 } + + // Use median instead of mean for better outlier resistance + let sortedIntervals = intervals.sorted() + let medianInterval: TimeInterval + + if sortedIntervals.count % 2 == 0 { + let mid = sortedIntervals.count / 2 + medianInterval = (sortedIntervals[mid - 1] + sortedIntervals[mid]) / 2.0 + } else { + medianInterval = sortedIntervals[sortedIntervals.count / 2] + } + + // Convert interval to BPM + let bpm = 60.0 / medianInterval + + // Clamp to realistic range + return max(30.0, min(200.0, bpm)) + } + + // Get recent heartbeat statistics + func getHeartbeatStats() -> HeartbeatStats { + guard !heartbeatData.isEmpty else { return HeartbeatStats(avgBPM: 0.0, confidence: 0.0, beatCount: 0) } + + let recentData = Array(heartbeatData.suffix(10)) + let avgBPM = recentData.map { $0.bpm }.reduce(0, +) / Double(recentData.count) + let avgConfidence = recentData.map { $0.confidence }.reduce(0, +) / Float(recentData.count) + + return HeartbeatStats(avgBPM: avgBPM, confidence: avgConfidence, beatCount: recentPeaks.count) + } + + // Reset detector state + func reset() { + heartbeatData.removeAll() + recentPeaks.removeAll() + lastPeakTime = nil + peakBuffer.removeAll() } } diff --git a/Tiny/Views/OrbLiveListenView.swift b/Tiny/Views/OrbLiveListenView.swift index 66b9935..2c3bfdc 100644 --- a/Tiny/Views/OrbLiveListenView.swift +++ b/Tiny/Views/OrbLiveListenView.swift @@ -330,7 +330,6 @@ extension OrbLiveListenView { } heartbeatSoundManager.start() heartbeatSoundManager.startRecording() - showListeningTutorialIfNeeded() } private func handleSingleTap() {