Skip to content

Latest commit

 

History

History
74 lines (48 loc) · 21.1 KB

File metadata and controls

74 lines (48 loc) · 21.1 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

Build and test via xcodebuild (scheme SoundCheck, targets SoundCheck, SoundCheckTests, SoundCheckUITests):

xcodebuild -project SoundCheck.xcodeproj -scheme SoundCheck -destination 'platform=macOS' build
xcodebuild -project SoundCheck.xcodeproj -scheme SoundCheck -destination 'platform=macOS' test

Tests use Swift Testing. Method-level -only-testing filters don't work here — -only-testing:SoundCheckTests/SoundCheckTests/<method> silently runs 0 tests and still prints TEST SUCCEEDED. Filter at the target level, write a result bundle, and read pass/fail counts from it (per-test results don't reach xcodebuild's stdout):

xcodebuild -project SoundCheck.xcodeproj -scheme SoundCheck -destination 'platform=macOS,arch=arm64' test -only-testing:SoundCheckTests -resultBundlePath /tmp/sc.xcresult
xcrun xcresulttool get test-results summary --path /tmp/sc.xcresult   # totalTestCount / failedTests / testFailures[].failureText

arch=arm64 avoids xcodebuild's "multiple matching destinations" pick on a universal-build project.

Releases

Cut locally, not in CI: scripts/release.sh vX.Y.Z builds a signed (Apple Development identity + hardened runtime, not notarized) universal arm64+x86_64 Release into dist/, verifying signature and architectures; add --publish to create the tag + GitHub release (auto-generated notes) and upload the zip. The GitHub release.yml workflow is manual-only and produces an unsigned build. Versions are v0.MINOR.PATCH git tags (v0.3.0 bumped minor for the Click signal type).

PRs are squash-merged. Stacked PRs: merging the lower PR with "delete branch" makes GitHub close (not retarget) any PR based on that branch, and it can't be reopened once its head is rebased/force-pushed. Retarget the stacked PR to main before deleting the lower branch.

The project uses PBXFileSystemSynchronizedRootGroup (Xcode 16+ synchronized folders) — new files dropped into SoundCheck/, SoundCheckTests/, or SoundCheckUITests/ are picked up automatically, no project.pbxproj edits needed.

The Xcode project's SUPPORTED_PLATFORMS includes iOS/visionOS from the template default, but the app is designed macOS-first (see below) — iOS portability is a deferred, unscoped concern. Anything gated behind Core Audio's AudioObject/HAL APIs is macOS-only and wrapped in #if os(macOS).

Minimum OS is macOS 14.0 / iOS 17.0 / visionOS 1.0 — not arbitrary: the binding constraint is @Observable (macOS 14+), used across the model layer; there are no @available/#available gates and no stdlib Atomic usage, so nothing pushes it higher. It was previously set to a needlessly high 26.5 with no code requiring it; lowered to the real floor and verified with both Debug and Release (-O/whole-module) builds. Re-check by building at a lower target if @Observable is ever dropped.

Documentation to read first

  • docs/spec.md — the spec of record: signal types, screen layout, every control's exact behavior, the audio engine architecture, and a running decisions log. Read this before changing UI or audio behavior.
  • CONTEXT.md — domain glossary (currently: Channel, Muted-by-default, Click).
  • docs/adr/ — architecture decision records; check these before "fixing" something that looks odd, it may be deliberate.
  • docs/research/ — research writeups backing specific technical choices (e.g. pink noise algorithm selection).

Planning workflow

