Skip to content
64 changes: 63 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Test files are located in `Stay TunedTests/`:
- `TuningTests.swift` - Tuning and GuitarString models
- `AudioTests.swift` - Pitch detection and spectrum analyzer sensitivity
- `TunerViewModelTests.swift` - ViewModel business logic
- `BeatDetectorTests.swift` - Autocorrelation & BPM logic

### Critical Audio Configuration (DO NOT CHANGE WITHOUT TESTING)

Expand Down Expand Up @@ -68,7 +69,9 @@ Stay Tuned/
├── ViewModels/
│ └── TunerViewModel.swift # Main app state and audio processing logic
├── Views/
│ ├── TunerView.swift # Main tuner screen
│ ├── TunerView.swift # Main tuner screen with Tools Menu integration
│ ├── StageModeView.swift # High-contrast, landscape-only performance view
│ ├── ToolsMenuView.swift # Consolidated menu for app tools (Settings, Stage Mode, etc.)
│ ├── HeadstockView.swift # Visual headstock with tappable tuning pegs
│ ├── TuningMeterView.swift # Animated needle meter with cents/Hz display
│ ├── TuningPickerView.swift # Tuning selection menu
Expand Down Expand Up @@ -97,6 +100,12 @@ Stay Tuned/
- **Confirmed strings:** Persist green state until user taps to retune
- **All-tuned celebration:** Quick flash + haptic when all strings confirmed

### Beat Detection (BeatDetector.swift)
- **Algorithm:** Normalized Autocorrelation (172Hz resolution)
- **Harmonic Correction:** "Double-Time Check" favors 2x tempos (e.g., 120 vs 60 BPM) if detected strength > 50%
- **Buffer:** 3.5s circular buffer of RMS energy
- **Range:** 60-200 BPM

### Audio Pipeline
1. AudioEngine captures mic input with small buffer (low latency)
2. PitchDetector accumulates samples and detects pitch
Expand All @@ -108,6 +117,13 @@ Organized in `TuningPresets.swift`:
- **Guitar:** Standard, Half Step Down, Whole Step Down, Drop D, DADGAD, Open tunings (G, D, C, E, A, B)
- Future: Banjo, Ukulele, Bass, etc.

### Stage Mode (StageModeView.swift)
- **Purpose:** High-contrast, interference-free view for live performance
- **Entry:** Accessed via Tools Menu or by rotating device to landscape
- **Landscape Lock:** View enforces landscape orientation by hiding close button
- **Wake Lock:** Disables idle timer to keep screen on during performance
- **Responsive Layout:** Dynamically scales UI elements based on available screen space using `GeometryReader`

## Settings Architecture

Settings are organized under `SettingsView.swift`, which acts as a **settings hub**. Each setting category has its own dedicated view that the user navigates to from the main settings screen.
Expand Down Expand Up @@ -194,3 +210,49 @@ Located in `TipJarView` within `ContentView.swift`:
- Venmo: `@NickMacCarthy`
- Cash App: `$NickMacCarthy`
- PayPal: `nickmaccarthy`


## App Store Guidelines Compliance

This app must adhere to Apple's App Store Review Guidelines. Key areas to watch:


### Guideline 2.1 - In-App Purchases

When submitting to the App Store:
- All IAP products must be submitted alongside the app binary
- Each IAP requires a **screenshot** in App Store Connect
- IAPs must be marked "Ready to Submit" before app submission

**Current IAP Products:**
| Product ID | Price | Description |
|------------|-------|-------------|
| `nmac.TipCalculator.tip.service.good` | $0.99 | Good Service tip |
| `nmac.TipCalculator.tip.service.great` | $2.99 | Great Service tip |
| `nmac.TipCalculator.tip.service.amazing` | $4.99 | Amazing Service tip |

### Guideline 2.3.3 - Screenshots

- iPhone screenshots must show iPhone UI (not iPad)
- iPad screenshots must show iPad UI (not iPhone in a frame)
- Screenshots must reflect actual app functionality
- Avoid marketing materials that don't show the app in use

**Required Screenshot Sizes:**
| Device | Size (pixels) |
|--------|---------------|
| 6.7" iPhone | 1290 × 2796 |
| 6.5" iPhone | 1284 × 2778 |
| 12.9" iPad Pro | 2048 × 2732 |
| 13" iPad Air/Pro | 2064 × 2752 |

### Pre-Submission Checklist

- [ ] All permission request flows use neutral language ("Continue", "Next")
- [ ] No skip/bypass buttons before system permission dialogs
- [ ] All IAP products have screenshots in App Store Connect
- [ ] IAP products are included in the submission
- [ ] iPhone screenshots taken on iPhone simulator
- [ ] iPad screenshots taken on iPad simulator (not iPhone in frame)
- [ ] All unit tests pass
- [ ] Version and build numbers updated
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

## ✨ Features

