This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
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.
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).
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.
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
AVAudioSourceNoderender 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). SignalGeneratoris widened to carry full parameters, plus areset()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 toSignalSettings(see below), whichContentViewbinds 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; seeSoundCheck/DesignSystem/below): every color reads fromEnvironmentValues.theme(accent/danger/surface tiers) rather than hardcoded values, and the app owns its Dark/Light appearance (a footer picker backed byAppearanceSettings, 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(asurfaceconsole-plate zone recessed into the window's lightersurfaceRaisedchassis, matching FreqTrace's Weighting/FFT-Size modules);LCDFieldStyle(the numeric readouts' dark inset panel, kept deliberately dark in both appearance modes via the one hardcodedColor.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 fromtheme.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 selectiontheme.accent, matching FreqTrace. Number keys1–9/0toggle the first ten channels' Mute (channelMuteShortcuts— invisible buttons behind the window chassis, gated off viaanyFieldFocusedwhile a numeric field is focused so typed digits aren't stolen). Seedocs/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 ownNoiseModeDraft—pinkDraft/whiteDraft— so their selections persist independently;NoiseModeDraftalso owns the manual-range/1/3-octave commit-clamp-snap logic itself, issue #39, rather than that logic living as untestable private methods onContentView), 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 — seedocs/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 oneFrequencySlotContentenum (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 viaSettingsStore.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.swiftholds the palette as hex strings (unit-testable, oneColorTokensset each for Dark and Light — Light is a higher-contrast redesign, not an inversion);Theme.swiftconverts them to SwiftUIColors exposed viaEnvironmentValues.theme, stored (not computed) so hex parsing stays off the per-body hot path;HexColor.swiftis the shared#rrggbbparser;AppearanceSettings.swiftis 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:surfaceRaisedis lighter thansurfacein Dark but darker in Light, so "lift a control tosurfaceRaised" 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 @Observablemodule (issue #31) owning every parameter that must land in bothSignalRenderCore.updateParametersandSettingsStore.update:signalType,isRunning,frequencyHz,levelDbfs,pinkNoiseMode,whiteNoiseMode,sweepDurationSeconds,clickIntervalSeconds,channels. Each is a stored property whosedidSetpushes 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 forcesisRunning = falseper ADR 0003.channelspersists under whichever device UIDContentViewlast reported viadeviceDidChange(to:)—SignalSettingsdoesn't own device selection itself (hardware-bound, out of scope, stays onContentView/AudioEngineController). Constructed with injectedrenderCore/settingsStore; itsinitseeds every property fromsettingsStore.snapshotand pushes that same snapshot intorenderCore, replacing what used to beContentView.onAppear's manual field-by-field unpack.SoundCheck/SignalRenderCore.swift— the generators (sine, Kellett pink noise, white noise via a fast xorshift64 PRNG), theOSAllocatedUnfairLock-guardedRenderParameters, 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 perclickIntervalSeconds, timed from the last pulse so a live interval change applies immediately, with its first pulse primed inreset()to land just after the 15ms start ramp;ClickIntervalholds the Interval field's 0.2–3.0s range and its 0.1s snap/step rule (integer-tenths math, no float drift). Also definesGeneratorKind(issue #33) — the single enum for "which signal is selected,"CaseIterable/Identifiable/Hashable/CodablesoContentView's picker andSettingsStore's persistence both use it directly; there is no separate UI-facing enum kept in sync by hand. The filtered-noise work addedNoiseMode(full-range/band-limited/1/3-octave, backed byBandLimitedFilterChain/ThirdOctaveFilterChainrespectively — both two-edge Butterworth cascades over a highpass+lowpass edge pair, not a bandpass filter), shared by bothPinkNoiseGeneratorandWhiteNoiseGenerator(RenderParameterscarries independentpinkNoiseMode/whiteNoiseModefields) and rebuilt only when the mode actually changes, never per sample.BandLimitedFilterChaintakes aNoiseSpectralShape(.pinkOneOverF/.whiteFlat) since White's flat power spectral density needs a different level-compensation formula than Pink's 1/f one — seedocs/research/white-noise-band-limiting.md.WhiteNoiseGeneratorsplits its pure PRNG draw (rawSample()) from its mode-dispatching public interface (nextSample) soPinkNoiseGenerator's internal white-noise source stays unaffected bywhiteNoiseMode. Pink noise (all sub-modes) also carriespinkLevelCompensationGain, an empirically-measured makeup gain so its RMS matches White noise's at the samelevelDbfs— seedocs/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 byPinkNoiseGeneratorandWhiteNoiseGenerator— parameterized byNoiseSpectralShapeand 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 ininit;process()is pure per-sample arithmetic, safe for the real-time render path. Only.lowpass/.highpassare used in production now (.bandpassbacked 1/3-Octave's original single-biquad design, superseded byThirdOctaveFilterChain; the case and its test coverage remain sinceBiquadis still a general-purpose shared primitive).SoundCheck/AudioEngineController.swift(macOS-only) — wrapsSignalRenderCorein a realAVAudioSourceNode/AVAudioEngine, rebuilding the node on device switch (channel count/sample rate can differ per device) and binding output via theAudioUnitdevice override. The render closure'sAudioBufferList-entry-to-typed-buffer conversion is anonisolated static func(floatBuffer(forChannel:in:frameCount:), issue #42) rather than inlined — pure and allocation-free, so it's unit-tested with a hand-builtAudioBufferListinstead of being reachable only through a realAVAudioEngine.SoundCheck/AudioDeviceCatalog.swift(macOS-only) —@Observablelive catalog of Core Audio output devices (UID, name, output channel count) via anAudioObjectPropertyListenerBlockonkAudioHardwarePropertyDevices; also exposes per-devicenominalSampleRate(for:)andbitDepth(for:)lookups.SoundCheck/Channel.swift—Channel, the one shape for a Channel's mute/phase-reverse state (seeCONTEXT.md), used directly by bothContentView/SignalSettings' live UI state andSettingsStore's persistedchannelStatesByDeviceUID— there is no separate UI-facing vs. persisted struct translated by hand at that seam.RenderParameters.channelMuted/channelPhaseReversedstay as two parallelBoolarrays 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 inUserDefaults, keyed by device UID, plus Click's interval.SettingsSnapshotdecodes field by field (a custominit(from:)), defaulting only a missing/unreadable field — synthesizedDecodablewould 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 fromchannelStates(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/bandLabelinContentView— a borderlessMenuchevron 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 byPinkNoiseGenerator/WhiteNoiseGenerator/ThirdOctaveFilterChainto 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.