# 🎚️ Car EQ Tuner

This web app measures the frequency response of a car stereo. It learns what
each EQ knob of the stereo does. It then recommends the settings that give a
target sound (flat, Harman-ish, or V-shape).

`CONTEXT.md` holds the glossary. The terms Knob, Band, Setting, Offset,
Default, Baseline, Response, Effect, Target, Stage, and Mode have exact
meanings there. This README uses them.

The app runs fully in the browser. It sends no data to a server. On a phone,
the app plays a sweep through the aux, Bluetooth, or CarPlay output. The app
records the sweep with the built-in microphone. **Demo mode** runs the full
pipeline against a simulated stereo. With Demo mode, you can try the app with
no hardware. The footer link turns Demo mode on and off. The tests also use
it.

## Why a low-cost phone microphone is sufficient

Two ideas make this possible:

1. **Reference subtraction.** The app plays a known exponential sine sweep.
   The app subtracts the spectrum of the known signal from the spectrum of
   the recording. The subtraction removes the spectral shape of the sweep.
   The remainder is the system response: the speaker, the cabin, and the
   microphone. The app compares magnitude spectra across the full recording.
   A time shift does not change a magnitude spectrum. Thus alignment of
   playback and recording is not important.

2. **Band detection is a difference.** To learn a Knob, the app measures two
   times: one time with each Knob at its Default, and one time with only that
   Knob at its maximum. The app subtracts the two curves. The coloration of
   the speaker, the cabin, and the microphone is equal in the two
   measurements. Thus the coloration is removed in the difference. A phone
   microphone without correction gives an accurate shape for each Band. The
   difference curve shows the center frequency, the bandwidth, and the dB for
   each Step of Offset.

## Measurement modes

The Mode is the device count. The source strip selects it.

- **One device** — One device plays the sweep (aux, Bluetooth, or CarPlay)
  and records with the microphone that you select.
- **Two device** — This device only listens with its built-in microphone. A
  second device, connected to the car, opens `player.html` and plays the
  sweep. The analysis uses magnitudes only, against a known reference. Thus
  playback and recording do not need the same device or a time alignment. The
  pipeline test shows that a delayed, attenuated capture with added silence
  gives the same analysis.

**Demo mode** is not on the source strip, because it measures no car. The
footer link turns it on and off, and `?mode=demo` opens the app directly in
it. Demo mode runs the full pipeline against a simulated stereo. No hardware
is necessary.

### CarPlay and Bluetooth microphone routing

When CarPlay or Bluetooth hands-free is connected, iOS can route the
microphone input to the microphone of the car when a page opens the
microphone. That is bad for the measurement. The microphone of the car is
not near your ears. Hands-free mode can also decrease the output quality.
The app has two defenses:

1. **A persistent audio session.** The app opens the microphone and the
   output one time for each Calibration. A silent keep-alive signal holds
   them. Thus iOS negotiates the route one time, not for each measurement.
   The app makes no sound until a sweep plays, also while you change
   settings between measurements. The session card shows the name of the
   active input device. The card shows a warning if the name points to a car
   or Bluetooth microphone. Use the selector to set the built-in microphone
   of the phone.
2. **Two-device mode** is the fallback that always works. A web page cannot
   fully control the iOS audio routes. That is an `AVAudioSession` function
   for native apps only. If the car continues to take the microphone, open
   the player page on the device that is connected to the car. The player
   page never opens a microphone. Thus it cannot start hands-free mode.
   Listen on a different device.

## The flow

The app has four Stages.

1. **Setup** — Select a Mode. Start the audio session (microphone Modes
   only). Add one Knob for each control on your head unit, or use a preset.
   Select a Target.
2. **Baseline** — Set each Knob to its Default and measure. This is the
   Response of your car with a flat EQ. The Default is the middle of the
   range of the Knob, which is 0 on almost every head unit.
3. **Detect bands** — For each Knob: set only that Knob to its maximum, and
   measure. The app learns the Band from the difference against the Baseline.
4. **Recommend** — The app solves for the Settings that move your Response as
   near to the Target as possible. The app obeys the real range and the Step
   size of each Knob. A chart shows the Baseline, the Target, and the
   predicted Response.

## The recommendation math

Each detected Band `k` has a measured Effect, `eₖ(f)`, in dB for one Step of
Offset, on a shared log-frequency grid. The predicted Response is:

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

Here `xₖ` is the Offset of Knob `k`, not its Setting. The Baseline already
holds each Knob at its Default, so an Offset of 0 adds nothing.

The app minimizes `Σ (predicted(f) − target(f))²` with the mean removed,
because the overall level is the volume, not the EQ. The app uses the normal
equations for a small least-squares system. The app then clamps and rounds
each `xₖ` to the range and the Step of the Knob. Some coordinate-descent
passes follow. With them, the Knobs compensate for each other after the
rounding. The app converts each Offset to a Setting one time, at the end,
because the head unit shows Settings.

## Project layout