Feature work was planned on GitHub Issues using the wayfinder skill convention: a map issue (labeled wayfinder:map) indexes child ticket issues (wayfinder:research/wayfinder:prototype/wayfinder:grilling/wayfinder:task), with native GitHub sub-issue and blocking relationships expressing the dependency graph. The first map (core signals + UI, titled "V1" on GitHub) is closed (destination reached). The second map (band-limited noise, 1/3-octave noise, sweeps, square wave, titled "V2") is closed (destination reached): band-limited and 1/3-octave pink noise shipped as Pink sub-modes, sine sweep and square wave shipped as new top-level GeneratorKind cases. Two rounds of standalone architecture-review issues have since shipped outside a map. Round 1: #33 unified ContentView's UI-facing SignalType enum into SignalRenderCore's GeneratorKind (one enum, not two kept in sync by hand); #31 extracted SignalSettings as the one seam that pushes a parameter into both the live render core and persisted settings. Round 2: #39 widened NoiseModeDraft to own manual-range/1/3-octave commit-clamp-snap logic (previously untestable ContentView methods); #40 collapsed four hand-duplicated "what's in the frequency slot" conditions into one FrequencySlotContent enum; #41 shared Pink/White's noise-mode filter rebuild-and-dispatch logic into one NoiseModeFilter type; #42 extracted AudioEngineController's pointer-to-buffer conversion into a testable static function. Use the wayfinder convention again for any non-trivial multi-ticket effort, naming the map after the feature, not a version number — the old "V1"/"V2" milestone names never matched the app's actual release versions (v0.x git tags) and are retired; a single-ticket feature gets a standalone issue instead.

Architecture

SoundCheck is a signal generator, not an analyser — it only ever produces audio, never captures or measures it. The core app is complete and fully wired end-to-end (no mock state remains anywhere). Four architectural decisions shape the audio engine, each with an ADR:

  • No third-party DSP/audio library. All generators are hand-written AVAudioSourceNode render blocks (docs/adr/0002-custom-render-blocks-no-dsp-library.md).
  • Signal-type switch forces a full stop, not a crossfade — changing signal type while running drops output to OFF; the user must press ON again (docs/adr/0003-signal-switch-forces-stop.md). This means the render graph only ever needs one live generator at a time.
  • Every output channel defaults to muted, including channel 1, on launch and on every device switch — a deliberate safety default since the signal is routed to every channel of the device simultaneously (docs/adr/0001-all-channels-muted-by-default.md).
  • SignalGenerator is widened to carry full parameters, plus a reset() hook — added for Sweep so generators needing more than frequency/sample rate (Sweep's duration, Pink's noise mode) don't force another protocol change each time (docs/adr/0004-generator-protocol-widened-for-parameterized-generators.md).

Per docs/spec.md's "Audio engine architecture" section: one persistent AVAudioSourceNode whose render block delegates to an atomically-swappable generator reference; UI-thread parameter changes (frequency, level, mute/phase, on/off, generator swap) cross into the render block via a plain struct guarded by OSAllocatedUnfairLock, not a third-party atomics package; per-channel mute/phase is applied as a final per-channel pass inside the same render block; output device binding stays inside AVAudioEngine, overriding the output node's underlying AudioUnit's kAudioOutputUnitProperty_CurrentDevice rather than dropping to a raw AUHAL unit.

