# 🎚️ Car EQ Tuner

A zero-install web app that measures a car stereo's frequency response, learns
what each of its EQ knobs actually does, and recommends settings to reach a
target sound (flat, Harman-ish, V-shape, …).

It runs entirely in the browser — no build step, no server, no uploads. Open
`index.html` and go. On a phone it plays a sweep out the aux/Bluetooth and
records it with the built-in mic; a **Demo mode** runs the whole pipeline
against a simulated stereo so you can try it (and so the algorithms can be
tested) with no hardware.

## Why this works with a cheap phone mic

Two ideas do the heavy lifting:

1. **Reference subtraction.** We play a known exponential sine sweep and compute
   the recorded spectrum *minus* the spectrum of the exact signal we generated.
   That cancels the sweep's own spectral shape, leaving the system response
   (speaker + cabin + mic). Because we compare magnitude spectra over the whole
   recording, playback/recording alignment doesn't matter — magnitude is
   time-shift invariant.

2. **Band detection is a *difference*.** To learn a knob, we measure once at
   flat and once with just that knob boosted, and subtract. The speaker, cabin,
   and — crucially — the mic's own coloration all cancel in that difference, so
   an uncalibrated phone mic still gives an accurate *shape* for each band. From
   the difference curve we read off the band's center frequency, bandwidth, and
   dB-per-step.

## Measurement modes

- **Demo** — runs the whole pipeline against a simulated stereo. No hardware.
- **One device (live)** — the same device plays the sweep (aux / Bluetooth /
  CarPlay) and records with a mic you pick.
- **Two device (listen)** — this device only *listens* with its built-in mic;
  a second device connected to the car opens `player.html` and plays the sweep.
  Because the analysis is magnitude-only against a known reference, playback
  and recording don't need to be on the same device or time-aligned at all —
  the pipeline test proves a delayed, attenuated, silence-padded capture
  analyzes identically.

### CarPlay / Bluetooth mic routing

When CarPlay or Bluetooth hands-free is connected, iOS may route microphone
input to the **car's** mic the moment a page opens the mic — which ruins the
measurement (the car mic is nowhere near your ears, and hands-free mode can
also degrade output quality). Two defenses:

1. **A persistent audio session.** The app opens the mic + output *once* per
   calibration and holds them with a silent keep-alive, so iOS negotiates the
   route a single time instead of on every measurement — and nothing audible
   plays until a sweep actually runs, even while you're changing settings
   between measurements. The session card shows the *active input device by
   name* and warns if it looks like a car/Bluetooth mic; use the picker to pin
   the phone's built-in mic.
2. **Two-device mode** as the guaranteed fallback. A web page cannot fully
   control iOS audio routing (that's an `AVAudioSession` capability reserved
   for native apps). If the car keeps stealing the mic, put the player page on
   the car-connected device — it never touches a microphone, so it can't
   trigger hands-free mode — and listen on any other device.

## The flow

1. **Setup** — pick a mode, start the audio session (mic modes), add one band
   per knob on your head unit (or a preset), and choose a target curve.
2. **Baseline** — set every band to 0 and measure. This is your car's response
   with EQ flat.
3. **Detect bands** — for each band, set just that knob to max and measure. The
   app characterizes the band from the difference against baseline.
4. **Recommend** — the app solves for the knob settings that push your response
   closest to the target, respecting each knob's real range and step size, and
   overlays baseline / target / predicted so you can see the result.

## The recommendation math

Each detected band `k` has a measured per-step effect `eₖ(f)` (dB on a shared
log-frequency grid). Predicted response is:

```
predicted(f) = baseline(f) + Σ xₖ · eₖ(f)
```

We minimize `Σ (predicted(f) − target(f))²` (de-meaned, since overall level is
just volume) via the normal equations for a small least-squares system, then
clamp/round each `xₖ` to the knob's range and step and run a few
coordinate-descent passes so the bands compensate for each other after rounding.

## Project layout

