Skip to content

Latest commit

 

History

History
238 lines (186 loc) · 43.1 KB

File metadata and controls

238 lines (186 loc) · 43.1 KB

SoundCheck — Spec

Signal generator utility for sound engineers, techs, and hifi enthusiasts testing speakers/sound systems. Not an audio analyser — playback only, no capture/analysis.

Platform: macOS first, iOS portability considered but not designed for yet.

Feature history

This document started as the spec for the first build (the "V1" planning milestone) and has grown by addenda since. The "V1"/"V2" milestone names were planning labels only — they never matched the app's release versions (v0.x git tags), so the spec no longer uses them; features are named instead. Linked GitHub issues keep their original milestone-era titles.

  • Core (first build): Sine, pink noise, white noise + full UI chrome (on/off, frequency field, level field, device picker, per-channel mute/phase, sample rate/bit depth display). Planned on the first wayfinder map.
  • Filtered noise, sweep, square (planned on the second wayfinder map): band-limited and 1/3-octave pink noise — see "Addendum: pink noise modes"; White noise gained the same modes — see "Addendum: White noise modes"; sine sweep — see "Addendum: sine sweep"; square wave — see "Addendum: square wave".
  • Click generator (issue #50): a repeating click for checking inter-speaker delay settings by ear — see "Addendum: click generator".

Signal types

The first build shipped Sine, Pink, and White (full-band only); the rest came later — each links to its addendum below.

Signal Parameters
Sine wave Frequency (Hz), Level (dBFS)
Square wave Frequency (Hz), Level (dBFS) — see "Addendum: square wave"
Pink noise Mode (Full-range / Band-limited / 1/3-Octave) + that mode's range or band, Level (dBFS) — see "Addendum: pink noise modes"
White noise Same modes as Pink — see "Addendum: White noise modes"
Sine sweep Duration (s), Level (dBFS) — log 20Hz–20kHz, looping — see "Addendum: sine sweep"
Click Interval (s), Level (dBFS, pulse peak) — see "Addendum: click generator"

Screen layout

┌──────────────────────────────────────────────────────────────┐
│ GENERATOR                                                    │
│  [ SINE | SQUARE | PINK | WHITE | SWEEP | CLICK ]  ← signal  │
│     ⌥S     ⌥Q      ⌥P     ⌥W      ⌥E      ⌥C     ← legends   │
│  ┌──────────────────────────────────────────────────────┐    │
│  │                 ● OFF  [Space]                        │    │  ← big toggle
│  └──────────────────────────────────────────────────────┘    │
│      [ FULL-RANGE | BAND-LIMITED | 1/3-OCTAVE ]              │  ← noise mode (Pink/White only; reserved otherwise)
│  Frequency:  [ 1000 ▾] Hz   [◀][▶]                           │  ← shared slot: Frequency / Range /
│                              ←   →                           │     Duration / Interval, or empty
│              [ low ] – [ high ]                              │  ← manual range (Band-limited › Manual only)
│  Level:      [ -20 ] dBFS   [+] ↑                            │
│                             [-] ↓                            │
├──────────────────────────────────────────────────────────────┤
│ OUTPUT                                                       │
│  Output Device:  [ MOTU 8A ▾ ]                               │
│  [Mute All ⌥M]                                               │
│  CH1 [Mute] 1 [Ø] ⌥1   CH2 [Mute] 2 [Ø] ⌥2   ...  ← scroll   │
├──────────────────────────────────────────────────────────────┤
│  48.0 kHz / 24-bit            [Dark ▾]   [ ] Always on Top   │
└──────────────────────────────────────────────────────────────┘

Every row is reserved whether or not it currently applies (hidden + disabled, never removed), so the fixed-size window never reflows on a signal-type or noise-mode switch.

GENERATOR and OUTPUT are titled panel groupings (tracked all-caps labels, subtle background fill) — not decoration, they separate "what's being generated" from "where it's going." The shared slot's steppers (Frequency's prev/next chevrons, Duration's and Interval's −/+) and Level's +/- both sit to the right of their value, ordered to match their keyboard shortcuts: chevrons left-then-right (matching ←/→), +/- stacked with + on top (matching ↑/↓ — up increases). All four stepper buttons share one explicit size so they read as one control family. See "Visual design" below for the full rationale.

Controls

Window

Fixed-size utility panel, not resizable — 540pt wide since the click generator added a sixth signal-type segment (see the decisions log). Optional user-toggleable "always on top" (off by default) so the panel can stay visible while working elsewhere in the room.

Signal type selector