- **Stage Mode** — High-contrast, landscape-locked performance view for live use
- **Tools Menu** — Consolidated access to metronome, settings, and tuner modes
- **Real-time Pitch Detection** — YIN-inspired algorithm with vDSP optimizations for accurate, low-latency tuning
- **Chromatic Tuner** — Detect any note across the full chromatic scale
- **Multiple Tunings** — Standard, Drop D, DADGAD, Open G/D/C/E/A/B, and more
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
ReferencedContainer = "container:Stay Tuned.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Stay Tuned/Configuration/Products.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
240 changes: 240 additions & 0 deletions Stay Tuned/Stay Tuned/Audio/BeatDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
//
// BeatDetector.swift
// Stay Tuned
//
// Beat/tempo detection using onset detection and interval measurement
//

import Accelerate
import Foundation

/// Result of beat detection analysis
struct BeatDetectionResult {
let bpm: Double
let confidence: Float
/// Alternative tempo (half or double time)
let alternativeBPM: Double?
}

/// Detects tempo/BPM from audio using onset detection and interval measurement
final class BeatDetector {

// MARK: - Configuration

/// Minimum BPM to detect
private let minBPM: Double = 60.0
/// Maximum BPM to detect
private let maxBPM: Double = 200.0

/// Size of the audio envelope history in seconds
/// 3-4 seconds is usually enough to capture 2-3 beats of slow tempos
private let historyDuration: TimeInterval = 3.5

/// Number of audio envelopes per second (downsample rate)
/// 172Hz provides ~1.5 BPM resolution at 120 BPM, preventing quantization errors
/// 44100 / 256 = ~172Hz
private let envelopeRate: Double = 172.0

// MARK: - State

/// Circular buffer storing RMS energy history
private var envelopeBuffer: [Float]
private var writeIndex: Int = 0
private var isBufferFull = false

/// Total sub-frames processed
private var framesProcessed: Int = 0

/// Progress 0-1
private(set) var analysisProgress: Float = 0

// MARK: - Callbacks

/// Called when an onset/beat is detected (used for visual pulse)
var onOnsetDetected: ((Float) -> Void)?

// MARK: - Initialization

init() {
// Calculate buffer size needed
let bufferSize = Int(historyDuration * envelopeRate)
envelopeBuffer = [Float](repeating: 0, count: bufferSize)
}

// MARK: - Public Interface

/// Reset detector state
func reset() {
envelopeBuffer.withUnsafeMutableBufferPointer { ptr in
ptr.baseAddress?.initialize(repeating: 0, count: ptr.count)
}
writeIndex = 0
isBufferFull = false
framesProcessed = 0
analysisProgress = 0
}

/// Process incoming audio samples and return detected BPM
func analyze(samples: [Float], sampleRate: Double) -> BeatDetectionResult? {
// We process the buffer in sub-chunks to achieve higher temporal resolution (172Hz)
// without requesting tiny buffers from the audio engine.
let step = Int(sampleRate / envelopeRate)
var bpmResult: BeatDetectionResult?

var currentIndex = 0
while currentIndex + step <= samples.count {
// Process sub-chunk
let chunkStart = currentIndex
let chunkEnd = currentIndex + step

// 1. Calculate RMS for this sub-chunk
var rawRMS: Float = 0
// We can assume contiguous memory for simple array slicing in vDSP
samples.withUnsafeBufferPointer { ptr in
guard let base = ptr.baseAddress else { return }
vDSP_rmsqv(base + chunkStart, 1, &rawRMS, vDSP_Length(step))
}

// Boost quiet signals
rawRMS = min(1.0, rawRMS * 3.0)

// 2. Store in circular buffer
envelopeBuffer[writeIndex] = rawRMS
writeIndex = (writeIndex + 1) % envelopeBuffer.count

framesProcessed += 1

if writeIndex == 0 {
isBufferFull = true
}

// Visual feedback (Pulse)
if rawRMS > 0.05 {
onOnsetDetected?(rawRMS)
}

currentIndex += step
}

// Update progress
if !isBufferFull {
analysisProgress = Float(writeIndex) / Float(envelopeBuffer.count)
return nil
}
analysisProgress = 1.0

// 3. Perform Autocorrelation
// Only run occasionally (e.g., every ~40 sub-frames, approx 4 times/sec)
// 172Hz / 40 = ~4.3Hz updates
if framesProcessed % 40 == 0 {
bpmResult = performAutocorrelation()
}

return bpmResult
}

// MARK: - Autocorrelation Logic

private func performAutocorrelation() -> BeatDetectionResult? {
let n = envelopeBuffer.count

// Create a linearized copy of the buffer
var orderedBuffer = [Float](repeating: 0, count: n)
if isBufferFull {
let part1Count = n - writeIndex
let part2Count = writeIndex

// Copy oldest part
for i in 0 ..< part1Count {
orderedBuffer[i] = envelopeBuffer[writeIndex + i]
}
// Copy newest part (wrapped)
for i in 0 ..< part2Count {
orderedBuffer[part1Count + i] = envelopeBuffer[i]
}
} else {
orderedBuffer = envelopeBuffer
}

// Remove DC component
var sum: Float = 0
vDSP_sve(orderedBuffer, 1, &sum, vDSP_Length(n))
var mean = -sum / Float(n)
vDSP_vsadd(orderedBuffer, 1, &mean, &orderedBuffer, 1, vDSP_Length(n))

// Lag Search
let minLag = Int((60.0 / maxBPM) * envelopeRate)
let maxLag = Int((60.0 / minBPM) * envelopeRate)

// Store correlations to check for harmonics later
// We use a dictionary for sparse storage or array for full range
// Since lags are indices, a simple array is fastest
var correlations = [Float](repeating: 0, count: n / 2 + 1)

var bestCorrelation: Float = -1.0
var bestLag = 0

for lag in minLag ... min(maxLag, n / 2) {
var correlation: Float = 0
let length = vDSP_Length(n - lag)

orderedBuffer.withUnsafeBufferPointer { ptr in
guard let base = ptr.baseAddress else { return }
vDSP_dotpr(base, 1, base + lag, 1, &correlation, length)
}

// Normalize by length
correlation /= Float(length)

// Store for peak picking
correlations[lag] = correlation

if correlation > bestCorrelation {
bestCorrelation = correlation
bestLag = lag
}
}

guard bestLag > 0 else { return nil }

// --- Harmonic Correction (Double Time Check) ---
// If we found a slow tempo (large lag), check if there is a strong peak at half the lag (fast tempo).
// e.g., if BestLag=86 (60 BPM), check Lag=43 (120 BPM).
// If Lag 43 has > 50% of the energy of Lag 86, prefer 120 BPM (it's more useful).

let doubleTimeLag = bestLag / 2
if doubleTimeLag >= minLag {
let harmonicScore = correlations[doubleTimeLag]
// "0.5" is the threshold ratio.
// If the faster beat is at least half as strong as the slower one, pick it.
if harmonicScore > (bestCorrelation * 0.5) {
bestLag = doubleTimeLag
bestCorrelation = harmonicScore
}
}

// Parabolic Interpolation for sub-sample precision
var fractionalOffset: Float = 0
if bestLag > 0, bestLag < correlations.count - 1 {
let valPrev = correlations[bestLag - 1]
let valNext = correlations[bestLag + 1]

let denominator = (valNext - 2 * correlations[bestLag] + valPrev)
if abs(denominator) > 1e-6 {
fractionalOffset = (valPrev - valNext) / (2 * denominator)
}
}

let adjustedLag = Double(bestLag) + Double(fractionalOffset)
let periodSeconds = adjustedLag / envelopeRate
let bpm = 60.0 / periodSeconds

let confidence = min(1.0, max(0.0, bestCorrelation * 1000.0))

return BeatDetectionResult(
bpm: bpm,
confidence: confidence,
alternativeBPM: nil
)
}
}
14 changes: 11 additions & 3 deletions Stay Tuned/Stay Tuned/Audio/MetronomeEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
private(set) var tempo: Double = 120
private(set) var timeSignature: TimeSignature = .fourFour