```
index.html            entry point
player.html           sweep-only player for two-device mode
css/styles.css        styling
js/
  audio/
    fft.js            radix-2 FFT + magnitude spectrum (zero-padded)
    analyzer.js       fractional-octave smoothing, shared frequency grid
    sweep.js          exponential sine sweep synthesis
    engine.js         persistent audio session: mic capture + on-demand sweeps
  core/
    measurement.js    one API over live vs. demo backends
    bands.js          band model + detection algorithm
    recommend.js      least-squares EQ solver
    targets.js        target curves (flat / Harman / V-shape)
  sim/
    virtualStereo.js  synthetic stereo for Demo mode + tests
  ui/chart.js         canvas response plot
  state.js            observable store + localStorage
  app.js              step-machine UI controller
test/
  pipeline.test.mjs   headless end-to-end validation
```

## Running

Just open `index.html` in a browser. Live mode needs microphone permission and
is served over `http://localhost` or `https://` (browsers block mic on
`file://`); Demo mode works anywhere. A tiny static server is enough:

```bash
python3 -m http.server 8000    # then visit http://localhost:8000
```

## Deploying

It's a static site, so any static host works and all of these give automatic
HTTPS (required for Live mic mode — Demo mode works over plain HTTP too):

**Coolify (Dockerfile — recommended)**
1. New Resource → your repo → build pack **Dockerfile**.
2. Set **Base Directory** to `/car-eq` (the app lives in a subfolder).
3. Port **80**. Attach a domain; Coolify's Traefik provisions the TLS cert.

The included `Dockerfile` just serves the folder with nginx — no build step.

**Coolify (no Dockerfile alternative)** — build pack **Static**, Base Directory
`/car-eq`, publish directory `.`.

**Cloudflare Pages / Netlify / GitHub Pages** — no build command, output/publish
directory `car-eq`. All free and HTTPS by default.

## Tests

```bash
node test/pipeline.test.mjs
```

This drives the exact detect→recommend flow the UI uses against the virtual
stereo and asserts that detection recovers the ground-truth band centers and the
recommender reduces deviation from every target.

## Troubleshooting live measurements

The session start includes a **mic self-test**: it verifies real audio samples
are arriving before reporting the session as running (falling back from
AudioWorklet to ScriptProcessor if needed, and recreating the AudioContext when
iOS reports a mic hardware rate that doesn't match — a mismatch makes iOS
deliver pure silence). Every measurement additionally validates that the
capture is neither empty nor digital silence, and a band probe that shows no
change from baseline is rejected with an explanation instead of producing a
bogus profile. If you see one of these errors:

- **"never delivered audio" / "pure digital silence"** — check the site's mic
  permission (iOS: Settings → Apps → Safari → Microphone, plus the per-site
  prompt), close apps holding the mic, and restart the session.
- **"no clear change from the baseline"** — the sweep didn't play (wrong
  device/output, volume at zero) or the wrong knob was boosted. In two-device
  mode, make sure you tapped Play on the player during the recording window.
- **Input warning naming a car/Bluetooth mic, or a low sample rate** — iOS
  routed you to hands-free mode; pick the built-in mic or switch to two-device
  mode.

## Live-mode caveats

- Phone mics roll off in the deep bass and can be uneven in the top octave, so
  the *absolute* baseline curve is approximate there. Band *detection* is
  unaffected (it's a difference), and support for loading a mic calibration file
  is a natural next step.
- Keep the volume identical between the baseline and every band probe, and keep
  the cabin quiet. Very loud levels may push the speaker into distortion and
  skew the measurement.
- Bluetooth/DSP "loudness" or "auto-volume" features on some head units apply
  their own dynamic EQ; disable them while measuring.
- Bluetooth hands-free (HFP) caps audio at ~8 kHz. If iOS drops into hands-free
  mode while measuring (one-device mode with Bluetooth audio), everything above
  that is meaningless — the session card's input warning is your tell; switch
  to two-device mode.