Segmented control, 6 states (Sine / Square / Pink / White / Sweep / Click), all options visible at once, each with an ⌥-letter shortcut printed beneath it (⌥S ⌥Q ⌥P ⌥W ⌥E ⌥C). The picker and its legend row are sized to the picker's natural width, so the window must stay wide enough to fit it (see "Window sizing" in the decisions log). Switching while playing fully stops output (drops to OFF) — for safety, to avoid unwanted noise from an in-flight transition. The user must press ON again to hear the newly selected signal. See ADR 0003.

Big On/Off switch

  • Spacebar toggles globally, except while a text field is actively being edited.
  • On launch, no field is pre-focused — otherwise AppKit's default first-responder behavior auto-focuses the frequency field (the first key-capable control), silently swallowing the very first spacebar press as a typed space instead of starting the generator. WindowAccessor explicitly resigns focus to the window's content view once, right after the window is created.
  • Linear ~15ms gain ramp on start/stop (applied inside the render block) to avoid click artifacts — matters since users are driving real speakers. Same ramp is used for the forced stop triggered by a signal-type switch.
  • State is shown via both color and an explicit text label ("ON"/"OFF") — never color alone. Running state uses red (a "live" tally light, theme.danger), not green, since this is the state where something is actively happening, not a "safe" state; idle is cyan-outlined ("armed").

In-app shortcut help