/// Beat positions that should be accented (1-indexed)
private var accentPositions: [Int] = [1]

private var currentBeat: Int = 0
private var nextBeatTime: AVAudioTime?

Expand Down Expand Up @@ -66,7 +69,7 @@
currentBeat = 0
scheduleNextBeat()
} catch {
print("MetronomeEngine: Failed to start - \(error.localizedDescription)")

Check warning on line 72 in Stay Tuned/Stay Tuned/Audio/MetronomeEngine.swift

View workflow job for this annotation

GitHub Actions / Swift Lint

Use os_log or remove debug print statements before committing (no_print_statements)
isPlaying = false
}
}
Expand Down Expand Up @@ -98,6 +101,11 @@
}
}

/// Update the accent positions based on beat grouping
func setAccentPositions(_ positions: [Int]) {
accentPositions = positions.isEmpty ? [1] : positions
}

// MARK: - Private Methods

private func generateClickBuffers() {
Expand Down Expand Up @@ -158,7 +166,7 @@
try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
} catch {
print("MetronomeEngine: Audio session setup failed - \(error.localizedDescription)")

Check warning on line 169 in Stay Tuned/Stay Tuned/Audio/MetronomeEngine.swift

View workflow job for this annotation

GitHub Actions / Swift Lint

Use os_log or remove debug print statements before committing (no_print_statements)
}

audioEngine = AVAudioEngine()
Expand All @@ -182,15 +190,15 @@
let playerNode,
let audioEngine else { return }

// Determine which buffer to play
let isAccent = currentBeat == 0
// Determine which buffer to play - accent if current beat is in accent positions
let beatNumber = currentBeat + 1 // 1-indexed for comparison
let isAccent = accentPositions.contains(beatNumber)
guard let buffer = isAccent ? accentClickBuffer : regularClickBuffer else { return }

// Calculate samples per beat
let samplesPerBeat = sampleRate * 60.0 / tempo

// Fire callback on main thread immediately
let beatNumber = currentBeat + 1 // 1-indexed for display
DispatchQueue.main.async { [weak self] in
self?.onBeat?(beatNumber)
}
Expand Down
Loading
Loading