diff --git a/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx
new file mode 100644
index 0000000000..36629bca22
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx
@@ -0,0 +1,191 @@
+/**
+ * The Tone module: a multi-band EQ as one control surface over several nodes.
+ *
+ * Faders rather than the rack's usual horizontal sliders, because a row of them
+ * around a centre detent is what an equaliser looks like to everybody who has
+ * met one. Recognising the control is most of the value — an author who has
+ * never opened a mixer has still used bass, middle and treble.
+ */
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ audioEqSummary,
+ HF_AUDIO_EQ_RANGE_DB,
+ type HfAudioEqBand,
+} from "@hyperframes/core/audio-fx-eq";
+
+export interface FxEqModuleProps {
+ eqId: string;
+ bands: HfAudioEqBand[];
+ open: boolean;
+ disabled?: boolean;
+ onToggleOpen(): void;
+ /** Dragging: heard immediately, not persisted. */
+ onPreview(bandName: string, gain: number): void;
+ /** Release: the write that persists. */
+ onCommit(bandName: string, gain: number): void;
+ onRemove(): void;
+}
+
+/** Fader travel as a percentage from the top, with 0 dB at the centre. */
+function offsetFor(gain: number): number {
+ const clamped = Math.max(-HF_AUDIO_EQ_RANGE_DB, Math.min(HF_AUDIO_EQ_RANGE_DB, gain));
+ return 50 - (clamped / (HF_AUDIO_EQ_RANGE_DB * 2)) * 100;
+}
+
+const shown = (gain: number): string => {
+ const v = Number(gain.toFixed(1));
+ return v > 0 ? `+${v}` : String(v);
+};
+
+function Fader({
+ band,
+ disabled,
+ onPreview,
+ onCommit,
+}: {
+ band: HfAudioEqBand;
+ disabled?: boolean;
+ onPreview(gain: number): void;
+ onCommit(gain: number): void;
+}) {
+ /**
+ * Held locally for the length of the gesture.
+ *
+ * The module is driven by the chain, and dragging only PREVIEWS — it does
+ * not write — so a purely controlled input re-renders back to the old value
+ * on the first move and the fader snaps out from under the pointer. Same
+ * split the rack's other controls already make.
+ */
+ const [local, setLocal] = useState(band.gain);
+ const [dragging, setDragging] = useState(false);
+ useEffect(() => {
+ if (!dragging) setLocal(band.gain);
+ }, [band.gain, dragging]);
+
+ const value = dragging ? local : band.gain;
+ const pct = offsetFor(value);
+ const moved = Math.abs(value) >= 0.05;
+
+ const move = (next: number) => {
+ setDragging(true);
+ setLocal(next);
+ onPreview(next);
+ };
+ const settle = () => {
+ if (!dragging) return;
+ setDragging(false);
+ onCommit(local);
+ };
+
+ // A range input rotated into a fader: it keeps keyboard control, focus and
+ // the platform's own pointer handling, which a div with pointer events would
+ // all have to reimplement badly.
+ return (
+