```
CONTEXT.md             the glossary of the domain
index.html             the entry point
player.html            the sweep-only player for two-device mode
css/styles.css         the styles
src/
  audio/
    fft.ts             the radix-2 FFT and the magnitude spectrum (zero-padded)
    analyzer.ts        fractional-octave smoothing and the shared frequency grid
    sweep.ts           exponential sine sweep synthesis
    engine.ts          the persistent audio session: microphone capture and sweeps
  core/
    calibration.ts     the Calibration: the Stage chart, the intents, storage
    measurement.ts     one API over the microphone and demo backends
    knobs.ts           the Knob model and the Band detection algorithm
    recommend.ts       the least-squares EQ solver
    targets.ts         the Target curves (flat, Harman, V-shape)
  sim/
    virtualStereo.ts   the synthetic stereo for Demo mode and the tests
  ui/chart.ts          the canvas response plot
  app.ts               the UI controller: it renders the Calibration
test/
  pipeline.test.ts     end-to-end validation without a browser
  browser-smoke.ts     a click-through smoke test in a real browser
  state-recovery.test.ts  regression tests for saved-state recovery
js/                    the compiled output of `npm run build` (not in git)
```

## Tooling

- **TypeScript** in the strictest configuration. `npm run typecheck` checks
  the sources and the tests. `npm run build` compiles `src/` to `js/`.
- **oxlint** lints (`npm run lint`). **oxfmt** formats (`npm run fmt`).
- **lefthook** runs the format, lint, and typecheck jobs before each commit
  (see `lefthook.yml`).
- Node 24 or later runs the TypeScript tests directly. No compilation is
  necessary for `npm test`.

## Run the app

```bash
npm install
npm run serve    # compiles, then serves http://localhost:8000
```

The microphone Modes need microphone permission. Serve the app on
`http://localhost` or on `https://`. Browsers block the microphone on
`file://`. Demo mode works everywhere.

## Deployment

The compiled app is a static site. Each static host with HTTPS is
sufficient. The microphone Modes require HTTPS. Demo mode also works on
plain HTTP.

**Coolify (Dockerfile — recommended)**

1. New Resource → your repository → build pack **Dockerfile**.
2. Set the port to **80**. Attach a domain. Coolify's Traefik gets the TLS
   certificate.

The `Dockerfile` has two stages. The first stage compiles the TypeScript.
The second stage serves the files with nginx.

**Cloudflare Pages, Netlify, or GitHub Pages** — Set the build command to
`npm run build`. Publish the repository root. These services are free and
give HTTPS by default.

## Tests

```bash
npm test              # the headless pipeline test (no browser)
npm run smoke         # a click-through test in a real browser (Playwright)
npm run test:state    # saved-state recovery tests (Playwright)
```

The pipeline test does the same detect-and-recommend flow as the UI, against
the virtual stereo. It makes sure that detection finds the reference Band
centers. It also makes sure that the recommender decreases the deviation for
each Target. It also makes sure that a Knob whose Default is not 0 finds the
same Effect as a symmetric Knob. The Playwright tests need a browser: run
`npx playwright install chromium` one time.

## Troubleshoot microphone measurements

The session start includes a **microphone self-test**. The test makes sure
that real audio samples arrive before the app shows the session as running.
If necessary, the app falls back from the AudioWorklet to a ScriptProcessor.
If iOS reports a microphone rate that does not agree with the context, the
app makes the AudioContext again with the correct rate. A rate mismatch
makes iOS deliver pure silence. Each measurement also makes sure that the
capture is not empty and not digital silence. A probe that shows no change
from the Baseline causes an error with an explanation. It does not make an
incorrect Band. If you see one of these errors:

- **"never delivered audio" or "pure digital silence"** — Examine the
  microphone permission of the site (iOS: Settings → Apps → Safari →
  Microphone, and the prompt for the site). Close apps that hold the
  microphone. Restart the session.
- **"no clear change from the baseline"** — The sweep did not play (a wrong
  device or output, or the volume at zero), or the wrong Knob was boosted. In two-device mode, make sure that you started Play on the
  player during the recording window.
- **An input warning that names a car or Bluetooth microphone, or a low
  sample rate** — iOS routed you to hands-free mode. Select the built-in
  microphone, or change to two-device mode.

## Microphone-mode limits

- Phone microphones lose level in the deep bass. They can also be uneven in
  the top octave. Thus the absolute Baseline is approximate there. Band
  detection is not affected, because it is a difference. Support for a
  microphone correction file is a possible next step.
- Keep the volume equal between the Baseline and each probe. Keep the cabin
  quiet. Very loud levels can push the speaker into distortion and can change
  the measurement.
- Some head units have "loudness" or "auto-volume" functions with their own
  dynamic EQ. Set these functions to off while you measure.
- Bluetooth hands-free (HFP) limits audio to approximately 8 kHz. If iOS
  goes into hands-free mode while you measure (one-device mode with
  Bluetooth audio), everything above that limit has no meaning. The input
  warning on the session card shows this condition. Change to two-device
  mode.