No separate help view, button, or menu item. Shortcuts are discoverable exactly where they apply, two ways:

  • Printed legends (#46): each shortcut is printed on or under its control — Space on ON/OFF, a faint ⌥-letter row under the signal-type picker, key caps under the shared slot's and Level's steppers (←/→, ↑/↓), ⌥M on Mute All, and the channel number (Mute) and ⌥-number (Ø) on each of the first ten channels.
  • Tooltips: every such control also carries a native .help() tooltip stating its shortcut, shown on hover.

Keyboard map: Space ON/OFF · ⌥S/⌥Q/⌥P/⌥W/⌥E/⌥C signal type · ⌥F/⌥B/⌥O noise mode (Pink/White only) · ←/→ the shared slot's stepper (band, duration, interval) · ↑/↓ Level · 1–9/0 Mute channels 1–10 · ⌥1–9/0 Ø channels 1–10 · ⌥M Mute All / Unmute All. Unmodified digit and ⌥-letter shortcuts are disabled while a numeric field is focused, so typing isn't stolen; ⌥M (the safety action) always fires.

Frequency field (Sine, Square, and 1/3-Octave noise)

  • Range 20Hz-20kHz, free text entry, default 1000Hz.
  • Whole Hz only — typed input is rounded to the nearest integer Hz on commit. (Internal oscillator still uses full float precision; this is a display/entry rule only.) Exception: the 1/3-octave band at 31.5Hz displays as "31.5Hz", not rounded to 32 — it's the fixed ISO 266 standard label, not a typed value.
  • Left/Right arrow keys step to prev/next 1/3-octave value from the canonical ISO 266 31-band list — not prev/next Hz.
  • A dropdown (▾ a borderless Menu chevron folded into the field's dark LCD panel, so the field + arrow read as one combo box) lists all 31 ISO 266 bands for direct picking — combo-box behavior: type any value or pick a standard band. The same dropdown is offered on the 1/3-Octave noise band field (Pink & White), where picking sets the band index directly. Band labels use raw Hz (e.g. "1000 Hz", "31.5 Hz"), matching the field's own whole-Hz display rule.
  • Typed free-text values don't snap to the 1/3-octave grid; arrows and the dropdown are the only things that land exactly on a band.
  • Clamp to range on commit, reject non-numeric input.
  • Prev/next chevron buttons sit together to the right of the value, left-then-right, matching the left-arrow/right-arrow shortcuts. Reserved (hidden + disabled, not removed) when nothing occupies it (full-range noise), so the fixed-size GENERATOR panel never reflows on signal-type switch. This reserved slot is a single shared control position (FrequencySlotContent), not parallel reserved rows: Sine and Square show this Frequency field; a noise color's 1/3-Octave reuses this exact control (not a separate band stepper); Band-limited shows a "Range" picker in the same slot instead; Sweep shows its Duration field and Click its Interval field there — see the addenda. The slot has a 42pt minimum height so its shorter occupant (the Range menu) doesn't reflow the window.

Level field

  • Range: -99 dBFS to 0 dBFS.
  • Default -20 dBFS.
  • Up/Down arrows and [+]/[-] buttons step by 1dB (whole numbers only).
  • Free-text entry accepts decimal dB values (e.g. "-18.5") for precise level matching — decimals are only reachable by typing, not by stepping.
  • +/- buttons sit stacked to the right of the value, + above -, matching the up-arrow/down-arrow shortcuts (up increases, physically on top).

Output device picker

  • Enumerate Core Audio output devices live; update on hot-plug/removal via device-change listener (not a one-time query at launch).
  • If the selected device is disconnected while playing: auto-stop and show an alert. Never silently reroute to system default.

Per-channel mute / phase reverse

  • Channel count driven by the selected device's actual output channel count, not fixed at 2.
  • The signal is routed to every channel of the device. Every channel defaults to muted — including channel 1 — on launch and on every device switch, so nothing plays until the user explicitly unmutes the channel(s) they intend to test. See ADR 0001.
  • Mute and Ø (phase) are independent per-channel toggles.
  • Layout must handle devices with many channels (8+) gracefully: a horizontally-scrolling row within a fixed-height area (not wrapping to multiple rows), so window height stays constant regardless of the connected device's channel count.
  • Both toggles use a solid-fill style (bold white text on a solid color background when engaged, dim outline when not) rather than a light system tint — engaged/disengaged must be unmistakable at a glance for a routing control this safety-relevant.

Sample rate / bit depth display

  • Read-only, reflects the selected device's current nominal sample rate and stream format. Updates live if the format changes externally (e.g. via Audio MIDI Setup while SoundCheck is running).

Visual design

The screen has a deliberate "precision instrument, not settings pane" identity, built after the initial functional shell already existed (see the "Give the V1 screen a real visual identity" commit, named for the first-build milestone). It respects the existing "follow system appearance automatically" decision below — no forced dark theme — so the identity comes from typography, proportion, and one accent color rather than overriding light/dark.

Later, the color and appearance implementation below was superseded by ADR 0005 (shared design tokens with the sibling FreqTrace app). The intent — a precision-instrument identity, one meaningful accent, dark LCD readouts — is unchanged, but the mechanics described in the bullets are historical. What changed: colors now read from EnvironmentValues.theme, so Color.soundCheckAmber no longer exists (the accent is theme.accent); the app owns its Dark/Light appearance via a footer picker rather than following the system, so "no forced dark theme / follow system appearance" no longer holds; the running state moved from amber to red (theme.danger, a tally light) and the ON/OFF idle state is amber-outlined; both segmented pickers tint their selection amber; and PanelSection is now a surface console-plate recessed into a surfaceRaised chassis. Still true: LCDFieldStyle stays deliberately dark in both appearance modes (Color.soundCheckLCDPanel, the one theme carve-out), and SolidToggleStyle still fills Mute/Ø solid. See ADR 0005 and CLAUDE.md's ContentView / DesignSystem entries for the current state.

  • One signature accent (Color.soundCheckAmber, a warning-lamp amber): reused consistently for the running-state LED/background, the selected signal-type tab, the engaged Ø toggle, and (added later, as a subtle hairline border/glow rather than a fill) LCDFieldStyle's numeric-readout treatment — never a plain decorative fill anywhere else, so it stays meaningful. Mute stays red — a distinct, universally-understood "danger/silence" color — so the two per-channel toggles read as different kinds of control, not just two amber-ish buttons.
  • LCDFieldStyle (added later): the numeric fields (Frequency, Level, Duration, manual-range low/high) render as a dark inset panel with an amber hairline glow, echoing the ON/OFF button's LED, since they're the closest thing in the design to an actual instrument's numeric display — deliberately dark regardless of system light/dark appearance, the one narrow exception to "no forced dark theme" above, matching the amber LED's own appearance-independent color. Color.soundCheckLCDPanel is the named panel color, alongside Color.soundCheckAmber.
  • Monospaced tabular digits (.fontDesign(.monospaced)) on the frequency field, level field, and the sample-rate/bit-depth readout — the standard instrumentation convention so numbers don't visually jitter as they change.
  • Two titled panel groupings (PanelSection, a reusable titled container with a subtle background fill): GENERATOR and OUTPUT, with tracked all-caps labels evoking panel silkscreening — structural, not decorative, since it separates "what's being generated" from "where it's going."
  • SolidToggleStyle: a custom ToggleStyle used for Mute and Ø, filling solid + bold white text when on, dim outline when off, instead of the much-subtler default .toggleStyle(.button) + .tint() combination.
  • Buttons whose only visible content is an icon (the ON/OFF button, the frequency chevrons, the level +/-) need an explicit .contentShape(Rectangle()) on their label — otherwise .buttonStyle(.plain) only makes the icon glyph itself tappable, not the surrounding background/padding, which is not obvious from the rendered appearance and needs a visual click-target check, not just a build, to catch.

Audio engine architecture

  • No third-party DSP library. Generators are hand-written AVAudioSourceNode render blocks. See ADR 0002.
  • One persistent source node. A single AVAudioSourceNode is attached to the engine; its render block delegates to an atomically-swappable generator reference. Only one generator is ever live — no multi-node mixing, since signal-type switch forces a full stop first (ADR 0003) rather than crossfading.
  • Cross-thread parameter passing. UI-driven changes (frequency, level, mute/phase, on/off, generator swap) are written to a plain parameter struct guarded by OSAllocatedUnfairLock. The render block takes the same lock to snapshot parameters at the top of each call — brief, uncontended, real-time-safe; no third-party atomics package.
  • Per-channel mute/phase is applied as a final per-channel pass inside the same render block (multiply each channel's samples by 0 if muted, ±1 for phase), not via a separate downstream node.
  • Device binding. Stays inside AVAudioEngine: the output node's underlying AudioUnit has its kAudioOutputUnitProperty_CurrentDevice overridden to target the user-selected Core Audio device, rather than bypassing AVAudioEngine for a raw AUHAL unit.

SignalRenderCore implements the generators, ramp, and per-channel routing as a pure, AVAudioEngine-independent unit (sine via a phase accumulator; pink noise via Paul Kellett's refined filter over a fast xorshift64 PRNG; white noise via the same PRNG directly). It's deliberately decoupled from AVAudioSourceNode so it's unit-testable without a live audio device.

AudioEngineController (macOS-only) wraps SignalRenderCore in a real AVAudioSourceNode, rebuilding the node whenever the selected device's channel count or sample rate changes (a device switch requires detach/reattach, not just a format tweak), and binds the engine's output to the selected device via the AudioUnit override described above. ContentView wires every control to it: the signal selector forces running = false on change (ADR 0003) before swapping the generator kind; frequency/level/mute/phase push straight into SignalRenderCore's parameters; the device picker uses AudioDeviceCatalog's live list; losing the selected device (detected via the catalog's list changing) auto-stops and shows a SwiftUI .alert, then clears the selection rather than silently falling back to another device.

Persistence

Remember last signal type, frequency, level, device, and per-channel mute/phase across launches. Key device selection by device UID (not index), so it survives device list reordering.

Implemented as SettingsStore: a single JSON-encoded SettingsSnapshot under one UserDefaults key. Per-channel mute/phase is stored as [deviceUID: [PersistedChannelState]], so switching back to a previously used device restores its exact channel states; an unrecognized device UID or a channel count mismatch (device swapped for a different one) falls back to all-muted, per ADR 0001, rather than reusing stale state that doesn't match the new device's layout.

ContentView loads the snapshot once on onAppear (falling back to the first available device if the saved device UID is no longer present) and pushes every subsequent change — signal type, frequency, level, selected device, per-channel mute/phase — straight back into SettingsStore via its own onChange handlers, so persistence needs no separate save action. This closed out the first build: every control is wired to a live audio engine, a real device layer, and now persisted settings.

Decisions log

Question Decision
Signal switch while playing Superseded — now forces full stop to OFF, see ADR 0003
Level step size 1dB
Level floor -99 dBFS
Device disconnect while playing Auto-stop + alert
Settings persistence Remembered across launches, keyed by device UID
Window sizing Fixed-size utility panel, not resizable. Widened from 420pt to 540pt when Click added a sixth signal-type segment: the six equal-width segments need ~459pt at natural size, and the panel's content width is the window width minus 72pt. 531pt would be the exact minimum (picker flush with the ON/OFF button); 540pt was kept for a little slack
Always on top User-toggleable, off by default
On/off state indication Color (red/amber when running) + explicit text label, never color alone
Signal type selector style Segmented control, all options visible
Frequency field precision Whole Hz only (display/entry); internal oscillator stays float
Level field precision Whole dB via arrows/buttons; decimals allowed via free-text entry
Many-channel layout Horizontal scroll within fixed-height row, not wrapping
Channel mute default All channels muted by default, including channel 1 — see ADR 0001
DSP library vs custom Custom AVAudioSourceNode render blocks, no third-party library — see ADR 0002
Render graph topology One persistent source node, swappable generator reference — no multi-node mixing
UI-to-audio-thread parameter passing Plain struct guarded by OSAllocatedUnfairLock
Start/stop ramp curve/duration Linear, ~15ms, inside the render block
Per-channel mute/phase application point Inside the same render block, final per-channel pass
Output device binding AVAudioEngine output node, AudioUnit kAudioOutputUnitProperty_CurrentDevice override
Visual identity "Precision instrument" direction: one amber signature accent, monospaced numeric readouts, titled panel groupings — see "Visual design"
Frequency control reflow on signal-type switch Space always reserved (hidden + disabled, not removed) so the fixed-size window never reflows
Stepper button placement Grouped to the right of the value, ordered to match their keyboard shortcut direction (chevrons left-then-right, +/- stacked with + on top)
Mute/phase toggle contrast Custom SolidToggleStyle (solid fill + white text when on) — the default .toggleStyle(.button) tint was too subtle for a safety-relevant control
Pink noise loudness vs. Sine/White at the same Level Kellett's raw output measured ~9.5dB quieter in RMS than White at the same levelDbfs (higher crest factor, uncompensated); pinkLevelCompensationGain now calibrates Pink's RMS to match White's — see docs/research/pink-white-noise-generation.md's "Level compensation" addendum
Noise reads its Level setting on an analyzer (later fix) Both noises were referenced to White's uniform RMS (1/sqrt(3), ~4.77dB below peak), so on an AES17 analyzer (0 dBFS == full-scale sine) noise at Level -20 read ~-22 while Sine read -20. noiseFullScaleReferenceGain (sqrt(3/2), +1.76dB) now lifts both noises' RMS to a full-scale sine's RMS (1/sqrt(2)) so they read the Level setting like Sine does — trade-off: peaks pushed ~1.76dB closer to full scale. See the "Full-scale reference calibration" addendum in docs/research/pink-white-noise-generation.md
Device switch mute (later fix) Now unconditionally forces every channel muted on every device switch, even a previously-used device with saved unmuted channels — matches ADR 0001's literal wording, which the original implementation didn't fully satisfy
Render-loop per-channel work (later perf fix) SignalRenderCore.render now precomputes each channel's buffer pointer and muted/phase-reversed flags once per callback, before the frame loop, instead of re-deriving them frameCount times per channel — same output, fewer redundant lookups per callback (see issue #11)
White noise sub-modes, render-core layer (later) White noise gained the same Full-range/Band-limited/1/3-Octave sub-modes Pink already had. The shared sub-mode enum was generalized from PinkNoiseMode to NoiseMode, with independent RenderParameters/SettingsSnapshot fields per color (pinkNoiseMode, whiteNoiseMode) so each color's selection persists separately. White's level-compensation math is a different formula from Pink's — linear-Hz-bandwidth-ratio, not octave-span, since White's PSD is flat rather than 1/f — see docs/research/white-noise-band-limiting.md. UI wiring (exposing White's modes in ContentView) is a separate, subsequent change (see issue #38)
Filtered-noise band levels ≤1dB across sample rates (later fix) The per-sub-mode makeup gains (pink octave-span ratio, pink's fixed 1/3-octave constant, white's per-band Hz-ratio) all assumed an ideal brick-wall passband and left up to ~±2.4dB real per-band error — worst at the 20Hz/20kHz edge bands — plus a sample-rate error (they referenced a fixed 20Hz–20kHz "full range" rather than the generator's true DC–Nyquist span). Replaced by one shared noiseBandMakeupGain deriving the gain from each realized filter cascade's effective noise bandwidth: sqrt(∫S(f)df / ∫S(f)·|H(f)|²df) over the filter's actual power response (Biquad.magnitudeSquared), weighted by the noise PSD (flat white; actual Kellett response for pink). Frequency- and sample-rate-aware; every band now lands ≤1dB from full-range at 44.1/48/96kHz (tests tightened from ±3dB, now multi-rate + edge bands). Also fixed a latent NaN: the 20kHz 1/3-octave band's upper edge exceeds Nyquist at 44.1kHz. Two test-harness bugs were found en route — the noise tests had been rendering a fallback sine (the render loop only adopts generatorKind on a rampGain==0 frame), and narrow low bands need a long settle window. See the "Effective-noise-bandwidth calibration" addenda in the three band-noise research docs
Design-review polish (issues #34–#36) Three small visual fixes from a codebase design review: the noise-mode picker (noiseModeControl) gets an explicit .tint(.secondary) instead of inheriting macOS's default system blue — the one accidental non-amber, non-neutral color the review flagged; manualRangeFields' always-reserved-but-often-hidden row gets an explicit, tighter .frame(height: 24); and the numeric fields (Frequency/Level/Duration/manual-range) get LCDFieldStyle, a dark inset panel with an amber hairline glow — see "One signature accent" and LCDFieldStyle above
Click Level semantics Level is the click pulse's peak in dBFS, not RMS — a 0.2ms pulse repeated every 0.2–3s has an RMS that's meaningless and interval-dependent. Deliberate consequence: a click at -20 sounds much quieter than noise at -20 (far less energy); that's expected, not a calibration bug. See "Addendum: click generator"
Signal picker squeezed below natural width (layout fix, with Click) macOS draws a segmented picker at its natural width once a later layout pass re-measures it (here: the noise-mode picker becoming enabled on a switch to Pink/White), even when SwiftUI had squeezed it to fit at launch — so an undersized window made the GENERATOR panel overflow and clip, and misaligned the ⌥-letter legend row from its segments. The signal picker + legend group is now sized to the picker's natural width (and the picker's empty label is hidden, which was offsetting the segments ~9pt), and the window is wide enough to fit it. Present in milder form before Click (5 segments at 420pt)
Shared slot height (layout fix, with Click) Band-limited's Range menu (24pt) is shorter than every other shared-slot occupant (42pt: field + steppers with key caps below), so switching into Band-limited shrank the slot and reflowed Level and the whole window upward — breaking the "never reflows" rule above. The slot now has a 42pt minimum height
Settings from an older build (persistence fix, with Click) SettingsSnapshot used synthesized Decodable, which rejects JSON missing any non-optional field — so adding a setting (e.g. clickIntervalSeconds) would have silently reset every saved setting on upgrade, and a value an older build doesn't know (e.g. signal type CLICK) resets everything on downgrade. It now decodes field by field, defaulting only a missing or unreadable field

Out of scope for the first build

Band-limited noise, 1/3-octave noise, sweeps, square wave, any analysis/metering, any recording/capture.

Addendum: pink noise modes

Implemented via issue #17 and its child tickets on the second wayfinder map, resolving that map's one open product question: band-limited and 1/3-octave noise are sub-modes of the existing Pink signal type, not new top-level signal-selector entries (5 fixed presets + 31 ISO bands as top-level segments would have overwhelmed the segmented signal-type selector).

  • Mode selector: a second segmented control — Full-range / Band-limited / 1/3-Octave — sits directly under the on/off switch, visible only when Pink is selected (reserved/hidden otherwise, same "always reserve, never remove" principle as the frequency field). Switching mode while Pink is running and playing applies live and does not force a full stop — only a signal-type switch does that (ADR 0003 is unchanged; mode is a parameter of Pink, not a different signal).
  • One shared slot, three uses: the reserved slot just above Level (Sine's frequency field) is a single control position, not three parallel reserved rows — it renders exactly one of Sine's Frequency field, Pink 1/3-Octave's Frequency field, or Pink Band-limited's Range picker at a time, switching content rather than toggling opacity on three separate views.
  • Band-limited: that shared slot shows a "Range" preset picker (0–200Hz, 200Hz–1kHz, 1k–20kHz, 7k–20kHz, or Manual). Manual reveals two numeric fields (low/high Hz, 20Hz–20kHz bounds, low < high enforced) in their own separate reserved row below, using the same text-field style as Frequency/Level — but unlike those fields, manual-range edits only commit on Return or on losing focus (blur), not per keystroke, to avoid audibly hot-swapping a running filter's coefficients while typing. Each preset is realized as an optional highpass edge and/or optional lowpass edge (0–200Hz = lowpass-only; 200Hz–1kHz = highpass+lowpass; 1k–20kHz and 7k–20kHz = highpass-only), each edge a 4th-order (2-section) Butterworth Biquad cascade.
  • 1/3-Octave: that shared slot shows the same "Frequency" TextField/Hz/chevrons control Sine uses — literally the same visual component, not a near-duplicate. Chevrons step via the same ThirdOctaveBands.step(from:direction:) used for Sine. Free-text typing is also allowed (unlike Sine, where typed values don't snap), but — same real-time-safety reasoning as the manual band fields — a typed value only commits, and snaps to the nearest ISO 266 band, on Return/blur, not per keystroke; the field's own draft state is kept separate from Sine's persisted frequencyHz so typing a band value here can't overwrite Sine's saved frequency. Realized as a two-edge, 16th-order (8-section per edge) Butterworth ThirdOctaveFilterChain — a highpass edge at centerHz / 2^(1/6) cascaded with a lowpass edge at centerHz * 2^(1/6), the standard ISO 1/3-octave band boundaries — matching REW's own cascaded-Butterworth approach for fractional-octave noise, at a higher order than REW's own ceiling since it measured strictly flatter here. See docs/research/one-third-octave-noise-generation.md's "Two-edge Butterworth cascade" addendum for the measurements behind this and why an earlier single fixed-Q bandpass biquad was replaced.
  • Persistence: the selected mode, band-limited preset (including a manual range), and 1/3-octave band index all persist across relaunch, same global (not per-device) treatment as frequency/level.
  • Architecture: PinkNoiseGenerator reads RenderParameters.pinkNoiseMode and rebuilds its filter chain only when the mode actually changes (never per sample) — see docs/research/band-limited-noise-generation.md and docs/research/one-third-octave-noise-generation.md for why a single shared Biquad type is reused by both modes rather than a single "band-limiting" abstraction. The mode type (NoiseMode, shared with White noise as of the addendum below) carries associated values (.bandLimited(BandLimitedPreset), .thirdOctave(bandIndex:)), which meant it couldn't stay CaseIterable/segmented-picker-friendly on its own — ContentView tracks UI-facing state (NoiseModeDraft) and composes/decomposes the real NoiseMode via resolved/init(resolving:).
  • Level compensation: both filtered sub-modes apply a makeup gain after filtering so levelDbfs reflects the actual (post-filter) output level, not pink noise's pre-filter amplitude — narrowing bandwidth otherwise discards most of a 1/f-PSD signal's energy, since pink noise is equal-energy-per-octave. BandLimitedFilterChain computes this per preset from its edges (levelCompensationGain); 1/3-Octave uses a single fixed constant (thirdOctaveLevelCompensationGain, always exactly 1/3 octave wide). See the "Level compensation" addenda in both research docs for the full rationale and a known peak-headroom caveat this doesn't attempt to fix.

Addendum: White noise modes

White noise gained the same three sub-modes Pink already had (issues #37/#38), reusing every piece of Pink's UI and filter machinery rather than duplicating it:

  • Shared mode type: PinkNoiseMode was generalized and renamed to NoiseMode — the same type now drives both colors' sub-mode selection, but RenderParameters/SettingsSnapshot each carry two fields (pinkNoiseMode, whiteNoiseMode), so Pink's and White's selections persist and apply live independently — switching White to 1/3-Octave doesn't touch Pink's Full-range selection, and vice versa.
  • Shared UI, per-color state: the mode selector, Range picker, and manual/1/3-octave fields are the same views for both colors (visible whenever the signal type is "noise-family" — Pink or White), bound to whichever of two NoiseModeDraft instances (pinkDraft, whiteDraft) matches the current signal type. The transient per-keystroke draft text for manual-range/1/3-octave typing stays shared (not duplicated per color), since only one color's fields are ever visible at once and they're re-seeded from the active color's committed values on every signal-type switch.
  • Different level-compensation math: White's power spectral density is flat (equal energy per Hz), unlike Pink's 1/f (equal energy per octave) — reusing Pink's octave-span compensation formula on White would be audibly wrong. BandLimitedFilterChain gained a NoiseSpectralShape parameter (.pinkOneOverF/.whiteFlat) selecting the correct formula; White's 1/3-Octave compensation is computed per band (a function of center frequency) rather than as one fixed constant, since a band's width in Hz — unlike its width in octaves — isn't constant across the 31 ISO bands. See docs/research/white-noise-band-limiting.md for the full derivation.
  • A correctness detail worth knowing: PinkNoiseGenerator holds its own internal WhiteNoiseGenerator to drive its Kellett filter cascade with raw white noise. WhiteNoiseGenerator splits its pure PRNG draw (rawSample()) from its mode-dispatching public interface (nextSample) specifically so Pink's internal noise source stays unaffected by RenderParameters.whiteNoiseMode — without that split, Pink's tone would silently change character whenever White's band-limited mode happened to be non-full-range, even while the user was listening to Pink, not White.

Addendum: sine sweep

Decided via a grilling session on issue #15 on the second wayfinder map.

  • Curve: logarithmic only — no linear mode, no user-selectable curve. A log sweep spends equal time per octave rather than per Hz, which is both the standard choice for acoustic test sweeps and the right fit for a tool whose job is checking a speaker's response across the audible range.
  • Range: fixed at the app's existing 20Hz–20kHz bounds — no configurable start/end frequency.
  • Duration control: a free numeric field in seconds (same interaction pattern as the level field — arrow/button stepping by whole seconds, free-text entry for decimals), range 1–60s, default 10s. Occupies the same reserved slot the frequency field uses today (hidden/disabled for Sine/Pink/White, visible/enabled for Sweep) — see "Frequency control reflow on signal-type switch" in the Decisions log, now generalized to "signal-type-specific control slot" rather than sine-only.
  • Playback: continuous loop (low→high, then instantly repeats) until the user presses OFF — no one-shot mode, no direction option (always low→high). Keeps the same on/off mental model as every other signal type: ON means sound continues until explicitly turned OFF.
  • Loop wrap-point: instant jump back to 20Hz, no fade/mute around the wrap. Sweeping only changes frequency, not amplitude, so there's no waveform discontinuity to guard against — the abrupt pitch drop is the expected, self-evident sound of the sweep restarting.
  • No live frequency readout during the sweep — a continuously-updating numeric display would need to refresh far faster than any other readout in the app and isn't actionable mid-sweep.
  • OFF then ON: always restarts fresh from 20Hz. Sweep position is not preserved across a stop — it's pure audio-thread-only ephemeral state (no SettingsStore interaction), reset via the generator's new reset() hook (see ADR 0004) at the same instant the render block already detects rampGain == 0.
  • Architecture: the SignalGenerator protocol widened to receive the full parameter snapshot (not just frequency/sample rate) so Sweep can read its configured duration, and gained a reset() lifecycle method — see ADR 0004.

Addendum: square wave

Decided via a grilling session on issue #16 on the second wayfinder map.

  • Duty cycle: fixed 50% — no adjustable duty cycle control. "Square wave," not a general pulse-wave generator.
  • Anti-aliasing: band-limited at generation time via PolyBLEP (polynomial band-limited step correction applied in a narrow window around each of the waveform's two discontinuities per cycle). A naive sign(sin(phase)) square wave's infinite odd-harmonic series aliases at the moment it's sampled — harmonics above Nyquist fold back into the audible range as wrong frequencies baked into the discrete signal, which a filter applied afterward cannot undo. This is why the RBJ biquad filters from the band-limited-noise research (docs/research/band-limited-noise-generation.md) don't apply here: those shape an already-generated noise signal's spectral envelope, a different problem from preventing aliasing during generation of a deterministic waveform. Additive/Fourier synthesis (summing harmonics up to Nyquist) is exactly band-limited but too costly at low fundamentals (1000+ harmonics per sample at 20Hz); oversample+filter+decimate works but adds disproportionate complexity (resampling stage, filter design, latency) for a single test waveform. PolyBLEP is O(1) per sample, fits the existing phase-accumulator style SineGenerator already uses, and is the standard real-time technique for this (Välimäki & Huovilainen's antialiasing-oscillator work).
  • UI: Frequency and Level fields behave for Square exactly as they do for Sine (same 20Hz–20kHz range, same whole-Hz display rule, same 1/3-octave arrow-stepping) — Square falls through to the same shared reserved slot's Frequency occupant Sine already renders there, not a hidden/disabled slot like Pink Full-range/White get. No new UI surface: no new control is introduced for Square, it just reuses Sine's existing Frequency field in that slot.
  • Architecture: implements SignalGenerator in its narrowest form — frequency + sample rate only, no-op reset() — the same shape as SineGenerator. Note: ADR 0004's protocol widening partly anticipated square wave needing the fuller parameter access for an adjustable duty cycle; since duty cycle ended up fixed, square wave doesn't end up exercising that widened surface. The widening was still the right call on Sweep's own merits — this is a note so the gap between "anticipated" and "actual" doesn't read as an oversight later, not a reason to revisit either decision.

Addendum: click generator

Decided via a grilling session, tracked as issue #50. Deferred follow-ups: a sub/mains tone-burst sub-mode (#48) and a per-channel "channel walk" (#49).

  • Purpose: checking inter-speaker delay settings by ear. The same click goes to every unmuted channel (the existing routing — no per-channel timing, mute/Ø unchanged); at the listening position, correctly aligned speakers produce one tight click, misaligned ones a "flam" (double click), and the user adjusts the processor's delays until it collapses. A clean impulse also works with a measurement mic, but that's incidental — SoundCheck stays a generator.
  • Waveform: a positive-going raised-cosine (Hann) pulse, 0.2ms wide, sampled from the same continuous shape at every sample rate and centered on a sample so its peak is exactly full level. Broadband and sharp-onset (a flam is very audible), smooth enough not to alias meaningfully, and polarity-defined (works with a polarity checker together with Ø). No shape selector. A single-sample Dirac impulse was rejected (sample-rate-dependent energy, harsh, extreme crest factor); a windowed tone burst is deferred (#48).
  • Level: the pulse's peak in dBFS — see the decisions log.
  • Interval: time between clicks, 0.2–3.0s, default 1.0s, always displayed with one decimal. Occupies the shared slot (a new FrequencySlotContent.interval), following Sweep Duration's pattern: ←/→ and −/+ steppers step 0.1s live; typed text commits on Return/blur, snapped to 0.1s and clamped (ClickInterval.committed), so the readout never leaves the arrows' 0.1s grid. Persisted globally, like Sweep's duration.
  • First click after ON: ~20ms after ON — after the 15ms start ramp completes — so the first click plays at full level instead of being swallowed by the fade-in. Primed entirely inside ClickGenerator.reset() (called at silence on every ON, ADR 0004); the render loop and its shared ramp are unchanged.
  • Live interval change: the next click fires at last click + new interval, or immediately if that moment has already passed. No stop, no burst from repeated arrow presses.
  • OFF: the normal 15ms fade; catching a pulse mid-fade is harmless.
  • UI: a sixth signal-type segment, CLICK, last, shortcut ⌥C. No per-click visual flash — it would add the first audio→UI data path, would lag the audio anyway, and can't help an ear-based check (same reasoning as Sweep's "no live frequency readout"). The sixth segment is why the window widened to 540pt.
  • Architecture: GeneratorKind.click, ClickGenerator (narrow SignalGenerator + the existing reset() hook), and clickIntervalSeconds through RenderParameters/SignalSettings/SettingsSnapshot. No ADR: no protocol, routing, or render-loop change.