Vibecode eqMac
track this build4 phases, 12 steps, beginner friendly0%The DSP is the easy part: a cascade of biquad filters driven by sliders is textbook, and an agent will write it correctly on the first try. The hard part is getting system audio into your process at all, which on macOS means a virtual output device and a Core Audio server plugin, signed and notarized, plus graceful handling of sample rate changes, device hotplug and headphone unplug. You can dodge most of that by installing BlackHole and building a loopback app that pulls from it and pushes to your real output, which is a genuine weekend project and works fine on your own machine. What you will not match in one sitting is the invisible-ness: no driver install prompts, no latency you can hear, no manual switching every time you plug in AirPods. Good build, mediocre replacement for something you want to forget exists.
You are building a lean indie version of eqMac. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # eqMac · indie build A system-wide audio processor whose difficulty is almost entirely in the platform layer: a virtual output device that survives device changes and sleep, a processing chain that must never add audible latency or artefacts, and a failure mode where a bug means the user's machine has no sound at all. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Capture | A user-space audio server plugin presenting a virtual output device | altering system output requires being in the output path, which a tap is not | | Processing | A biquad filter chain in a real-time safe callback | the audio callback cannot allocate, lock or log without producing clicks | | Interface | A menu bar application communicating with the driver out of band | the interface must never block the audio thread | | Routing | Device selection and follow-the-default behaviour | headphones being unplugged is the normal case, not an edge case | | Safety | A hardware bypass path and an uninstaller that restores the real device | a broken build must not leave a machine silent | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14.2 or newer** · free if you own one - Why: Core Audio taps require 14.2 and the audio server plugin interfaces have changed over recent versions. Older systems need a different approach entirely. - Get it: Check the version before starting and decide the minimum you will support. - Verify: The system version is 14.2 or higher and the version is written down - [ ] **Xcode and the command line tools** · free - Why: An audio server plugin is a native bundle and cannot be built without them. - Get it: Install from the App Store, then the command line tools. - Verify: A sample audio server plugin builds and loads - [ ] **A developer account for signing and notarisation** · an annual fee - Why: An unsigned driver does not load on a current system, and the symptom is the device simply never appearing. - Get it: Enrol, create the certificates, and notarise a trivial build before writing the real driver. - Verify: A trivial signed and notarised bundle loads on a second machine - [ ] **A second machine or a spare user account** · free if you have one - Why: A broken audio driver can leave a machine silent, and debugging that on the machine you are working on is miserable. - Get it: A second Mac, a virtual machine, or at minimum a separate user account and a known-good recovery path. - Verify: You can install, break and recover audio without losing your working environment - [ ] **A way to record and inspect the output** · free - Why: Every claim in this build, from bit-identical passthrough to the absence of clicks, is verified by recording rather than by listening. - Get it: Any audio editor that shows a waveform and a spectrum, plus a loopback path to record your own output. - Verify: You can record output and inspect the waveform at a transition ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. - A first-party, notarized audio driver: you are borrowing someone else's virtual device instead - Automatic output-device following, so plugging in headphones means switching things by hand - Very low latency and rock-solid handling of sample rate mismatches and hotplug events - Extras like per-device presets, balance control, and a curated preset library - Updates, crash fixes, and someone else's problem when a macOS point release breaks audio If one of those is essential to you, that is the reason to keep paying for eqMac, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · eqMac The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a system-wide equaliser and audio router that sits in the menu bar to replace eqMac. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one is different from most builds on this site: the hard part is not your code, it is that the operating system does not want you to do this. macOS gives you two separate things and it matters which you need. Core Audio process taps, available since macOS 14.2, let an application capture a copy of what another process or the whole system is playing, and they need audio capture permission that there is still no public API to request or check. Taps let you listen. They do not let you change what comes out of the speakers. To actually alter system output you need a virtual output device the user selects as their output, implemented as a user-space audio server plugin, since kernel extensions are deprecated. Your plugin receives the audio, processes it, and forwards it to the real device. That means an installer, a system extension approval, and a device that must keep working when the user unplugs their headphones. Decide which of the two you need before writing anything. Building a tap and then discovering it cannot change the sound is a week lost. ### Stack (fixed, do not substitute) - Capture: A user-space audio server plugin presenting a virtual output device · altering system output requires being in the output path, which a tap is not - Processing: A biquad filter chain in a real-time safe callback · the audio callback cannot allocate, lock or log without producing clicks - Interface: A menu bar application communicating with the driver out of band · the interface must never block the audio thread - Routing: Device selection and follow-the-default behaviour · headphones being unplugged is the normal case, not an edge case - Safety: A hardware bypass path and an uninstaller that restores the real device · a broken build must not leave a machine silent ### Data model (create this before Phase 1) - presets(id, name, bands_json, preamp_db, created_at) · a preset is a filter chain - bands(preset_id, ordinal, kind, frequency_hz, gain_db, q) · kind is peaking, shelf or pass - devices(uid, name, is_default, sample_rate, channels, last_seen_at) - routes(id, source_device_uid, target_device_uid, active) · what goes where - state(key, value) · current preset, bypass, volume. Read by the driver, written by the interface - The driver reads coefficients, never the model. Filter design happens in the interface and only coefficients cross into the audio thread. ### Phase 1 · A virtual device the system will actually use Build: A user-space audio server plugin presenting an output device. Pass audio through untouched to the real output. Handle the sample rate the system asks for rather than assuming one. Ship an uninstaller from day one. Trap: A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. Done when: The device appears in sound settings and passing audio through it is bit-identical to the real output, verified by recording both. The system's requested sample rate is honoured, tested at two rates. Uninstalling restores the previous default device. Killing the interface application leaves audio still passing. Do not build yet: No processing, presets or routing. ### Phase 2 · Filters that do not click Build: A biquad filter chain evaluated in the audio callback. Design coefficients in the interface and hand them across without allocating in the callback. Ramp gain changes rather than applying them instantly. Trap: Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. Done when: A gain change produces no audible click, verified by recording and inspecting the waveform at the transition. The callback contains no allocation, lock or logging, verified by inspection and a real-time safety check. Round-trip latency is measured and stated. A sweep through the filter matches the designed response within tolerance. Do not build yet: No presets or routing yet. ### Phase 3 · Devices appearing and disappearing Build: Follow the system default output, handle devices arriving and leaving, and handle sleep and wake. Preserve the active preset across a device change. Recover if the target device disappears while audio is playing. Trap: Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. Done when: Unplugging the target device mid-playback re-targets to the new default without silence lasting more than a moment. Sleep and wake resume audio with the preset intact. A device reappearing is picked up. Rapidly connecting and disconnecting a device does not deadlock, tested by doing it twenty times. Do not build yet: No presets or per-application routing yet. ### Phase 4 · Presets worth switching between Build: Named presets with a preamp, switching without a gap in audio. A true bypass that removes processing entirely rather than flattening the bands. Automatic gain compensation so switching does not change perceived loudness dramatically. Trap: Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. Done when: Switching presets produces no gap or click. A preset whose combined gain would clip is compensated automatically or refused, tested with an extreme setting. True bypass is bit-identical to no processing, verified by recording. Preset switching under load does not affect the audio thread's deadline. Do not build yet: No per-application routing yet. ### Phase 5 · Routing, and shipping something installable Build: Route output to a chosen device, and optionally capture specific processes using a system tap for a per-application mix. Package the driver and application with the signing and notarisation the platform requires, and document the permission prompts. Trap: An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. Done when: The build installs on a machine that has never had it, with every permission prompt documented in advance. Routing to a chosen device works and survives that device disappearing. A per-application capture uses a tap and requests permission correctly. Uninstalling leaves no driver behind and restores the original default device. Do not build yet: Nothing after this. ### Out of scope (and why) - Cross-platform support, since the entire difficulty is platform-specific and none of this driver work transfers. - Advanced processing such as convolution, dynamics or room correction, which is a signal processing project on top of a working driver. - Per-application volume for arbitrary processes, which the platform makes considerably harder than it sounds. - Automatic updates for a system driver, which is genuinely risky to get wrong and is why commercial audio tools invest in it. ### README must contain - That taps capture audio and a virtual output device is required to change it, and which this build uses - That the audio callback never allocates, locks or logs, and why - That the uninstaller exists from the first phase because a broken audio device is hard to undo - That a clipping preset is compensated or refused rather than allowed to distort - That the driver must be signed and notarised or it silently will not appear ===== AGENTS.md ===== # Agent instructions · eqMac indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A user-space audio server plugin presenting a virtual output device, A biquad filter chain in a real-time safe callback, A menu bar application communicating with the driver out of band, Device selection and follow-the-default behaviour, A hardware bypass path and an uninstaller that restores the real device. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ===== BUILD_PLAN.md ===== # Build plan · eqMac A system-wide audio processor whose difficulty is almost entirely in the platform layer: a virtual output device that survives device changes and sleep, a processing chain that must never add audible latency or artefacts, and a failure mode where a bug means the user's machine has no sound at all. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A virtual device the system will actually use Your device appears in sound settings and audio passes through it unchanged. ### Steps 1. Present an output device from a server plugin User space, not a kernel extension. Kernel extensions are deprecated. Files: `driver/Device.cpp` 2. Pass through bit-identically first Prove the path before processing anything. Record both and compare. Files: `tests/passthrough.sh` 3. Write the uninstaller now Before the device is ever default. A broken audio device is hard to undo by hand. Files: `scripts/uninstall.sh` ### Done when - [ ] The device appears and passes audio bit-identically - [ ] Two sample rates are handled correctly - [ ] Uninstalling restores the previous default - [ ] Killing the interface leaves audio passing ### Watch out - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. ## Phase 2 · Filters that do not click Equalisation applies with no audible artefact and no added latency. ### Steps 1. Design coefficients outside the audio thread The callback consumes coefficients. It never designs filters. Files: `src/biquad.ts`, `driver/Filter.cpp` 2. Publish updates lock free Atomic swap of a coefficient block. No locks on the audio thread, ever. Files: `driver/State.cpp` 3. Ramp changes over a short window An instant gain change is a discontinuity and a discontinuity is a click. Files: `driver/Filter.cpp` ### Done when - [ ] A gain change produces no click in a recording - [ ] The callback has no allocation, lock or logging - [ ] Round-trip latency is measured and stated - [ ] A sweep matches the designed response ### Watch out - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. ## Phase 3 · Devices appearing and disappearing Unplugging headphones does not break audio or lose your settings. ### Steps 1. Watch for device changes Arrival, departure and default changes. All three are routine. Files: `driver/Devices.cpp` 2. Re-target rather than fail Losing the target device should cost a moment of audio, not the session. Files: `driver/Route.cpp` 3. Survive sleep and wake Test it repeatedly. It is where audio software reliably breaks. Files: `tests/sleep-wake.md` ### Done when - [ ] Unplugging mid-playback re-targets quickly - [ ] Sleep and wake resume with the preset intact - [ ] A reappearing device is picked up - [ ] Twenty rapid connect cycles do not deadlock ### Watch out - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. ## Phase 4 · Presets worth switching between Saved chains you can move between instantly, including a true bypass. ### Steps 1. Switch without a gap Crossfade or ramp. A gap between presets is as bad as a click. Files: `driver/Preset.cpp` 2. Compensate or refuse clipping Compute maximum chain gain. Digital clipping is worse than the problem. Files: `src/headroom.ts`, `src/headroom.test.ts` 3. Make bypass truly bypass Bit-identical to no processing, not a flat curve. Verify by recording. Files: `driver/Bypass.cpp` ### Done when - [ ] Preset switching has no gap or click - [ ] A clipping preset is compensated or refused - [ ] Bypass is bit-identical to no processing - [ ] Switching under load misses no deadline ### Watch out - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ## Not in this build - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. ## After v1, if you want it - A spectrum analyser fed from the driver, once the audio thread is provably safe - Per-device presets that switch automatically with the output - Loudness matching between presets, which makes comparing them honest ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Presets and routing, read by the interface. The driver reads only coefficients. STATE_PATH=~/Library/Application Support/eqmac/state.json # Required. Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. DEFAULT_PRESET=flat # Required. How long a gain change takes. Too short clicks, too long feels unresponsive. RAMP_MS=20 # Required. Whether to re-target when the system default changes, which is what unplugging headphones does. FOLLOW_SYSTEM_DEFAULT=true # Required. Written by the interface only. The audio callback never logs. LOG_PATH=~/Library/Logs/eqmac.log
You are building a lean indie version of eqMac. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # eqMac · indie build A system-wide audio processor whose difficulty is almost entirely in the platform layer: a virtual output device that survives device changes and sleep, a processing chain that must never add audible latency or artefacts, and a failure mode where a bug means the user's machine has no sound at all. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Capture | A user-space audio server plugin presenting a virtual output device | altering system output requires being in the output path, which a tap is not | | Processing | A biquad filter chain in a real-time safe callback | the audio callback cannot allocate, lock or log without producing clicks | | Interface | A menu bar application communicating with the driver out of band | the interface must never block the audio thread | | Routing | Device selection and follow-the-default behaviour | headphones being unplugged is the normal case, not an edge case | | Safety | A hardware bypass path and an uninstaller that restores the real device | a broken build must not leave a machine silent | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14.2 or newer** · free if you own one - Why: Core Audio taps require 14.2 and the audio server plugin interfaces have changed over recent versions. Older systems need a different approach entirely. - Get it: Check the version before starting and decide the minimum you will support. - Verify: The system version is 14.2 or higher and the version is written down - [ ] **Xcode and the command line tools** · free - Why: An audio server plugin is a native bundle and cannot be built without them. - Get it: Install from the App Store, then the command line tools. - Verify: A sample audio server plugin builds and loads - [ ] **A developer account for signing and notarisation** · an annual fee - Why: An unsigned driver does not load on a current system, and the symptom is the device simply never appearing. - Get it: Enrol, create the certificates, and notarise a trivial build before writing the real driver. - Verify: A trivial signed and notarised bundle loads on a second machine - [ ] **A second machine or a spare user account** · free if you have one - Why: A broken audio driver can leave a machine silent, and debugging that on the machine you are working on is miserable. - Get it: A second Mac, a virtual machine, or at minimum a separate user account and a known-good recovery path. - Verify: You can install, break and recover audio without losing your working environment - [ ] **A way to record and inspect the output** · free - Why: Every claim in this build, from bit-identical passthrough to the absence of clicks, is verified by recording rather than by listening. - Get it: Any audio editor that shows a waveform and a spectrum, plus a loopback path to record your own output. - Verify: You can record output and inspect the waveform at a transition ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. - A first-party, notarized audio driver: you are borrowing someone else's virtual device instead - Automatic output-device following, so plugging in headphones means switching things by hand - Very low latency and rock-solid handling of sample rate mismatches and hotplug events - Extras like per-device presets, balance control, and a curated preset library - Updates, crash fixes, and someone else's problem when a macOS point release breaks audio If one of those is essential to you, that is the reason to keep paying for eqMac, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · eqMac The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a system-wide equaliser and audio router that sits in the menu bar to replace eqMac. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one is different from most builds on this site: the hard part is not your code, it is that the operating system does not want you to do this. macOS gives you two separate things and it matters which you need. Core Audio process taps, available since macOS 14.2, let an application capture a copy of what another process or the whole system is playing, and they need audio capture permission that there is still no public API to request or check. Taps let you listen. They do not let you change what comes out of the speakers. To actually alter system output you need a virtual output device the user selects as their output, implemented as a user-space audio server plugin, since kernel extensions are deprecated. Your plugin receives the audio, processes it, and forwards it to the real device. That means an installer, a system extension approval, and a device that must keep working when the user unplugs their headphones. Decide which of the two you need before writing anything. Building a tap and then discovering it cannot change the sound is a week lost. ### Stack (fixed, do not substitute) - Capture: A user-space audio server plugin presenting a virtual output device · altering system output requires being in the output path, which a tap is not - Processing: A biquad filter chain in a real-time safe callback · the audio callback cannot allocate, lock or log without producing clicks - Interface: A menu bar application communicating with the driver out of band · the interface must never block the audio thread - Routing: Device selection and follow-the-default behaviour · headphones being unplugged is the normal case, not an edge case - Safety: A hardware bypass path and an uninstaller that restores the real device · a broken build must not leave a machine silent ### Data model (create this before Phase 1) - presets(id, name, bands_json, preamp_db, created_at) · a preset is a filter chain - bands(preset_id, ordinal, kind, frequency_hz, gain_db, q) · kind is peaking, shelf or pass - devices(uid, name, is_default, sample_rate, channels, last_seen_at) - routes(id, source_device_uid, target_device_uid, active) · what goes where - state(key, value) · current preset, bypass, volume. Read by the driver, written by the interface - The driver reads coefficients, never the model. Filter design happens in the interface and only coefficients cross into the audio thread. ### Phase 1 · A virtual device the system will actually use Build: A user-space audio server plugin presenting an output device. Pass audio through untouched to the real output. Handle the sample rate the system asks for rather than assuming one. Ship an uninstaller from day one. Trap: A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. Done when: The device appears in sound settings and passing audio through it is bit-identical to the real output, verified by recording both. The system's requested sample rate is honoured, tested at two rates. Uninstalling restores the previous default device. Killing the interface application leaves audio still passing. Do not build yet: No processing, presets or routing. ### Phase 2 · Filters that do not click Build: A biquad filter chain evaluated in the audio callback. Design coefficients in the interface and hand them across without allocating in the callback. Ramp gain changes rather than applying them instantly. Trap: Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. Done when: A gain change produces no audible click, verified by recording and inspecting the waveform at the transition. The callback contains no allocation, lock or logging, verified by inspection and a real-time safety check. Round-trip latency is measured and stated. A sweep through the filter matches the designed response within tolerance. Do not build yet: No presets or routing yet. ### Phase 3 · Devices appearing and disappearing Build: Follow the system default output, handle devices arriving and leaving, and handle sleep and wake. Preserve the active preset across a device change. Recover if the target device disappears while audio is playing. Trap: Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. Done when: Unplugging the target device mid-playback re-targets to the new default without silence lasting more than a moment. Sleep and wake resume audio with the preset intact. A device reappearing is picked up. Rapidly connecting and disconnecting a device does not deadlock, tested by doing it twenty times. Do not build yet: No presets or per-application routing yet. ### Phase 4 · Presets worth switching between Build: Named presets with a preamp, switching without a gap in audio. A true bypass that removes processing entirely rather than flattening the bands. Automatic gain compensation so switching does not change perceived loudness dramatically. Trap: Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. Done when: Switching presets produces no gap or click. A preset whose combined gain would clip is compensated automatically or refused, tested with an extreme setting. True bypass is bit-identical to no processing, verified by recording. Preset switching under load does not affect the audio thread's deadline. Do not build yet: No per-application routing yet. ### Phase 5 · Routing, and shipping something installable Build: Route output to a chosen device, and optionally capture specific processes using a system tap for a per-application mix. Package the driver and application with the signing and notarisation the platform requires, and document the permission prompts. Trap: An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. Done when: The build installs on a machine that has never had it, with every permission prompt documented in advance. Routing to a chosen device works and survives that device disappearing. A per-application capture uses a tap and requests permission correctly. Uninstalling leaves no driver behind and restores the original default device. Do not build yet: Nothing after this. ### Out of scope (and why) - Cross-platform support, since the entire difficulty is platform-specific and none of this driver work transfers. - Advanced processing such as convolution, dynamics or room correction, which is a signal processing project on top of a working driver. - Per-application volume for arbitrary processes, which the platform makes considerably harder than it sounds. - Automatic updates for a system driver, which is genuinely risky to get wrong and is why commercial audio tools invest in it. ### README must contain - That taps capture audio and a virtual output device is required to change it, and which this build uses - That the audio callback never allocates, locks or logs, and why - That the uninstaller exists from the first phase because a broken audio device is hard to undo - That a clipping preset is compensated or refused rather than allowed to distort - That the driver must be signed and notarised or it silently will not appear ===== AGENTS.md ===== # Agent instructions · eqMac indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A user-space audio server plugin presenting a virtual output device, A biquad filter chain in a real-time safe callback, A menu bar application communicating with the driver out of band, Device selection and follow-the-default behaviour, A hardware bypass path and an uninstaller that restores the real device. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ===== BUILD_PLAN.md ===== # Build plan · eqMac A system-wide audio processor whose difficulty is almost entirely in the platform layer: a virtual output device that survives device changes and sleep, a processing chain that must never add audible latency or artefacts, and a failure mode where a bug means the user's machine has no sound at all. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A virtual device the system will actually use Your device appears in sound settings and audio passes through it unchanged. ### Steps 1. Present an output device from a server plugin User space, not a kernel extension. Kernel extensions are deprecated. Files: `driver/Device.cpp` 2. Pass through bit-identically first Prove the path before processing anything. Record both and compare. Files: `tests/passthrough.sh` 3. Write the uninstaller now Before the device is ever default. A broken audio device is hard to undo by hand. Files: `scripts/uninstall.sh` ### Done when - [ ] The device appears and passes audio bit-identically - [ ] Two sample rates are handled correctly - [ ] Uninstalling restores the previous default - [ ] Killing the interface leaves audio passing ### Watch out - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. ## Phase 2 · Filters that do not click Equalisation applies with no audible artefact and no added latency. ### Steps 1. Design coefficients outside the audio thread The callback consumes coefficients. It never designs filters. Files: `src/biquad.ts`, `driver/Filter.cpp` 2. Publish updates lock free Atomic swap of a coefficient block. No locks on the audio thread, ever. Files: `driver/State.cpp` 3. Ramp changes over a short window An instant gain change is a discontinuity and a discontinuity is a click. Files: `driver/Filter.cpp` ### Done when - [ ] A gain change produces no click in a recording - [ ] The callback has no allocation, lock or logging - [ ] Round-trip latency is measured and stated - [ ] A sweep matches the designed response ### Watch out - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. ## Phase 3 · Devices appearing and disappearing Unplugging headphones does not break audio or lose your settings. ### Steps 1. Watch for device changes Arrival, departure and default changes. All three are routine. Files: `driver/Devices.cpp` 2. Re-target rather than fail Losing the target device should cost a moment of audio, not the session. Files: `driver/Route.cpp` 3. Survive sleep and wake Test it repeatedly. It is where audio software reliably breaks. Files: `tests/sleep-wake.md` ### Done when - [ ] Unplugging mid-playback re-targets quickly - [ ] Sleep and wake resume with the preset intact - [ ] A reappearing device is picked up - [ ] Twenty rapid connect cycles do not deadlock ### Watch out - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. ## Phase 4 · Presets worth switching between Saved chains you can move between instantly, including a true bypass. ### Steps 1. Switch without a gap Crossfade or ramp. A gap between presets is as bad as a click. Files: `driver/Preset.cpp` 2. Compensate or refuse clipping Compute maximum chain gain. Digital clipping is worse than the problem. Files: `src/headroom.ts`, `src/headroom.test.ts` 3. Make bypass truly bypass Bit-identical to no processing, not a flat curve. Verify by recording. Files: `driver/Bypass.cpp` ### Done when - [ ] Preset switching has no gap or click - [ ] A clipping preset is compensated or refused - [ ] Bypass is bit-identical to no processing - [ ] Switching under load misses no deadline ### Watch out - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ## Not in this build - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. ## After v1, if you want it - A spectrum analyser fed from the driver, once the audio thread is provably safe - Per-device presets that switch automatically with the output - Loudness matching between presets, which makes comparing them honest ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Presets and routing, read by the interface. The driver reads only coefficients. STATE_PATH=~/Library/Application Support/eqmac/state.json # Required. Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. DEFAULT_PRESET=flat # Required. How long a gain change takes. Too short clicks, too long feels unresponsive. RAMP_MS=20 # Required. Whether to re-target when the system default changes, which is what unplugging headphones does. FOLLOW_SYSTEM_DEFAULT=true # Required. Written by the interface only. The audio callback never logs. LOG_PATH=~/Library/Logs/eqmac.log
You are building a production product version of eqMac. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # eqMac · product brief ## Problem The DSP is the easy part: a cascade of biquad filters driven by sliders is textbook, and an agent will write it correctly on the first try. The hard part is getting system audio into your process at all, which on macOS means a virtual output device and a Core Audio server plugin, signed and notarized, plus graceful handling of sample rate changes, device hotplug and headphone unplug. You can dodge most of that by installing BlackHole and building a loopback app that pulls from it and pushes to your real output, which is a genuine weekend project and works fine on your own machine. What you will not match in one sitting is the invisible-ness: no driver install prompts, no latency you can hear, no manual switching every time you plug in AirPods. Good build, mediocre replacement for something you want to forget exists. ## Product outcome A system-wide equaliser that never clicks, never leaves the machine silent, survives headphones being unplugged, and can be completely removed. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS with Xcode and a Swift toolchain - BlackHole (or another virtual audio device) installed, e.g. via Homebrew - Willingness to set your system output to the virtual device and let the app do the routing - Microphone/audio permissions granted to the app - Basic tolerance for a few ms of added latency ## Explicit non-goals for v1 - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. - A first-party, notarized audio driver: you are borrowing someone else's virtual device instead - Automatic output-device following, so plugging in headphones means switching things by hand - Very low latency and rock-solid handling of sample rate mismatches and hotplug events - Extras like per-device presets, balance control, and a curated preset library - Updates, crash fixes, and someone else's problem when a macOS point release breaks audio ## Success criteria - Passthrough is confirmed bit-identical by recording - A gain change is confirmed to produce no click - Unplugging the target device is confirmed to re-target - A clipping preset is confirmed compensated or refused - A clean install and clean uninstall are confirmed on a fresh machine ===== BRIEF.md ===== # Build brief · eqMac The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a system-wide equaliser and audio router that sits in the menu bar to replace eqMac. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one is different from most builds on this site: the hard part is not your code, it is that the operating system does not want you to do this. macOS gives you two separate things and it matters which you need. Core Audio process taps, available since macOS 14.2, let an application capture a copy of what another process or the whole system is playing, and they need audio capture permission that there is still no public API to request or check. Taps let you listen. They do not let you change what comes out of the speakers. To actually alter system output you need a virtual output device the user selects as their output, implemented as a user-space audio server plugin, since kernel extensions are deprecated. Your plugin receives the audio, processes it, and forwards it to the real device. That means an installer, a system extension approval, and a device that must keep working when the user unplugs their headphones. Decide which of the two you need before writing anything. Building a tap and then discovering it cannot change the sound is a week lost. ### Stack (fixed, do not substitute) - Capture: A user-space audio server plugin presenting a virtual output device · altering system output requires being in the output path, which a tap is not - Processing: A biquad filter chain in a real-time safe callback · the audio callback cannot allocate, lock or log without producing clicks - Interface: A menu bar application communicating with the driver out of band · the interface must never block the audio thread - Routing: Device selection and follow-the-default behaviour · headphones being unplugged is the normal case, not an edge case - Safety: A hardware bypass path and an uninstaller that restores the real device · a broken build must not leave a machine silent ### Data model (create this before Phase 1) - presets(id, name, bands_json, preamp_db, created_at) · a preset is a filter chain - bands(preset_id, ordinal, kind, frequency_hz, gain_db, q) · kind is peaking, shelf or pass - devices(uid, name, is_default, sample_rate, channels, last_seen_at) - routes(id, source_device_uid, target_device_uid, active) · what goes where - state(key, value) · current preset, bypass, volume. Read by the driver, written by the interface - The driver reads coefficients, never the model. Filter design happens in the interface and only coefficients cross into the audio thread. ### Phase 1 · A virtual device the system will actually use Build: A user-space audio server plugin presenting an output device. Pass audio through untouched to the real output. Handle the sample rate the system asks for rather than assuming one. Ship an uninstaller from day one. Trap: A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. Done when: The device appears in sound settings and passing audio through it is bit-identical to the real output, verified by recording both. The system's requested sample rate is honoured, tested at two rates. Uninstalling restores the previous default device. Killing the interface application leaves audio still passing. Do not build yet: No processing, presets or routing. ### Phase 2 · Filters that do not click Build: A biquad filter chain evaluated in the audio callback. Design coefficients in the interface and hand them across without allocating in the callback. Ramp gain changes rather than applying them instantly. Trap: Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. Done when: A gain change produces no audible click, verified by recording and inspecting the waveform at the transition. The callback contains no allocation, lock or logging, verified by inspection and a real-time safety check. Round-trip latency is measured and stated. A sweep through the filter matches the designed response within tolerance. Do not build yet: No presets or routing yet. ### Phase 3 · Devices appearing and disappearing Build: Follow the system default output, handle devices arriving and leaving, and handle sleep and wake. Preserve the active preset across a device change. Recover if the target device disappears while audio is playing. Trap: Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. Done when: Unplugging the target device mid-playback re-targets to the new default without silence lasting more than a moment. Sleep and wake resume audio with the preset intact. A device reappearing is picked up. Rapidly connecting and disconnecting a device does not deadlock, tested by doing it twenty times. Do not build yet: No presets or per-application routing yet. ### Phase 4 · Presets worth switching between Build: Named presets with a preamp, switching without a gap in audio. A true bypass that removes processing entirely rather than flattening the bands. Automatic gain compensation so switching does not change perceived loudness dramatically. Trap: Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. Done when: Switching presets produces no gap or click. A preset whose combined gain would clip is compensated automatically or refused, tested with an extreme setting. True bypass is bit-identical to no processing, verified by recording. Preset switching under load does not affect the audio thread's deadline. Do not build yet: No per-application routing yet. ### Phase 5 · Routing, and shipping something installable Build: Route output to a chosen device, and optionally capture specific processes using a system tap for a per-application mix. Package the driver and application with the signing and notarisation the platform requires, and document the permission prompts. Trap: An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. Done when: The build installs on a machine that has never had it, with every permission prompt documented in advance. Routing to a chosen device works and survives that device disappearing. A per-application capture uses a tap and requests permission correctly. Uninstalling leaves no driver behind and restores the original default device. Do not build yet: Nothing after this. ### Out of scope (and why) - Cross-platform support, since the entire difficulty is platform-specific and none of this driver work transfers. - Advanced processing such as convolution, dynamics or room correction, which is a signal processing project on top of a working driver. - Per-application volume for arbitrary processes, which the platform makes considerably harder than it sounds. - Automatic updates for a system driver, which is genuinely risky to get wrong and is why commercial audio tools invest in it. ### README must contain - That taps capture audio and a virtual output device is required to change it, and which this build uses - That the audio callback never allocates, locks or logs, and why - That the uninstaller exists from the first phase because a broken audio device is hard to undo - That a clipping preset is compensated or refused rather than allowed to distort - That the driver must be signed and notarised or it silently will not appear ===== ARCHITECTURE.md ===== # Architecture · eqMac ## Stack | Part | Choice | Why | | --- | --- | --- | | Capture | A user-space audio server plugin presenting a virtual output device | altering system output requires being in the output path, which a tap is not | | Processing | A biquad filter chain in a real-time safe callback | the audio callback cannot allocate, lock or log without producing clicks | | Interface | A menu bar application communicating with the driver out of band | the interface must never block the audio thread | | Routing | Device selection and follow-the-default behaviour | headphones being unplugged is the normal case, not an edge case | | Safety | A hardware bypass path and an uninstaller that restores the real device | a broken build must not leave a machine silent | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Driver | The virtual output device and the audio callback. | Not swappable. This is the whole platform layer and it is where the difficulty lives. | | Filters | Coefficient design and real-time safe evaluation. | Any filter topology, provided design stays off the audio thread. | | Routing | Device tracking and re-targeting. | Any implementation that treats device disappearance as routine. | | Presets | Chains, headroom and true bypass. | Any storage. Refusing to let a preset clip is the requirement. | | Packaging | Signing, notarisation and uninstall. | Not optional. An unsigned driver does not load and a missing uninstaller strands users. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `STATE_PATH` · required · Presets and routing, read by the interface. The driver reads only coefficients. - `DEFAULT_PRESET` · required · Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. - `RAMP_MS` · required · How long a gain change takes. Too short clicks, too long feels unresponsive. - `FOLLOW_SYSTEM_DEFAULT` · required · Whether to re-target when the system default changes, which is what unplugging headphones does. - `LOG_PATH` · required · Written by the interface only. The audio callback never logs. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · eqMac product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A user-space audio server plugin presenting a virtual output device, A biquad filter chain in a real-time safe callback, A menu bar application communicating with the driver out of band, Device selection and follow-the-default behaviour, A hardware bypass path and an uninstaller that restores the real device. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. - An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. ===== MILESTONES.md ===== # Delivery milestones · eqMac Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A virtual device the system will actually use Your device appears in sound settings and audio passes through it unchanged. ### Steps 1. Present an output device from a server plugin User space, not a kernel extension. Kernel extensions are deprecated. Files: `driver/Device.cpp` 2. Pass through bit-identically first Prove the path before processing anything. Record both and compare. Files: `tests/passthrough.sh` 3. Write the uninstaller now Before the device is ever default. A broken audio device is hard to undo by hand. Files: `scripts/uninstall.sh` ### Done when - [ ] The device appears and passes audio bit-identically - [ ] Two sample rates are handled correctly - [ ] Uninstalling restores the previous default - [ ] Killing the interface leaves audio passing ### Watch out - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. ## M2 · Filters that do not click Equalisation applies with no audible artefact and no added latency. ### Steps 1. Design coefficients outside the audio thread The callback consumes coefficients. It never designs filters. Files: `src/biquad.ts`, `driver/Filter.cpp` 2. Publish updates lock free Atomic swap of a coefficient block. No locks on the audio thread, ever. Files: `driver/State.cpp` 3. Ramp changes over a short window An instant gain change is a discontinuity and a discontinuity is a click. Files: `driver/Filter.cpp` ### Done when - [ ] A gain change produces no click in a recording - [ ] The callback has no allocation, lock or logging - [ ] Round-trip latency is measured and stated - [ ] A sweep matches the designed response ### Watch out - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. ## M3 · Devices appearing and disappearing Unplugging headphones does not break audio or lose your settings. ### Steps 1. Watch for device changes Arrival, departure and default changes. All three are routine. Files: `driver/Devices.cpp` 2. Re-target rather than fail Losing the target device should cost a moment of audio, not the session. Files: `driver/Route.cpp` 3. Survive sleep and wake Test it repeatedly. It is where audio software reliably breaks. Files: `tests/sleep-wake.md` ### Done when - [ ] Unplugging mid-playback re-targets quickly - [ ] Sleep and wake resume with the preset intact - [ ] A reappearing device is picked up - [ ] Twenty rapid connect cycles do not deadlock ### Watch out - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. ## M4 · Presets worth switching between Saved chains you can move between instantly, including a true bypass. ### Steps 1. Switch without a gap Crossfade or ramp. A gap between presets is as bad as a click. Files: `driver/Preset.cpp` 2. Compensate or refuse clipping Compute maximum chain gain. Digital clipping is worse than the problem. Files: `src/headroom.ts`, `src/headroom.test.ts` 3. Make bypass truly bypass Bit-identical to no processing, not a flat curve. Verify by recording. Files: `driver/Bypass.cpp` ### Done when - [ ] Preset switching has no gap or click - [ ] A clipping preset is compensated or refused - [ ] Bypass is bit-identical to no processing - [ ] Switching under load misses no deadline ### Watch out - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ## M5 · Routing, and shipping something installable (production only) Audio goes where you want it, and another person can install this. ### Steps 1. Sign and notarise the driver An unsigned driver silently does not appear. Do this early, not at release. Files: `scripts/sign.sh` 2. Document every permission prompt There is no public API to request audio capture permission. The user needs telling. Files: `README.md` 3. Test a clean install and a clean uninstall On a machine that has never had it. Then remove it and confirm nothing remains. Files: `tests/install.md` ### Done when - [ ] A clean machine installs successfully - [ ] Every permission prompt is documented - [ ] Per-application capture requests permission correctly - [ ] Uninstalling leaves nothing behind ### Watch out - An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. ===== OPERATIONS.md ===== # Operations · eqMac ## Backup Presets are the only user data and are small. Export them as a file, since a user reinstalling the driver should not lose their chains. ## Restore Restore presets and confirm the driver reinstalls cleanly. The real recovery question is not data but whether the machine's audio can be returned to normal, which the uninstaller answers. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Audio callback deadline misses are the only metric that matters, counted in the driver and read out of band. A rising count means something has crept into the callback. ## Incident checklist No sound at all is the incident. The documented recovery is to select the real output device in sound settings, which works whether or not your interface is running, and that instruction belongs somewhere the user can find it without the application. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Passthrough is confirmed bit-identical by recording - [ ] A gain change is confirmed to produce no click - [ ] Unplugging the target device is confirmed to re-target - [ ] A clipping preset is confirmed compensated or refused - [ ] A clean install and clean uninstall are confirmed on a fresh machine ## Launch constraint Do not market omitted eqMac capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Presets and routing, read by the interface. The driver reads only coefficients. STATE_PATH=~/Library/Application Support/eqmac/state.json # Required. Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. DEFAULT_PRESET=flat # Required. How long a gain change takes. Too short clicks, too long feels unresponsive. RAMP_MS=20 # Required. Whether to re-target when the system default changes, which is what unplugging headphones does. FOLLOW_SYSTEM_DEFAULT=true # Required. Written by the interface only. The audio callback never logs. LOG_PATH=~/Library/Logs/eqmac.log
# eqMac · indie build A system-wide audio processor whose difficulty is almost entirely in the platform layer: a virtual output device that survives device changes and sleep, a processing chain that must never add audible latency or artefacts, and a failure mode where a bug means the user's machine has no sound at all. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Capture | A user-space audio server plugin presenting a virtual output device | altering system output requires being in the output path, which a tap is not | | Processing | A biquad filter chain in a real-time safe callback | the audio callback cannot allocate, lock or log without producing clicks | | Interface | A menu bar application communicating with the driver out of band | the interface must never block the audio thread | | Routing | Device selection and follow-the-default behaviour | headphones being unplugged is the normal case, not an edge case | | Safety | A hardware bypass path and an uninstaller that restores the real device | a broken build must not leave a machine silent | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14.2 or newer** · free if you own one - Why: Core Audio taps require 14.2 and the audio server plugin interfaces have changed over recent versions. Older systems need a different approach entirely. - Get it: Check the version before starting and decide the minimum you will support. - Verify: The system version is 14.2 or higher and the version is written down - [ ] **Xcode and the command line tools** · free - Why: An audio server plugin is a native bundle and cannot be built without them. - Get it: Install from the App Store, then the command line tools. - Verify: A sample audio server plugin builds and loads - [ ] **A developer account for signing and notarisation** · an annual fee - Why: An unsigned driver does not load on a current system, and the symptom is the device simply never appearing. - Get it: Enrol, create the certificates, and notarise a trivial build before writing the real driver. - Verify: A trivial signed and notarised bundle loads on a second machine - [ ] **A second machine or a spare user account** · free if you have one - Why: A broken audio driver can leave a machine silent, and debugging that on the machine you are working on is miserable. - Get it: A second Mac, a virtual machine, or at minimum a separate user account and a known-good recovery path. - Verify: You can install, break and recover audio without losing your working environment - [ ] **A way to record and inspect the output** · free - Why: Every claim in this build, from bit-identical passthrough to the absence of clicks, is verified by recording rather than by listening. - Get it: Any audio editor that shows a waveform and a spectrum, plus a loopback path to record your own output. - Verify: You can record output and inspect the waveform at a transition ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. - A first-party, notarized audio driver: you are borrowing someone else's virtual device instead - Automatic output-device following, so plugging in headphones means switching things by hand - Very low latency and rock-solid handling of sample rate mismatches and hotplug events - Extras like per-device presets, balance control, and a curated preset library - Updates, crash fixes, and someone else's problem when a macOS point release breaks audio If one of those is essential to you, that is the reason to keep paying for eqMac, and the README should say so rather than pretend.
# Build brief · eqMac The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a system-wide equaliser and audio router that sits in the menu bar to replace eqMac. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one is different from most builds on this site: the hard part is not your code, it is that the operating system does not want you to do this. macOS gives you two separate things and it matters which you need. Core Audio process taps, available since macOS 14.2, let an application capture a copy of what another process or the whole system is playing, and they need audio capture permission that there is still no public API to request or check. Taps let you listen. They do not let you change what comes out of the speakers. To actually alter system output you need a virtual output device the user selects as their output, implemented as a user-space audio server plugin, since kernel extensions are deprecated. Your plugin receives the audio, processes it, and forwards it to the real device. That means an installer, a system extension approval, and a device that must keep working when the user unplugs their headphones. Decide which of the two you need before writing anything. Building a tap and then discovering it cannot change the sound is a week lost. ### Stack (fixed, do not substitute) - Capture: A user-space audio server plugin presenting a virtual output device · altering system output requires being in the output path, which a tap is not - Processing: A biquad filter chain in a real-time safe callback · the audio callback cannot allocate, lock or log without producing clicks - Interface: A menu bar application communicating with the driver out of band · the interface must never block the audio thread - Routing: Device selection and follow-the-default behaviour · headphones being unplugged is the normal case, not an edge case - Safety: A hardware bypass path and an uninstaller that restores the real device · a broken build must not leave a machine silent ### Data model (create this before Phase 1) - presets(id, name, bands_json, preamp_db, created_at) · a preset is a filter chain - bands(preset_id, ordinal, kind, frequency_hz, gain_db, q) · kind is peaking, shelf or pass - devices(uid, name, is_default, sample_rate, channels, last_seen_at) - routes(id, source_device_uid, target_device_uid, active) · what goes where - state(key, value) · current preset, bypass, volume. Read by the driver, written by the interface - The driver reads coefficients, never the model. Filter design happens in the interface and only coefficients cross into the audio thread. ### Phase 1 · A virtual device the system will actually use Build: A user-space audio server plugin presenting an output device. Pass audio through untouched to the real output. Handle the sample rate the system asks for rather than assuming one. Ship an uninstaller from day one. Trap: A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. Done when: The device appears in sound settings and passing audio through it is bit-identical to the real output, verified by recording both. The system's requested sample rate is honoured, tested at two rates. Uninstalling restores the previous default device. Killing the interface application leaves audio still passing. Do not build yet: No processing, presets or routing. ### Phase 2 · Filters that do not click Build: A biquad filter chain evaluated in the audio callback. Design coefficients in the interface and hand them across without allocating in the callback. Ramp gain changes rather than applying them instantly. Trap: Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. Done when: A gain change produces no audible click, verified by recording and inspecting the waveform at the transition. The callback contains no allocation, lock or logging, verified by inspection and a real-time safety check. Round-trip latency is measured and stated. A sweep through the filter matches the designed response within tolerance. Do not build yet: No presets or routing yet. ### Phase 3 · Devices appearing and disappearing Build: Follow the system default output, handle devices arriving and leaving, and handle sleep and wake. Preserve the active preset across a device change. Recover if the target device disappears while audio is playing. Trap: Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. Done when: Unplugging the target device mid-playback re-targets to the new default without silence lasting more than a moment. Sleep and wake resume audio with the preset intact. A device reappearing is picked up. Rapidly connecting and disconnecting a device does not deadlock, tested by doing it twenty times. Do not build yet: No presets or per-application routing yet. ### Phase 4 · Presets worth switching between Build: Named presets with a preamp, switching without a gap in audio. A true bypass that removes processing entirely rather than flattening the bands. Automatic gain compensation so switching does not change perceived loudness dramatically. Trap: Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. Done when: Switching presets produces no gap or click. A preset whose combined gain would clip is compensated automatically or refused, tested with an extreme setting. True bypass is bit-identical to no processing, verified by recording. Preset switching under load does not affect the audio thread's deadline. Do not build yet: No per-application routing yet. ### Phase 5 · Routing, and shipping something installable Build: Route output to a chosen device, and optionally capture specific processes using a system tap for a per-application mix. Package the driver and application with the signing and notarisation the platform requires, and document the permission prompts. Trap: An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. Done when: The build installs on a machine that has never had it, with every permission prompt documented in advance. Routing to a chosen device works and survives that device disappearing. A per-application capture uses a tap and requests permission correctly. Uninstalling leaves no driver behind and restores the original default device. Do not build yet: Nothing after this. ### Out of scope (and why) - Cross-platform support, since the entire difficulty is platform-specific and none of this driver work transfers. - Advanced processing such as convolution, dynamics or room correction, which is a signal processing project on top of a working driver. - Per-application volume for arbitrary processes, which the platform makes considerably harder than it sounds. - Automatic updates for a system driver, which is genuinely risky to get wrong and is why commercial audio tools invest in it. ### README must contain - That taps capture audio and a virtual output device is required to change it, and which this build uses - That the audio callback never allocates, locks or logs, and why - That the uninstaller exists from the first phase because a broken audio device is hard to undo - That a clipping preset is compensated or refused rather than allowed to distort - That the driver must be signed and notarised or it silently will not appear
# Agent instructions · eqMac indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A user-space audio server plugin presenting a virtual output device, A biquad filter chain in a real-time safe callback, A menu bar application communicating with the driver out of band, Device selection and follow-the-default behaviour, A hardware bypass path and an uninstaller that restores the real device. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts.
# Build plan · eqMac A system-wide audio processor whose difficulty is almost entirely in the platform layer: a virtual output device that survives device changes and sleep, a processing chain that must never add audible latency or artefacts, and a failure mode where a bug means the user's machine has no sound at all. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A virtual device the system will actually use Your device appears in sound settings and audio passes through it unchanged. ### Steps 1. Present an output device from a server plugin User space, not a kernel extension. Kernel extensions are deprecated. Files: `driver/Device.cpp` 2. Pass through bit-identically first Prove the path before processing anything. Record both and compare. Files: `tests/passthrough.sh` 3. Write the uninstaller now Before the device is ever default. A broken audio device is hard to undo by hand. Files: `scripts/uninstall.sh` ### Done when - [ ] The device appears and passes audio bit-identically - [ ] Two sample rates are handled correctly - [ ] Uninstalling restores the previous default - [ ] Killing the interface leaves audio passing ### Watch out - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. ## Phase 2 · Filters that do not click Equalisation applies with no audible artefact and no added latency. ### Steps 1. Design coefficients outside the audio thread The callback consumes coefficients. It never designs filters. Files: `src/biquad.ts`, `driver/Filter.cpp` 2. Publish updates lock free Atomic swap of a coefficient block. No locks on the audio thread, ever. Files: `driver/State.cpp` 3. Ramp changes over a short window An instant gain change is a discontinuity and a discontinuity is a click. Files: `driver/Filter.cpp` ### Done when - [ ] A gain change produces no click in a recording - [ ] The callback has no allocation, lock or logging - [ ] Round-trip latency is measured and stated - [ ] A sweep matches the designed response ### Watch out - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. ## Phase 3 · Devices appearing and disappearing Unplugging headphones does not break audio or lose your settings. ### Steps 1. Watch for device changes Arrival, departure and default changes. All three are routine. Files: `driver/Devices.cpp` 2. Re-target rather than fail Losing the target device should cost a moment of audio, not the session. Files: `driver/Route.cpp` 3. Survive sleep and wake Test it repeatedly. It is where audio software reliably breaks. Files: `tests/sleep-wake.md` ### Done when - [ ] Unplugging mid-playback re-targets quickly - [ ] Sleep and wake resume with the preset intact - [ ] A reappearing device is picked up - [ ] Twenty rapid connect cycles do not deadlock ### Watch out - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. ## Phase 4 · Presets worth switching between Saved chains you can move between instantly, including a true bypass. ### Steps 1. Switch without a gap Crossfade or ramp. A gap between presets is as bad as a click. Files: `driver/Preset.cpp` 2. Compensate or refuse clipping Compute maximum chain gain. Digital clipping is worse than the problem. Files: `src/headroom.ts`, `src/headroom.test.ts` 3. Make bypass truly bypass Bit-identical to no processing, not a flat curve. Verify by recording. Files: `driver/Bypass.cpp` ### Done when - [ ] Preset switching has no gap or click - [ ] A clipping preset is compensated or refused - [ ] Bypass is bit-identical to no processing - [ ] Switching under load misses no deadline ### Watch out - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ## Not in this build - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. ## After v1, if you want it - A spectrum analyser fed from the driver, once the audio thread is provably safe - Per-device presets that switch automatically with the output - Loudness matching between presets, which makes comparing them honest
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Presets and routing, read by the interface. The driver reads only coefficients. STATE_PATH=~/Library/Application Support/eqmac/state.json # Required. Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. DEFAULT_PRESET=flat # Required. How long a gain change takes. Too short clicks, too long feels unresponsive. RAMP_MS=20 # Required. Whether to re-target when the system default changes, which is what unplugging headphones does. FOLLOW_SYSTEM_DEFAULT=true # Required. Written by the interface only. The audio callback never logs. LOG_PATH=~/Library/Logs/eqmac.log
# eqMac · product brief ## Problem The DSP is the easy part: a cascade of biquad filters driven by sliders is textbook, and an agent will write it correctly on the first try. The hard part is getting system audio into your process at all, which on macOS means a virtual output device and a Core Audio server plugin, signed and notarized, plus graceful handling of sample rate changes, device hotplug and headphone unplug. You can dodge most of that by installing BlackHole and building a loopback app that pulls from it and pushes to your real output, which is a genuine weekend project and works fine on your own machine. What you will not match in one sitting is the invisible-ness: no driver install prompts, no latency you can hear, no manual switching every time you plug in AirPods. Good build, mediocre replacement for something you want to forget exists. ## Product outcome A system-wide equaliser that never clicks, never leaves the machine silent, survives headphones being unplugged, and can be completely removed. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS with Xcode and a Swift toolchain - BlackHole (or another virtual audio device) installed, e.g. via Homebrew - Willingness to set your system output to the virtual device and let the app do the routing - Microphone/audio permissions granted to the app - Basic tolerance for a few ms of added latency ## Explicit non-goals for v1 - Cross-platform support. - Convolution, dynamics and room correction. - Per-application volume for arbitrary processes. - Automatic updates for a system driver. - A first-party, notarized audio driver: you are borrowing someone else's virtual device instead - Automatic output-device following, so plugging in headphones means switching things by hand - Very low latency and rock-solid handling of sample rate mismatches and hotplug events - Extras like per-device presets, balance control, and a curated preset library - Updates, crash fixes, and someone else's problem when a macOS point release breaks audio ## Success criteria - Passthrough is confirmed bit-identical by recording - A gain change is confirmed to produce no click - Unplugging the target device is confirmed to re-target - A clipping preset is confirmed compensated or refused - A clean install and clean uninstall are confirmed on a fresh machine
# Build brief · eqMac The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a system-wide equaliser and audio router that sits in the menu bar to replace eqMac. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one is different from most builds on this site: the hard part is not your code, it is that the operating system does not want you to do this. macOS gives you two separate things and it matters which you need. Core Audio process taps, available since macOS 14.2, let an application capture a copy of what another process or the whole system is playing, and they need audio capture permission that there is still no public API to request or check. Taps let you listen. They do not let you change what comes out of the speakers. To actually alter system output you need a virtual output device the user selects as their output, implemented as a user-space audio server plugin, since kernel extensions are deprecated. Your plugin receives the audio, processes it, and forwards it to the real device. That means an installer, a system extension approval, and a device that must keep working when the user unplugs their headphones. Decide which of the two you need before writing anything. Building a tap and then discovering it cannot change the sound is a week lost. ### Stack (fixed, do not substitute) - Capture: A user-space audio server plugin presenting a virtual output device · altering system output requires being in the output path, which a tap is not - Processing: A biquad filter chain in a real-time safe callback · the audio callback cannot allocate, lock or log without producing clicks - Interface: A menu bar application communicating with the driver out of band · the interface must never block the audio thread - Routing: Device selection and follow-the-default behaviour · headphones being unplugged is the normal case, not an edge case - Safety: A hardware bypass path and an uninstaller that restores the real device · a broken build must not leave a machine silent ### Data model (create this before Phase 1) - presets(id, name, bands_json, preamp_db, created_at) · a preset is a filter chain - bands(preset_id, ordinal, kind, frequency_hz, gain_db, q) · kind is peaking, shelf or pass - devices(uid, name, is_default, sample_rate, channels, last_seen_at) - routes(id, source_device_uid, target_device_uid, active) · what goes where - state(key, value) · current preset, bypass, volume. Read by the driver, written by the interface - The driver reads coefficients, never the model. Filter design happens in the interface and only coefficients cross into the audio thread. ### Phase 1 · A virtual device the system will actually use Build: A user-space audio server plugin presenting an output device. Pass audio through untouched to the real output. Handle the sample rate the system asks for rather than assuming one. Ship an uninstaller from day one. Trap: A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. Done when: The device appears in sound settings and passing audio through it is bit-identical to the real output, verified by recording both. The system's requested sample rate is honoured, tested at two rates. Uninstalling restores the previous default device. Killing the interface application leaves audio still passing. Do not build yet: No processing, presets or routing. ### Phase 2 · Filters that do not click Build: A biquad filter chain evaluated in the audio callback. Design coefficients in the interface and hand them across without allocating in the callback. Ramp gain changes rather than applying them instantly. Trap: Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. Done when: A gain change produces no audible click, verified by recording and inspecting the waveform at the transition. The callback contains no allocation, lock or logging, verified by inspection and a real-time safety check. Round-trip latency is measured and stated. A sweep through the filter matches the designed response within tolerance. Do not build yet: No presets or routing yet. ### Phase 3 · Devices appearing and disappearing Build: Follow the system default output, handle devices arriving and leaving, and handle sleep and wake. Preserve the active preset across a device change. Recover if the target device disappears while audio is playing. Trap: Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. Done when: Unplugging the target device mid-playback re-targets to the new default without silence lasting more than a moment. Sleep and wake resume audio with the preset intact. A device reappearing is picked up. Rapidly connecting and disconnecting a device does not deadlock, tested by doing it twenty times. Do not build yet: No presets or per-application routing yet. ### Phase 4 · Presets worth switching between Build: Named presets with a preamp, switching without a gap in audio. A true bypass that removes processing entirely rather than flattening the bands. Automatic gain compensation so switching does not change perceived loudness dramatically. Trap: Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. Done when: Switching presets produces no gap or click. A preset whose combined gain would clip is compensated automatically or refused, tested with an extreme setting. True bypass is bit-identical to no processing, verified by recording. Preset switching under load does not affect the audio thread's deadline. Do not build yet: No per-application routing yet. ### Phase 5 · Routing, and shipping something installable Build: Route output to a chosen device, and optionally capture specific processes using a system tap for a per-application mix. Package the driver and application with the signing and notarisation the platform requires, and document the permission prompts. Trap: An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated. Done when: The build installs on a machine that has never had it, with every permission prompt documented in advance. Routing to a chosen device works and survives that device disappearing. A per-application capture uses a tap and requests permission correctly. Uninstalling leaves no driver behind and restores the original default device. Do not build yet: Nothing after this. ### Out of scope (and why) - Cross-platform support, since the entire difficulty is platform-specific and none of this driver work transfers. - Advanced processing such as convolution, dynamics or room correction, which is a signal processing project on top of a working driver. - Per-application volume for arbitrary processes, which the platform makes considerably harder than it sounds. - Automatic updates for a system driver, which is genuinely risky to get wrong and is why commercial audio tools invest in it. ### README must contain - That taps capture audio and a virtual output device is required to change it, and which this build uses - That the audio callback never allocates, locks or logs, and why - That the uninstaller exists from the first phase because a broken audio device is hard to undo - That a clipping preset is compensated or refused rather than allowed to distort - That the driver must be signed and notarised or it silently will not appear
# Architecture · eqMac ## Stack | Part | Choice | Why | | --- | --- | --- | | Capture | A user-space audio server plugin presenting a virtual output device | altering system output requires being in the output path, which a tap is not | | Processing | A biquad filter chain in a real-time safe callback | the audio callback cannot allocate, lock or log without producing clicks | | Interface | A menu bar application communicating with the driver out of band | the interface must never block the audio thread | | Routing | Device selection and follow-the-default behaviour | headphones being unplugged is the normal case, not an edge case | | Safety | A hardware bypass path and an uninstaller that restores the real device | a broken build must not leave a machine silent | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Driver | The virtual output device and the audio callback. | Not swappable. This is the whole platform layer and it is where the difficulty lives. | | Filters | Coefficient design and real-time safe evaluation. | Any filter topology, provided design stays off the audio thread. | | Routing | Device tracking and re-targeting. | Any implementation that treats device disappearance as routine. | | Presets | Chains, headroom and true bypass. | Any storage. Refusing to let a preset clip is the requirement. | | Packaging | Signing, notarisation and uninstall. | Not optional. An unsigned driver does not load and a missing uninstaller strands users. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `STATE_PATH` · required · Presets and routing, read by the interface. The driver reads only coefficients. - `DEFAULT_PRESET` · required · Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. - `RAMP_MS` · required · How long a gain change takes. Too short clicks, too long feels unresponsive. - `FOLLOW_SYSTEM_DEFAULT` · required · Whether to re-target when the system default changes, which is what unplugging headphones does. - `LOG_PATH` · required · Written by the interface only. The audio callback never logs. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · eqMac product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A user-space audio server plugin presenting a virtual output device, A biquad filter chain in a real-time safe callback, A menu bar application communicating with the driver out of band, Device selection and follow-the-default behaviour, A hardware bypass path and an uninstaller that restores the real device. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. - An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated.
# Delivery milestones · eqMac Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A virtual device the system will actually use Your device appears in sound settings and audio passes through it unchanged. ### Steps 1. Present an output device from a server plugin User space, not a kernel extension. Kernel extensions are deprecated. Files: `driver/Device.cpp` 2. Pass through bit-identically first Prove the path before processing anything. Record both and compare. Files: `tests/passthrough.sh` 3. Write the uninstaller now Before the device is ever default. A broken audio device is hard to undo by hand. Files: `scripts/uninstall.sh` ### Done when - [ ] The device appears and passes audio bit-identically - [ ] Two sample rates are handled correctly - [ ] Uninstalling restores the previous default - [ ] Killing the interface leaves audio passing ### Watch out - A virtual output device that fails to forward audio leaves the machine with no sound at all, and the user cannot easily work out why or undo it, especially since your interface may be the thing that is broken. Build and test the uninstaller in this phase, before the device is ever the default, and make the failure path fall back to the real device rather than to silence. ## M2 · Filters that do not click Equalisation applies with no audible artefact and no added latency. ### Steps 1. Design coefficients outside the audio thread The callback consumes coefficients. It never designs filters. Files: `src/biquad.ts`, `driver/Filter.cpp` 2. Publish updates lock free Atomic swap of a coefficient block. No locks on the audio thread, ever. Files: `driver/State.cpp` 3. Ramp changes over a short window An instant gain change is a discontinuity and a discontinuity is a click. Files: `driver/Filter.cpp` ### Done when - [ ] A gain change produces no click in a recording - [ ] The callback has no allocation, lock or logging - [ ] Round-trip latency is measured and stated - [ ] A sweep matches the designed response ### Watch out - Anything in the audio callback that allocates, takes a lock or writes a log will eventually miss its deadline and produce a click, and it will do so under load rather than during testing. The callback touches pre-allocated memory and lock-free state only, and coefficient updates are published atomically from outside it. ## M3 · Devices appearing and disappearing Unplugging headphones does not break audio or lose your settings. ### Steps 1. Watch for device changes Arrival, departure and default changes. All three are routine. Files: `driver/Devices.cpp` 2. Re-target rather than fail Losing the target device should cost a moment of audio, not the session. Files: `driver/Route.cpp` 3. Survive sleep and wake Test it repeatedly. It is where audio software reliably breaks. Files: `tests/sleep-wake.md` ### Done when - [ ] Unplugging mid-playback re-targets quickly - [ ] Sleep and wake resume with the preset intact - [ ] A reappearing device is picked up - [ ] Twenty rapid connect cycles do not deadlock ### Watch out - Holding a reference to an output device that is then unplugged gives a driver writing into a device that no longer exists, which on a good day is silence and on a bad day is a kernel-level audio stall requiring a restart. Watch for device changes and re-target, treating disappearance as routine rather than exceptional. ## M4 · Presets worth switching between Saved chains you can move between instantly, including a true bypass. ### Steps 1. Switch without a gap Crossfade or ramp. A gap between presets is as bad as a click. Files: `driver/Preset.cpp` 2. Compensate or refuse clipping Compute maximum chain gain. Digital clipping is worse than the problem. Files: `src/headroom.ts`, `src/headroom.test.ts` 3. Make bypass truly bypass Bit-identical to no processing, not a flat curve. Verify by recording. Files: `driver/Bypass.cpp` ### Done when - [ ] Preset switching has no gap or click - [ ] A clipping preset is compensated or refused - [ ] Bypass is bit-identical to no processing - [ ] Switching under load misses no deadline ### Watch out - Boosting several bands without reducing the preamp clips the signal, and digital clipping sounds far worse than the problem the boost was meant to solve. Compute the chain's maximum gain and either apply automatic preamp reduction or refuse the setting, rather than letting the user build a preset that distorts. ## M5 · Routing, and shipping something installable (production only) Audio goes where you want it, and another person can install this. ### Steps 1. Sign and notarise the driver An unsigned driver silently does not appear. Do this early, not at release. Files: `scripts/sign.sh` 2. Document every permission prompt There is no public API to request audio capture permission. The user needs telling. Files: `README.md` 3. Test a clean install and a clean uninstall On a machine that has never had it. Then remove it and confirm nothing remains. Files: `tests/install.md` ### Done when - [ ] A clean machine installs successfully - [ ] Every permission prompt is documented - [ ] Per-application capture requests permission correctly - [ ] Uninstalling leaves nothing behind ### Watch out - An unsigned or un-notarised audio driver will not load on a current system, and the failure surfaces as the device simply not appearing, with no useful message anywhere. Sort out signing and notarisation early rather than at release, because it also affects how the driver is installed and updated.
# Operations · eqMac ## Backup Presets are the only user data and are small. Export them as a file, since a user reinstalling the driver should not lose their chains. ## Restore Restore presets and confirm the driver reinstalls cleanly. The real recovery question is not data but whether the machine's audio can be returned to normal, which the uninstaller answers. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Audio callback deadline misses are the only metric that matters, counted in the driver and read out of band. A rising count means something has crept into the callback. ## Incident checklist No sound at all is the incident. The documented recovery is to select the real output device in sound settings, which works whether or not your interface is running, and that instruction belongs somewhere the user can find it without the application. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Passthrough is confirmed bit-identical by recording - [ ] A gain change is confirmed to produce no click - [ ] Unplugging the target device is confirmed to re-target - [ ] A clipping preset is confirmed compensated or refused - [ ] A clean install and clean uninstall are confirmed on a fresh machine ## Launch constraint Do not market omitted eqMac capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Presets and routing, read by the interface. The driver reads only coefficients. STATE_PATH=~/Library/Application Support/eqmac/state.json # Required. Applied at startup. Flat is the safe default, since a boosted preset at login is unpleasant. DEFAULT_PRESET=flat # Required. How long a gain change takes. Too short clicks, too long feels unresponsive. RAMP_MS=20 # Required. Whether to re-target when the system default changes, which is what unplugging headphones does. FOLLOW_SYSTEM_DEFAULT=true # Required. Written by the interface only. The audio callback never logs. LOG_PATH=~/Library/Logs/eqmac.log
$ choose a build depth, inspect the files, then open the complete pack in your agent
System audio on macOS is hostile territory: Apple gives you no sanctioned tap on the output mix, so anything like this lives or dies on driver work, signing, notarization and years of edge cases with Bluetooth, multi-output devices and OS updates. A personal build that requires you to manually route through BlackHole is fine on a laptop you control, and annoying everywhere else. Paying is mostly paying for the audio path to never think about itself again.
xA first-party, notarized audio driver: you are borrowing someone else's virtual device instead
xAutomatic output-device following, so plugging in headphones means switching things by hand
xVery low latency and rock-solid handling of sample rate mismatches and hotplug events
xExtras like per-device presets, balance control, and a curated preset library
xUpdates, crash fixes, and someone else's problem when a macOS point release breaks audio
Nothing worth pointing at. That's why the prompt exists.
Vibecode eqMac
Kinda. The core of eqMac is buildable in a weekend with the prompt on this page, but there are real gaps: A first-party, notarized audio driver: you are borrowing someone else's virtual device instead, Automatic output-device following, so plugging in headphones means switching things by hand. Read the honest list above before committing.
How much does eqMac cost?
eqMac costs about $3/month (Pro Subscription, checked 2026-08-18), which is $36 per year.
What do I lose by replacing eqMac?
Honestly: A first-party, notarized audio driver: you are borrowing someone else's virtual device instead; Automatic output-device following, so plugging in headphones means switching things by hand; Very low latency and rock-solid handling of sample rate mismatches and hotplug events; Extras like per-device presets, balance control, and a curated preset library; Updates, crash fixes, and someone else's problem when a macOS point release breaks audio. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to eqMac?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.