Key source files:

  • SoundCheck/ContentView.swift — the app screen: signal selector, on/off, frequency/level fields, device picker, per-channel mute/phase row, format readout. Owns only view-local/transient state now (focus tracking, noise-mode drafts, always-on-top, device selection); every parameter that must reach both the live render core and persisted settings is delegated to SignalSettings (see below), which ContentView binds to via @Bindable. Also holds the visual-design pieces, now driven by the shared design-token system ported from the sibling FreqTrace project (ADR 0005; see SoundCheck/DesignSystem/ below): every color reads from EnvironmentValues.theme (accent/danger/surface tiers) rather than hardcoded values, and the app owns its Dark/Light appearance (a footer picker backed by AppearanceSettings, driving .preferredColorScheme) instead of following the system — a deliberate reversal of the original "follow system appearance" stance, since SoundCheck and FreqTrace are companion tools meant to read as one console. The pieces: PanelSection (a surface console-plate zone recessed into the window's lighter surfaceRaised chassis, matching FreqTrace's Weighting/FFT-Size modules); LCDFieldStyle (the numeric readouts' dark inset panel, kept deliberately dark in both appearance modes via the one hardcoded Color.soundCheckLCDPanel — the sole carve-out from the theme, since an instrument's LCD backlight doesn't go white in a bright room — with a cyan hairline glow from theme.accent); SolidToggleStyle (solid-fill Mute/Ø, deliberately louder than FreqTrace's lit-LED language because Mute is safety-critical per ADR 0001); LEDIndicator (the small lit dot used for the ON/OFF running tally); and the ON/OFF button's cyan-"armed" → red-"live" colored states (not a neutral fill, which vanished against Light mode's near-white panels). Both segmented pickers (signal-type and noise-mode) tint their selection theme.accent, matching FreqTrace. Number keys 1–9/0 toggle the first ten channels' Mute (channelMuteShortcuts — invisible buttons behind the window chassis, gated off via anyFieldFocused while a numeric field is focused so typed digits aren't stolen). See docs/spec.md's "Visual design" section and ADR 0005 before changing any of this screen's appearance. Later work added a noise mode sub-selector (Full-range/Band-limited/1/3-Octave) directly under the on/off switch, shared by both Pink and White (each holding its own NoiseModeDraft — pinkDraft/whiteDraft — so their selections persist independently; NoiseModeDraft also owns the manual-range/1/3-octave commit-clamp-snap logic itself, issue #39, rather than that logic living as untestable private methods on ContentView), and collapsed the frequency-like slot into one shared control position: Sine and a noise color's 1/3-Octave render the literal same Frequency field there, that color's Band-limited renders a "Range" picker there instead — see docs/spec.md's "Addendum: pink noise modes" and "Addendum: White noise modes". Which control fills that shared slot, and whether it's visible at all, is decided by one FrequencySlotContent enum (frequencySlotContent/frequencySlotVisible, issue #40) — not by re-deriving the same noise-family/signal-type condition independently at each call site. Device switch (selectDeviceIfNeeded) always forces every channel muted via SettingsStore.channelStatesForDeviceSwitch, even for a previously-used device with saved unmuted channels, per ADR 0001.
  • SoundCheck/DesignSystem/ (ADR 0005) — the shared design-token system copied verbatim from the sibling FreqTrace analyzer app, so the two companion tools (FreqTrace analyzes live sound, SoundCheck generates test signals) read as one console. DesignTokens.swift holds the palette as hex strings (unit-testable, one ColorTokens set each for Dark and Light — Light is a higher-contrast redesign, not an inversion); Theme.swift converts them to SwiftUI Colors exposed via EnvironmentValues.theme, stored (not computed) so hex parsing stays off the per-body hot path; HexColor.swift is the shared #rrggbb parser; AppearanceSettings.swift is the persisted (UserDefaults) Dark/Light selection. Currently a copy, not a shared Swift package — promote to a package if a third consumer appears or the two palettes drift. When adjusting element elevation, note the token quirk that bit us: surfaceRaised is lighter than surface in Dark but darker in Light, so "lift a control to surfaceRaised" doesn't read as a lift in Light (this is why the ON/OFF button uses colored states, not a neutral fill).
  • SoundCheck/SignalSettings.swift — @MainActor @Observable module (issue #31) owning every parameter that must land in both SignalRenderCore.updateParameters and SettingsStore.update: signalType, isRunning, frequencyHz, levelDbfs, pinkNoiseMode, whiteNoiseMode, sweepDurationSeconds, clickIntervalSeconds, channels. Each is a stored property whose didSet pushes into both destinations, so a call site assigns one property instead of hand-writing both — the one place a new dual-write parameter's bug would concentrate, and the one seam unit-tested for it. signalType's setter also forces isRunning = false per ADR 0003. channels persists under whichever device UID ContentView last reported via deviceDidChange(to:) — SignalSettings doesn't own device selection itself (hardware-bound, out of scope, stays on ContentView/AudioEngineController). Constructed with injected renderCore/settingsStore; its init seeds every property from settingsStore.snapshot and pushes that same snapshot into renderCore, replacing what used to be ContentView.onAppear's manual field-by-field unpack.
  • SoundCheck/SignalRenderCore.swift — the generators (sine, Kellett pink noise, white noise via a fast xorshift64 PRNG), the OSAllocatedUnfairLock-guarded RenderParameters, the start/stop ramp, and per-channel mute/phase — a pure unit, independently unit-tested without any live audio device. ClickGenerator (issue #50) emits a 0.2ms positive Hann pulse per clickIntervalSeconds, timed from the last pulse so a live interval change applies immediately, with its first pulse primed in reset() to land just after the 15ms start ramp; ClickInterval holds the Interval field's 0.2–3.0s range and its 0.1s snap/step rule (integer-tenths math, no float drift). Also defines GeneratorKind (issue #33) — the single enum for "which signal is selected," CaseIterable/Identifiable/Hashable/Codable so ContentView's picker and SettingsStore's persistence both use it directly; there is no separate UI-facing enum kept in sync by hand. The filtered-noise work added NoiseMode (full-range/band-limited/1/3-octave, backed by BandLimitedFilterChain/ThirdOctaveFilterChain respectively — both two-edge Butterworth cascades over a highpass+lowpass edge pair, not a bandpass filter), shared by both PinkNoiseGenerator and WhiteNoiseGenerator (RenderParameters carries independent pinkNoiseMode/whiteNoiseMode fields) and rebuilt only when the mode actually changes, never per sample. BandLimitedFilterChain takes a NoiseSpectralShape (.pinkOneOverF/.whiteFlat) since White's flat power spectral density needs a different level-compensation formula than Pink's 1/f one — see docs/research/white-noise-band-limiting.md. WhiteNoiseGenerator splits its pure PRNG draw (rawSample()) from its mode-dispatching public interface (nextSample) so PinkNoiseGenerator's internal white-noise source stays unaffected by whiteNoiseMode. Pink noise (all sub-modes) also carries pinkLevelCompensationGain, an empirically-measured makeup gain so its RMS matches White noise's at the same levelDbfs — see docs/research/pink-white-noise-generation.md's "Level compensation" addendum. NoiseModeFilter (issue #41) owns the "rebuild the band-limited/1/3-octave filter on mode change, else reuse, then dispatch" logic shared by PinkNoiseGenerator and WhiteNoiseGenerator — parameterized by NoiseSpectralShape and a per-band third-octave compensation-gain closure (a fixed-constant closure for Pink, whiteThirdOctaveLevelCompensationGain(centerHz:) directly for White) — so that ~25-line pattern is no longer copy-pasted between the two generators.
  • SoundCheck/Biquad.swift — the shared RBJ-cookbook biquad filter (Direct Form II: lowpass/highpass/bandpass), used to build both noise filtering modes' edge cascades. Coefficient computation happens only in init; process() is pure per-sample arithmetic, safe for the real-time render path. Only .lowpass/.highpass are used in production now (.bandpass backed 1/3-Octave's original single-biquad design, superseded by ThirdOctaveFilterChain; the case and its test coverage remain since Biquad is still a general-purpose shared primitive).
  • SoundCheck/AudioEngineController.swift (macOS-only) — wraps SignalRenderCore in a real AVAudioSourceNode/AVAudioEngine, rebuilding the node on device switch (channel count/sample rate can differ per device) and binding output via the AudioUnit device override. The render closure's AudioBufferList-entry-to-typed-buffer conversion is a nonisolated static func (floatBuffer(forChannel:in:frameCount:), issue #42) rather than inlined — pure and allocation-free, so it's unit-tested with a hand-built AudioBufferList instead of being reachable only through a real AVAudioEngine.
  • SoundCheck/AudioDeviceCatalog.swift (macOS-only) — @Observable live catalog of Core Audio output devices (UID, name, output channel count) via an AudioObjectPropertyListenerBlock on kAudioHardwarePropertyDevices; also exposes per-device nominalSampleRate(for:) and bitDepth(for:) lookups.
  • SoundCheck/Channel.swift — Channel, the one shape for a Channel's mute/phase-reverse state (see CONTEXT.md), used directly by both ContentView/SignalSettings' live UI state and SettingsStore's persisted channelStatesByDeviceUID — there is no separate UI-facing vs. persisted struct translated by hand at that seam. RenderParameters.channelMuted/channelPhaseReversed stay as two parallel Bool arrays rather than [Channel]: a deliberate real-time-safety adapter (no per-callback allocation on the audio render thread, per ADR 0002), not a third shape of the same duplication.
  • SoundCheck/SettingsStore.swift — persists the last signal type, frequency, level, device, per-channel mute/phase (as [Channel]), and Pink's and White's noise modes independently as one JSON-encoded snapshot in UserDefaults, keyed by device UID, plus Click's interval. SettingsSnapshot decodes field by field (a custom init(from:)), defaulting only a missing/unreadable field — synthesized Decodable would reject an older build's JSON outright and silently reset every setting, so keep new fields in that initializer. channelStatesForDeviceSwitch(forDeviceUID:channelCount:) is the one production call site for applying saved per-device state — it always forces mute on (preserving only phase-reverse) per ADR 0001, distinct from channelStates(forDeviceUID:channelCount:)'s plain saved-or-default lookup.
  • SoundCheck/ThirdOctaveBands.swift — the canonical 31-band ISO 266 1/3-octave frequency list (20Hz–20kHz), used by the shared Frequency field's prev/next stepping and its band dropdown (lcdComboField/bandMenuLabel/bandLabel in ContentView — a borderless Menu chevron folded into the field's dark LCD panel so the field + ▾ read as one combo box, giving the Frequency field combo-box behavior: type freely or pick a standard band, on both Sine/Square and a noise color's 1/3-Octave field) for Sine and for a noise color's 1/3-Octave sub-mode (Pink or White), and by PinkNoiseGenerator/WhiteNoiseGenerator/ThirdOctaveFilterChain to resolve a selected band index to its center frequency. The 31.5Hz band keeps its decimal label as a deliberate, narrow exception to the app's otherwise whole-Hz-only display rule.

Swift concurrency note: AudioDeviceCatalog is @MainActor + @Observable, but its Core Audio listener state (listenerBlock, devicesAddress) is @ObservationIgnored nonisolated(unsafe) because deinit runs nonisolated and needs to remove the property listener; its static Core Audio query functions (fetchOutputDevices(), outputChannelCount(for:), etc.) are nonisolated since they touch no actor-isolated state and need to be callable from tests without hopping to the main actor. SignalRenderCore follows the same pattern: its generator instances and ramp state are nonisolated(unsafe) since they're only ever touched from the single real-time render callback.

UI gotcha worth knowing before touching the signal-type picker or window width: AppKit draws a segmented picker at its natural width once a later layout pass re-measures it (e.g. the noise-mode picker enabling on a switch to Pink/White), even if SwiftUI squeezed it narrower at launch. So the picker + ⌥-legend group is .fixedSize(horizontal:)'d to that natural width (its equal columns then line up with the equal-width segments), and the window (540pt) must stay wide enough to fit it — natural width ~459pt vs. panel content = window width − 72pt. Adding a segment or lengthening a label means re-measuring and possibly widening. Relatedly, the shared frequency-like slot has a 42pt minimum height so Band-limited's shorter Range menu doesn't reflow the window.

UI gotcha worth knowing before touching button styling: a .buttonStyle(.plain) button whose .background()/.overlay() are applied outside the label is only tappable where the label's actual glyphs render, not across the visible background — needs an explicit .contentShape(Rectangle()) on the label to fix, and this is easy to miss since it looks correct until you actually click it.