Improve UI connectors
diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts
index ac8bbaa..625348d 100644
--- a/ui/playwright.config.ts
+++ b/ui/playwright.config.ts
@@ -55,7 +55,7 @@
   },
 
   use: {
-    baseURL: 'http://127.0.0.1:10000',
+    baseURL: process.env.PERFETTO_BASE_URL ?? 'http://127.0.0.1:10000',
     trace: 'off',
   },
 
@@ -94,7 +94,7 @@
     command:
       './run-dev-server --no-build --no-depscheck ' +
       (process.env.DEV_SERVER_ARGS ?? ''),
-    url: 'http://127.0.0.1:10000',
+    url: process.env.PERFETTO_BASE_URL ?? 'http://127.0.0.1:10000',
     reuseExistingServer: true,
     timeout: 5 * 60 * 1000,
     stdout: 'pipe',
diff --git a/ui/src/plugins/dev.perfetto.SoundSynth/block_registry.ts b/ui/src/plugins/dev.perfetto.SoundSynth/block_registry.ts
index 5d0be47..c84e134 100644
--- a/ui/src/plugins/dev.perfetto.SoundSynth/block_registry.ts
+++ b/ui/src/plugins/dev.perfetto.SoundSynth/block_registry.ts
@@ -20,8 +20,9 @@
 //   - a factory that creates a default ISynthModule
 //   - a renderParams() function that renders inline parameter controls
 //
-// Milestone 1 ships full panels for 8 key blocks; the others use a
-// generic fallback that introspects the proto oneof and auto-renders.
+// Milestone 2: every block has a hand-tuned param panel; only the
+// legacy blocks (vco/envelope) still fall back to the generic
+// auto-introspecting renderer.
 
 import m from 'mithril';
 import protos from '../../protos';
@@ -33,6 +34,38 @@
   readonly kind: PortKind;
 }
 
+/** Per-PortKind visual colors (used in port badges and palette). */
+export const PORT_KIND_COLORS: Record<PortKind, string> = {
+  audio: '#e8a33d',  // amber - audio signal
+  cv:    '#5fa7ee',  // blue - control voltage
+  gate:  '#7cd66a',  // green - gate / trigger
+  freq:  '#d770d7',  // magenta - pitch CV
+};
+
+/**
+ * Builds an m.Children to use as NodePort.content for a port. Renders
+ * a small colored dot followed by the port name, so the user can tell
+ * at a glance what kind of signal a port carries.
+ */
+export function portContent(name: string, kind: PortKind): m.Children {
+  return m('span', {
+    style: {
+      display: 'inline-flex', alignItems: 'center', gap: '4px',
+      fontSize: '10px', color: '#333',
+    },
+    title: `${kind} signal`,
+  },
+    m('span', {
+      style: {
+        display: 'inline-block', width: '7px', height: '7px',
+        borderRadius: '50%', background: PORT_KIND_COLORS[kind],
+        boxShadow: '0 0 0 1px rgba(0,0,0,0.2)',
+      },
+    }),
+    name,
+  );
+}
+
 export type BlockCategory =
   | 'source' | 'oscillator' | 'filter'
   | 'effect' | 'modulator' | 'utility';
@@ -94,6 +127,49 @@
   );
 }
 
+/**
+ * Logarithmic slider, used for frequency-like params where linear
+ * interpolation feels horrible. The DOM slider always operates on a
+ * 0..1000 integer range internally; we map to/from log space.
+ */
+function logSlider(
+  label: string, value: number, min: number, max: number,
+  unit: string, onchange: (v: number) => void,
+): m.Child {
+  const safeMin = Math.max(min, 1e-6);
+  const lo = Math.log(safeMin);
+  const hi = Math.log(max);
+  const t = Math.max(0, Math.min(1000,
+    Math.round(((Math.log(Math.max(value, safeMin)) - lo) / (hi - lo)) * 1000),
+  ));
+  const decimals = value < 10 ? 2 : (value < 100 ? 1 : 0);
+  return m('div', {
+    style: {
+      display: 'flex', alignItems: 'center', gap: '4px',
+      marginBottom: '2px',
+    },
+  },
+    m('span', {style: fieldLabelStyle}, label),
+    m('input[type=range]', {
+      style: {flex: '1', minWidth: '60px'},
+      min: '0', max: '1000', step: '1', value: String(t),
+      oninput: (e: InputEvent) => {
+        const raw = parseFloat((e.target as HTMLInputElement).value);
+        const mapped = Math.exp(lo + (raw / 1000) * (hi - lo));
+        onchange(mapped);
+      },
+      onclick: (e: Event) => e.stopPropagation(),
+      onmousedown: (e: Event) => e.stopPropagation(),
+    }),
+    m('span', {
+      style: {
+        fontSize: '10px', color: '#333', width: '52px',
+        textAlign: 'right', fontFamily: 'monospace',
+      },
+    }, `${value.toFixed(decimals)}${unit}`),
+  );
+}
+
 function dropdown<T extends string | number>(
   label: string, value: T,
   options: Array<{value: T; label: string}>,
@@ -123,6 +199,111 @@
   );
 }
 
+function checkbox(
+  label: string, value: boolean, onchange: (v: boolean) => void,
+): m.Child {
+  return m('div', {
+    style: {
+      display: 'flex', alignItems: 'center', gap: '4px',
+      marginBottom: '2px',
+    },
+  },
+    m('span', {style: fieldLabelStyle}, label),
+    m('input[type=checkbox]', {
+      checked: value,
+      onchange: (e: Event) => {
+        onchange((e.target as HTMLInputElement).checked);
+      },
+      onclick: (e: Event) => e.stopPropagation(),
+      onmousedown: (e: Event) => e.stopPropagation(),
+    }),
+  );
+}
+
+/**
+ * Compact vertical drawbar (Hammond-drawbar style). Used by the
+ * DrawbarOrgan panel.
+ *
+ * Implemented as a click+drag track: we set the value from the
+ * pointer's Y position. We avoid <input type=range> with vertical
+ * writing-mode here because legacy `appearance: slider-vertical` is
+ * gone in Chrome 145+ and the modern equivalent renders inconsistently.
+ */
+function verticalSlider(
+  label: string, value: number, onchange: (v: number) => void,
+): m.Child {
+  const TRACK_HEIGHT = 60;
+  const filled = Math.max(0, Math.min(1, value));
+  const updateFromY = (clientY: number, trackEl: HTMLElement) => {
+    const rect = trackEl.getBoundingClientRect();
+    const t = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
+    onchange(t);
+  };
+  return m('div', {
+    style: {
+      display: 'flex', flexDirection: 'column', alignItems: 'center',
+      gap: '2px', width: '18px',
+    },
+  },
+    m('div.drawbar-track', {
+      style: {
+        position: 'relative',
+        width: '10px',
+        height: `${TRACK_HEIGHT}px`,
+        background: '#1a1a1c',
+        border: '1px solid #555',
+        borderRadius: '2px',
+        cursor: 'ns-resize',
+      },
+      onpointerdown: (e: PointerEvent) => {
+        e.stopPropagation();
+        const el = e.currentTarget as HTMLElement;
+        el.setPointerCapture(e.pointerId);
+        updateFromY(e.clientY, el);
+        const onMove = (ev: PointerEvent) => {
+          updateFromY(ev.clientY, el);
+        };
+        const onUp = (ev: PointerEvent) => {
+          el.releasePointerCapture(ev.pointerId);
+          el.removeEventListener('pointermove', onMove);
+          el.removeEventListener('pointerup', onUp);
+        };
+        el.addEventListener('pointermove', onMove);
+        el.addEventListener('pointerup', onUp);
+      },
+      onmousedown: (e: Event) => e.stopPropagation(),
+    },
+      // The drawbar "slug" — a beige tab that fills from the bottom up.
+      m('div', {
+        style: {
+          position: 'absolute',
+          left: '0', right: '0',
+          top: `${(1 - filled) * 100}%`,
+          bottom: '0',
+          background: 'linear-gradient(180deg,#f5f5dc,#e6d8a0)',
+          boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.6)',
+        },
+      }),
+      // Black tip line at the top of the slug.
+      m('div', {
+        style: {
+          position: 'absolute',
+          left: '0', right: '0',
+          top: `calc(${(1 - filled) * 100}% - 1px)`,
+          height: '2px',
+          background: '#000',
+          pointerEvents: 'none',
+        },
+      }),
+    ),
+    m('span', {
+      style: {
+        fontSize: '8px', fontFamily: 'monospace', color: '#bbb',
+      },
+    }, label),
+  );
+}
+
 // --- Full descriptors for the 8 key blocks ---
 
 const descriptors: BlockDescriptor[] = [];
@@ -369,36 +550,405 @@
   }, 'Sums all inputs'),
 });
 
-// --- Generic descriptors for the remaining blocks (Milestone 1 stubs) ---
+// --- Full descriptors for the remaining blocks (Milestone 2) ---
+
+// Oscillators: all have a freq_in port and out port.
+const OSC_INPUTS: ReadonlyArray<PortSpec> = [
+  {name: 'freq', kind: 'freq'},
+  {name: 'freq_mod', kind: 'cv'},
+];
+const OSC_OUTPUTS: ReadonlyArray<PortSpec> = [{name: 'out', kind: 'audio'}];
+
+// FmOsc -----------------------------------------------------------------------
+descriptors.push({
+  protoField: 'fm_osc',
+  displayName: 'FM Osc',
+  description: '2-operator phase-modulation FM oscillator (Chowning)',
+  category: 'oscillator',
+  hue: 40,
+  inputs: OSC_INPUTS,
+  outputs: OSC_OUTPUTS,
+  createDefault: (): protos.ISynthModule => ({
+    fmOsc: {baseFreqHz: 220, modRatio: 1, modIndex: 1, feedback: 0},
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.fmOsc!;
+    return m('div', {style: {fontSize: '11px'}},
+      logSlider('Freq', cfg.baseFreqHz ?? 220, 20, 4000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      slider('Ratio', cfg.modRatio ?? 1, 0, 16, 'x', 2,
+        (v) => { cfg.modRatio = v; onChange(); }),
+      slider('Index', cfg.modIndex ?? 1, 0, 32, '', 2,
+        (v) => { cfg.modIndex = v; onChange(); }),
+      slider('Feedback', cfg.feedback ?? 0, 0, 1, '', 2,
+        (v) => { cfg.feedback = v; onChange(); }),
+    );
+  },
+});
+
+// PhaseDistortionOsc ----------------------------------------------------------
+descriptors.push({
+  protoField: 'phase_distortion_osc',
+  displayName: 'Phase Dist',
+  description: 'Casio CZ-style phase-distortion oscillator',
+  category: 'oscillator',
+  hue: 50,
+  inputs: OSC_INPUTS,
+  outputs: OSC_OUTPUTS,
+  createDefault: (): protos.ISynthModule => ({
+    phaseDistortionOsc: {
+      mode: protos.PhaseDistortionOscConfig.Mode.SAW_WARP,
+      baseFreqHz: 220, amount: 0.5,
+    },
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.phaseDistortionOsc!;
+    return m('div', {style: {fontSize: '11px'}},
+      dropdown('Mode', cfg.mode ?? 0, [
+        {value: 0, label: 'Saw Warp'},
+        {value: 1, label: 'Pulse Warp'},
+      ], (v) => { cfg.mode = v; onChange(); }),
+      logSlider('Freq', cfg.baseFreqHz ?? 220, 20, 4000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      slider('Amount', cfg.amount ?? 0.5, 0, 1, '', 2,
+        (v) => { cfg.amount = v; onChange(); }),
+    );
+  },
+});
+
+// FoldOsc ---------------------------------------------------------------------
+descriptors.push({
+  protoField: 'fold_osc',
+  displayName: 'Fold Osc',
+  description: 'Smooth wavefolder oscillator (sin(drive·sin))',
+  category: 'oscillator',
+  hue: 60,
+  inputs: OSC_INPUTS,
+  outputs: OSC_OUTPUTS,
+  createDefault: (): protos.ISynthModule => ({
+    foldOsc: {baseFreqHz: 220, drive: 3, bias: 0},
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.foldOsc!;
+    return m('div', {style: {fontSize: '11px'}},
+      logSlider('Freq', cfg.baseFreqHz ?? 220, 20, 4000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      slider('Drive', cfg.drive ?? 3, 1, 20, '', 1,
+        (v) => { cfg.drive = v; onChange(); }),
+      slider('Bias', cfg.bias ?? 0, -1, 1, '', 2,
+        (v) => { cfg.bias = v; onChange(); }),
+    );
+  },
+});
+
+// SyncOsc ---------------------------------------------------------------------
+descriptors.push({
+  protoField: 'sync_osc',
+  displayName: 'Sync Osc',
+  description: 'Hardsync oscillator (master+slave)',
+  category: 'oscillator',
+  hue: 70,
+  inputs: OSC_INPUTS,
+  outputs: OSC_OUTPUTS,
+  createDefault: (): protos.ISynthModule => ({
+    syncOsc: {baseFreqHz: 110, syncRatio: 2},
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.syncOsc!;
+    return m('div', {style: {fontSize: '11px'}},
+      logSlider('Master', cfg.baseFreqHz ?? 110, 20, 2000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      slider('Ratio', cfg.syncRatio ?? 2, 1, 16, 'x', 2,
+        (v) => { cfg.syncRatio = v; onChange(); }),
+    );
+  },
+});
+
+// SuperOsc --------------------------------------------------------------------
+descriptors.push({
+  protoField: 'super_osc',
+  displayName: 'SuperSaw',
+  description: 'JP-8000 7-saw supersaw',
+  category: 'oscillator',
+  hue: 80,
+  inputs: OSC_INPUTS,
+  outputs: OSC_OUTPUTS,
+  createDefault: (): protos.ISynthModule => ({
+    superOsc: {baseFreqHz: 220, detune: 0.3, mix: 0.5},
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.superOsc!;
+    return m('div', {style: {fontSize: '11px'}},
+      logSlider('Freq', cfg.baseFreqHz ?? 220, 20, 4000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      slider('Detune', cfg.detune ?? 0.3, 0, 1, '', 2,
+        (v) => { cfg.detune = v; onChange(); }),
+      slider('Mix', cfg.mix ?? 0.5, 0, 1, '', 2,
+        (v) => { cfg.mix = v; onChange(); }),
+    );
+  },
+});
+
+// WavetableOsc ----------------------------------------------------------------
+descriptors.push({
+  protoField: 'wavetable_osc',
+  displayName: 'Wavetable',
+  description: 'Procedural wavetable oscillator (4 tables)',
+  category: 'oscillator',
+  hue: 90,
+  inputs: [
+    {name: 'freq', kind: 'freq'},
+    {name: 'freq_mod', kind: 'cv'},
+    {name: 'position_mod', kind: 'cv'},
+  ],
+  outputs: OSC_OUTPUTS,
+  createDefault: (): protos.ISynthModule => ({
+    wavetableOsc: {
+      tableType: protos.WavetableOscConfig.TableType.SINE_TO_SAW,
+      baseFreqHz: 220, basePosition: 0,
+    },
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.wavetableOsc!;
+    return m('div', {style: {fontSize: '11px'}},
+      dropdown('Table', cfg.tableType ?? 0, [
+        {value: 0, label: 'Sine→Saw'},
+        {value: 1, label: 'Pulse Sweep'},
+        {value: 2, label: 'Bell'},
+        {value: 3, label: 'Vocal'},
+      ], (v) => { cfg.tableType = v; onChange(); }),
+      logSlider('Freq', cfg.baseFreqHz ?? 220, 20, 4000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      slider('Pos', cfg.basePosition ?? 0, 0, 1, '', 2,
+        (v) => { cfg.basePosition = v; onChange(); }),
+    );
+  },
+});
+
+// NoiseOsc --------------------------------------------------------------------
+descriptors.push({
+  protoField: 'noise_osc',
+  displayName: 'Noise',
+  description: 'Tilted noise (white → pink → brown)',
+  category: 'oscillator',
+  hue: 100,
+  inputs: [],
+  outputs: [{name: 'out', kind: 'audio'}],
+  createDefault: (): protos.ISynthModule => ({
+    noiseOsc: {tilt: 0.5, seed: 0},
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.noiseOsc!;
+    const tilt = cfg.tilt ?? 0.5;
+    const flavor =
+      tilt < 0.2 ? 'white' :
+      tilt < 0.7 ? 'pink' : 'brown';
+    return m('div', {style: {fontSize: '11px'}},
+      slider('Tilt', tilt, 0, 1, '', 2,
+        (v) => { cfg.tilt = v; onChange(); }),
+      m('div', {
+        style: {
+          fontSize: '9px', color: '#888', textAlign: 'right',
+          fontStyle: 'italic',
+        },
+      }, `(${flavor})`),
+      slider('Seed', cfg.seed ?? 0, 0, 1024, '', 0,
+        (v) => { cfg.seed = Math.round(v); onChange(); }),
+    );
+  },
+});
+
+// DrawbarOrgan ----------------------------------------------------------------
+const DRAWBAR_DEFS: ReadonlyArray<{
+  field: keyof protos.IDrawbarOrganConfig;
+  label: string;
+}> = [
+  {field: 'db16',    label: '16'},
+  {field: 'db5_1_3', label: '5⅓'},
+  {field: 'db8',     label: '8'},
+  {field: 'db4',     label: '4'},
+  {field: 'db2_2_3', label: '2⅔'},
+  {field: 'db2',     label: '2'},
+  {field: 'db1_3_5', label: '1⅗'},
+  {field: 'db1_1_3', label: '1⅓'},
+  {field: 'db1',     label: '1'},
+];
+
+const DRAWBAR_PRESETS: ReadonlyArray<{
+  name: string;
+  values: ReadonlyArray<number>;
+}> = [
+  {name: 'Full Organ',  values: [1, 1, 1, 1, 1, 1, 1, 1, 1]},
+  {name: 'Jazz',        values: [0.875, 0.875, 0.875, 0, 0, 0, 0, 0, 0]},
+  {name: 'Gospel',      values: [0.75, 0.625, 0.875, 0, 0, 0, 0, 0, 0]},
+  {name: 'Bright Lead', values: [0.875, 0.625, 0.875, 0.625, 0.625, 0.625, 0.5, 0.375, 0.875]},
+  {name: 'Whistle',     values: [0, 0, 0.875, 0, 0, 0, 0, 0, 0.875]},
+];
+
+descriptors.push({
+  protoField: 'drawbar_organ',
+  displayName: 'Drawbar Organ',
+  description: 'Hammond B3 9-drawbar additive synth',
+  category: 'oscillator',
+  hue: 110,
+  inputs: [{name: 'freq', kind: 'freq'}],
+  outputs: [{name: 'out', kind: 'audio'}],
+  createDefault: (): protos.ISynthModule => ({
+    drawbarOrgan: {
+      baseFreqHz: 220,
+      db16: 0.875, db5_1_3: 0.875, db8: 0.875,
+      db4: 0, db2_2_3: 0, db2: 0,
+      db1_3_5: 0, db1_1_3: 0, db1: 0,
+    },
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.drawbarOrgan! as Record<string, number | undefined>;
+    return m('div', {style: {fontSize: '11px', minWidth: '220px'}},
+      logSlider('Freq', (cfg.baseFreqHz as number) ?? 220, 20, 2000, 'Hz',
+        (v) => { cfg.baseFreqHz = v; onChange(); }),
+      // 9 vertical drawbars in a row.
+      m('div', {
+        style: {
+          display: 'flex', justifyContent: 'space-between',
+          padding: '4px 2px 2px 2px', background: '#2a2a2e',
+          borderRadius: '3px', marginTop: '4px',
+        },
+      },
+        DRAWBAR_DEFS.map((d) => verticalSlider(
+          d.label,
+          (cfg[d.field] as number) ?? 0,
+          (v) => { cfg[d.field] = v; onChange(); },
+        )),
+      ),
+      // Preset chips.
+      m('div', {
+        style: {
+          display: 'flex', flexWrap: 'wrap', gap: '2px', marginTop: '4px',
+        },
+      },
+        DRAWBAR_PRESETS.map((p) =>
+          m('button', {
+            style: {
+              fontSize: '9px', padding: '1px 4px', cursor: 'pointer',
+              border: '1px solid #ccc', background: '#f8f8f8',
+              borderRadius: '2px',
+            },
+            onclick: (e: Event) => {
+              e.stopPropagation();
+              for (let i = 0; i < DRAWBAR_DEFS.length; i++) {
+                cfg[DRAWBAR_DEFS[i].field] = p.values[i];
+              }
+              onChange();
+            },
+            onmousedown: (e: Event) => e.stopPropagation(),
+          }, p.name),
+        ),
+      ),
+    );
+  },
+});
+
+// Lfo -------------------------------------------------------------------------
+descriptors.push({
+  protoField: 'lfo',
+  displayName: 'LFO',
+  description: 'Low-frequency oscillator (control-rate modulation)',
+  category: 'modulator',
+  hue: 260,
+  inputs: [],
+  outputs: [{name: 'out', kind: 'cv'}],
+  createDefault: (): protos.ISynthModule => ({
+    lfo: {
+      waveform: protos.LfoConfig.Waveform.SINE,
+      rateHz: 2, depth: 1, bipolar: true, seed: 0,
+    },
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.lfo!;
+    return m('div', {style: {fontSize: '11px'}},
+      dropdown('Wave', cfg.waveform ?? 0, [
+        {value: 0, label: 'Sine'},
+        {value: 1, label: 'Triangle'},
+        {value: 2, label: 'Square'},
+        {value: 3, label: 'Saw Up'},
+        {value: 4, label: 'Saw Down'},
+        {value: 5, label: 'Sample & Hold'},
+      ], (v) => { cfg.waveform = v; onChange(); }),
+      logSlider('Rate', cfg.rateHz ?? 2, 0.01, 50, 'Hz',
+        (v) => { cfg.rateHz = v; onChange(); }),
+      slider('Depth', cfg.depth ?? 1, 0, 1, '', 2,
+        (v) => { cfg.depth = v; onChange(); }),
+      checkbox('Bipolar', cfg.bipolar ?? true,
+        (v) => { cfg.bipolar = v; onChange(); }),
+    );
+  },
+});
+
+// Delay -----------------------------------------------------------------------
+descriptors.push({
+  protoField: 'delay',
+  displayName: 'Delay',
+  description: 'Feedback delay with damping (dub-style)',
+  category: 'effect',
+  hue: 170,
+  inputs: [{name: 'in', kind: 'audio'}],
+  outputs: [{name: 'out', kind: 'audio'}],
+  createDefault: (): protos.ISynthModule => ({
+    delay: {timeMs: 250, feedback: 0.4, damping: 0.3, mix: 0.4},
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.delay!;
+    return m('div', {style: {fontSize: '11px'}},
+      logSlider('Time', cfg.timeMs ?? 250, 1, 2000, 'ms',
+        (v) => { cfg.timeMs = v; onChange(); }),
+      slider('Feedback', cfg.feedback ?? 0.4, 0, 0.95, '', 2,
+        (v) => { cfg.feedback = v; onChange(); }),
+      slider('Damping', cfg.damping ?? 0.3, 0, 0.99, '', 2,
+        (v) => { cfg.damping = v; onChange(); }),
+      slider('Mix', cfg.mix ?? 0.4, 0, 1, '', 2,
+        (v) => { cfg.mix = v; onChange(); }),
+    );
+  },
+});
+
+// Chorus ----------------------------------------------------------------------
+descriptors.push({
+  protoField: 'chorus',
+  displayName: 'Chorus',
+  description: 'Multi-voice modulated-delay chorus',
+  category: 'effect',
+  hue: 160,
+  inputs: [{name: 'in', kind: 'audio'}],
+  outputs: [{name: 'out', kind: 'audio'}],
+  createDefault: (): protos.ISynthModule => ({
+    chorus: {
+      rateHz: 0.5, depthMs: 4, midDelayMs: 15, mix: 0.5, voices: 3,
+    },
+  }),
+  renderParams: (mod, onChange) => {
+    const cfg = mod.chorus!;
+    return m('div', {style: {fontSize: '11px'}},
+      logSlider('Rate', cfg.rateHz ?? 0.5, 0.01, 10, 'Hz',
+        (v) => { cfg.rateHz = v; onChange(); }),
+      slider('Depth', cfg.depthMs ?? 4, 0, 40, 'ms', 1,
+        (v) => { cfg.depthMs = v; onChange(); }),
+      slider('Center', cfg.midDelayMs ?? 15, 1, 30, 'ms', 1,
+        (v) => { cfg.midDelayMs = v; onChange(); }),
+      slider('Voices', cfg.voices ?? 3, 1, 8, '', 0,
+        (v) => { cfg.voices = Math.round(v); onChange(); }),
+      slider('Mix', cfg.mix ?? 0.5, 0, 1, '', 2,
+        (v) => { cfg.mix = v; onChange(); }),
+    );
+  },
+});
+
+// --- Generic descriptors for legacy blocks ---
 
 const GENERIC_HUE: Record<string, number> = {
-  classic_osc: 30,
-  fm_osc: 40,
-  phase_distortion_osc: 50,
-  fold_osc: 60,
-  sync_osc: 70,
-  super_osc: 80,
-  wavetable_osc: 90,
-  noise_osc: 100,
-  drawbar_organ: 110,
-  lfo: 260,
-  chorus: 160,
-  delay: 170,
   envelope: 250,
   vco: 25,
 };
 
-// Oscillators: all have a freq_in port and out port.
-function oscPorts(): {inputs: PortSpec[]; outputs: PortSpec[]} {
-  return {
-    inputs: [
-      {name: 'freq', kind: 'freq'},
-      {name: 'freq_mod', kind: 'cv'},
-    ],
-    outputs: [{name: 'out', kind: 'audio'}],
-  };
-}
-
 function buildGenericParams(fieldName: string, mod: protos.ISynthModule) {
   const obj = (mod as Record<string, unknown>)[camelize(fieldName)];
   if (!obj || typeof obj !== 'object') {
@@ -449,86 +999,6 @@
   });
 }
 
-addGeneric(
-  'fm_osc', 'FM Osc', '2-op FM oscillator',
-  'oscillator', oscPorts(),
-  (): protos.ISynthModule => ({
-    fmOsc: {baseFreqHz: 220, modRatio: 1, modIndex: 1, feedback: 0},
-  }),
-);
-addGeneric(
-  'phase_distortion_osc', 'Phase Dist', 'Casio CZ-style phase warp',
-  'oscillator', oscPorts(),
-  (): protos.ISynthModule => ({phaseDistortionOsc: {baseFreqHz: 220}}),
-);
-addGeneric(
-  'fold_osc', 'Fold Osc', 'Wavefolder oscillator',
-  'oscillator', oscPorts(),
-  (): protos.ISynthModule => ({foldOsc: {baseFreqHz: 220}}),
-);
-addGeneric(
-  'sync_osc', 'Sync Osc', 'Hardsync oscillator',
-  'oscillator', oscPorts(),
-  (): protos.ISynthModule => ({syncOsc: {}}),
-);
-addGeneric(
-  'super_osc', 'SuperSaw', 'JP-8000 7-saw supersaw',
-  'oscillator', oscPorts(),
-  (): protos.ISynthModule => ({superOsc: {baseFreqHz: 220}}),
-);
-addGeneric(
-  'wavetable_osc', 'Wavetable', 'Wavetable oscillator (4 tables)',
-  'oscillator', {
-    inputs: [
-      {name: 'freq', kind: 'freq'},
-      {name: 'freq_mod', kind: 'cv'},
-      {name: 'position_mod', kind: 'cv'},
-    ],
-    outputs: [{name: 'out', kind: 'audio'}],
-  },
-  (): protos.ISynthModule => ({wavetableOsc: {baseFreqHz: 220}}),
-);
-addGeneric(
-  'noise_osc', 'Noise', 'Tilted white/pink/brown noise',
-  'oscillator',
-  {inputs: [], outputs: [{name: 'out', kind: 'audio'}]},
-  (): protos.ISynthModule => ({noiseOsc: {tilt: 0.5}}),
-);
-addGeneric(
-  'drawbar_organ', 'Drawbar Organ', 'Hammond B3 additive synth',
-  'oscillator',
-  {
-    inputs: [{name: 'freq', kind: 'freq'}],
-    outputs: [{name: 'out', kind: 'audio'}],
-  },
-  (): protos.ISynthModule => ({drawbarOrgan: {baseFreqHz: 220}}),
-);
-addGeneric(
-  'lfo', 'LFO', 'Low-frequency oscillator',
-  'modulator',
-  {inputs: [], outputs: [{name: 'out', kind: 'cv'}]},
-  (): protos.ISynthModule => ({lfo: {rateHz: 2, bipolar: true}}),
-);
-addGeneric(
-  'chorus', 'Chorus', 'Multi-voice modulated delay',
-  'effect',
-  {
-    inputs: [{name: 'in', kind: 'audio'}],
-    outputs: [{name: 'out', kind: 'audio'}],
-  },
-  (): protos.ISynthModule => ({chorus: {rateHz: 0.5, depthMs: 4, mix: 0.5}}),
-);
-addGeneric(
-  'delay', 'Delay', 'Circular-buffer feedback delay',
-  'effect',
-  {
-    inputs: [{name: 'in', kind: 'audio'}],
-    outputs: [{name: 'out', kind: 'audio'}],
-  },
-  (): protos.ISynthModule => ({delay: {
-    timeMs: 250, feedback: 0.4, mix: 0.3,
-  }}),
-);
 // Legacy blocks (kept for backward compat with old presets / UI).
 addGeneric(
   'vco', 'VCO (legacy)', 'Naive oscillator (prefer ClassicOsc)',
diff --git a/ui/src/plugins/dev.perfetto.SoundSynth/instrument_canvas.ts b/ui/src/plugins/dev.perfetto.SoundSynth/instrument_canvas.ts
index eacf84a..801fa7c 100644
--- a/ui/src/plugins/dev.perfetto.SoundSynth/instrument_canvas.ts
+++ b/ui/src/plugins/dev.perfetto.SoundSynth/instrument_canvas.ts
@@ -22,7 +22,6 @@
   Node,
   Connection,
   NodeGraph,
-  NodeGraphApi,
 } from '../../widgets/nodegraph';
 import {MenuItem} from '../../widgets/menu';
 import {
@@ -43,6 +42,7 @@
   getAllDescriptors,
   descriptorsByCategory,
   BlockCategory,
+  portContent,
 } from './block_registry';
 
 // Reserved canvas node IDs (not stored in proto).
@@ -60,9 +60,6 @@
 export class InstrumentCanvas
 implements m.ClassComponent<InstrumentCanvasAttrs> {
   private showBlockPalette = false;
-  // The NodeGraph API handle (autoLayout, recenter, etc.), available
-  // once the NodeGraph has mounted.
-  private nodeGraphApi: NodeGraphApi | null = null;
   // Track which instrument we've already auto-laid-out so we only do
   // it once per instrument (after the DOM is mounted and we have real
   // node dimensions).
@@ -259,7 +256,6 @@
             this.selectedNodeId = null;
           },
           onReady: (api) => {
-            this.nodeGraphApi = api;
             // If we've just switched to a new instrument, trigger an
             // auto-layout pass on the next frame. The NodeGraph's
             // autoLayout uses real measured DOM sizes so it avoids the
@@ -587,8 +583,8 @@
       hue: 160,
       titleBar: {title: 'INPUT', icon: 'input'},
       outputs: [
-        {direction: 'right' as const, content: 'gate'},
-        {direction: 'right' as const, content: 'freq'},
+        {direction: 'right' as const, content: portContent('gate', 'gate')},
+        {direction: 'right' as const, content: portContent('freq', 'freq')},
       ],
       content: m('div', {
         style: {
@@ -613,7 +609,10 @@
       y: ui.outY ?? 280,
       hue: 200,
       titleBar: {title: 'OUTPUT', icon: 'output'},
-      inputs: [{direction: 'left' as const, content: 'in'}],
+      inputs: [{
+        direction: 'left' as const,
+        content: portContent('in', 'audio'),
+      }],
       content: m('div', {
         style: {
           padding: '8px 12px', fontSize: '10px',
@@ -636,10 +635,16 @@
     const hue = desc?.hue ?? 150;
 
     const inputs = (desc?.inputs ?? []).map(
-      (p) => ({direction: 'left' as const, content: p.name}),
+      (p) => ({
+        direction: 'left' as const,
+        content: portContent(p.name, p.kind),
+      }),
     );
     const outputs = (desc?.outputs ?? []).map(
-      (p) => ({direction: 'right' as const, content: p.name}),
+      (p) => ({
+        direction: 'right' as const,
+        content: portContent(p.name, p.kind),
+      }),
     );
 
     // Use ?? instead of || so that x=0 is treated as a valid position.
diff --git a/ui/src/plugins/dev.perfetto.SoundSynth/rack_canvas.ts b/ui/src/plugins/dev.perfetto.SoundSynth/rack_canvas.ts
index b3088e9..072c7a2 100644
--- a/ui/src/plugins/dev.perfetto.SoundSynth/rack_canvas.ts
+++ b/ui/src/plugins/dev.perfetto.SoundSynth/rack_canvas.ts
@@ -41,7 +41,7 @@
   addWire,
   removeWireAt,
 } from './patch_state';
-import {getDescriptorForModule} from './block_registry';
+import {getDescriptorForModule, portContent} from './block_registry';
 
 const CATEGORY_HUES: Record<string, number> = {
   drum: 0,
@@ -189,6 +189,12 @@
     // treatment), so pressing Delete won't accidentally kill it.
     const selectedIds = new Set<string>();
     if (this.selectedNodeId) selectedIds.add(this.selectedNodeId);
+    // Keep the parent's notion of the selected trace source in sync
+    // with our internal state: if a trace source is shown in the
+    // bottom panel preview, mark it visually selected in the rack too.
+    if (selectedTraceSourceId) {
+      selectedIds.add(`src:${selectedTraceSourceId}`);
+    }
 
     return m(NodeGraph, {
       nodes,
@@ -207,10 +213,16 @@
         this.selectedNodeId = nodeId;
         if (nodeId.startsWith('inst:')) {
           onEditInstrument(nodeId.substring(5));
+          onSelectTraceSource(null);
+        } else if (nodeId.startsWith('src:')) {
+          onSelectTraceSource(nodeId.substring(4));
+        } else {
+          onSelectTraceSource(null);
         }
       },
       onSelectionClear: () => {
         this.selectedNodeId = null;
+        onSelectTraceSource(null);
       },
       onConnect: (conn) => {
         this.handleConnect(conn, patch, view, onChange);
@@ -344,7 +356,10 @@
         title: ui.displayName || 'Trace Source',
         icon: 'track_changes',
       },
-      outputs: [{direction: 'right' as const, content: 'out'}],
+      outputs: [{
+        direction: 'right' as const,
+        content: portContent('out', 'gate'),
+      }],
       content: m('div', {
         style: {padding: '6px 8px', fontSize: '11px', minWidth: '190px'},
       },
@@ -402,11 +417,11 @@
     const isSelected = selectedInstrumentId === instId;
 
     const inputs: NodePort[] = [
-      {direction: 'left' as const, content: 'gate'},
-      {direction: 'left' as const, content: 'freq'},
+      {direction: 'left' as const, content: portContent('gate', 'gate')},
+      {direction: 'left' as const, content: portContent('freq', 'freq')},
     ];
     const outputs: NodePort[] = [
-      {direction: 'right' as const, content: 'out'},
+      {direction: 'right' as const, content: portContent('out', 'audio')},
     ];
 
     const chainPreview = this.computeChainPreview(inst, patch);
@@ -537,7 +552,10 @@
       y: ui.y,
       hue: 200,
       titleBar: {title: 'Master Out', icon: 'volume_up'},
-      inputs: [{direction: 'left' as const, content: 'in'}],
+      inputs: [{
+        direction: 'left' as const,
+        content: portContent('in', 'audio'),
+      }],
       content: m('div', {
         style: {
           padding: '12px 20px', fontSize: '11px',
diff --git a/ui/src/plugins/dev.perfetto.SoundSynth/sound_synth_page.ts b/ui/src/plugins/dev.perfetto.SoundSynth/sound_synth_page.ts
index b0c7afe..165121c 100644
--- a/ui/src/plugins/dev.perfetto.SoundSynth/sound_synth_page.ts
+++ b/ui/src/plugins/dev.perfetto.SoundSynth/sound_synth_page.ts
@@ -40,6 +40,7 @@
   PresetLibrary, PresetEntry, loadPresetLibrary,
 } from './preset_library';
 import {PresetPicker} from './preset_picker';
+import {TraceSourcePreview} from './trace_source_preview';
 
 interface SoundSynthPageAttrs {
   trace: Trace;
@@ -60,6 +61,10 @@
   private presetLibrary: PresetLibrary | null = null;
   private presetPickerTarget: PresetPickerTarget | null = null;
   private selectedInstrumentId: string | null = null;
+  // The currently-selected rack trace source. When set, the bottom
+  // panel shows a signal preview for it instead of the instrument
+  // editor. Selecting an instrument clears this (and vice versa).
+  private selectedTraceSourceId: string | null = null;
 
   async oncreate(vnode: m.VnodeDOM<SoundSynthPageAttrs>) {
     this.trace = vnode.attrs.trace;
@@ -173,23 +178,31 @@
                 patch,
                 view,
                 selectedInstrumentId: editingId,
+                selectedTraceSourceId: this.selectedTraceSourceId,
                 onEditInstrument: (id) => {
                   this.selectedInstrumentId = id;
+                  this.selectedTraceSourceId = null;
                   writePatchUiState(patch, {editingInstrumentId: id});
                 },
                 onTestInstrument: (id) => this.onTestInstrument(id, view),
+                onSelectTraceSource: (id) => {
+                  this.selectedTraceSourceId = id;
+                },
                 onChange: () => { /* mithril auto-redraws */ },
               }),
             ),
           ),
-          // Instrument editor (bottom).
+          // Bottom panel — content depends on selection:
+          //   - instrument selected → instrument editor canvas
+          //   - trace source selected → trace source signal preview
+          //   - nothing selected → hint empty state
           m('.instrument-canvas-wrapper', {
             style: {
               flex: '1 1 0',
               minHeight: '260px',
               position: 'relative',
               overflow: 'hidden',
-              background: editingInst ? 'white' : '#f5f5f5',
+              background: editingInst ? 'white' : '#fbfbfc',
             },
           },
             m('.instrument-canvas-inner', {
@@ -210,7 +223,7 @@
                     },
                     onChange: () => { /* mithril auto-redraws */ },
                   })
-                : this.renderInstrumentEmptyState(),
+                : this.renderTraceSourceOrEmpty(patch),
             ),
           ),
         ),
@@ -337,9 +350,32 @@
       m('div', {style: {fontSize: '24px'}}, '\u266B'),
       m('div', 'Select an instrument on the rack above'),
       m('div', 'and click Edit to view its internal patch'),
+      m('div', {style: {fontSize: '11px', color: '#aaa', marginTop: '6px'}},
+        'Or select a trace source to preview its output signal'),
     );
   }
 
+  /**
+   * When no instrument is being edited, the bottom panel either shows
+   * the trace source signal preview (if a trace source is selected) or
+   * an empty-state hint.
+   */
+  private renderTraceSourceOrEmpty(patch: protos.ISynthPatch): m.Child {
+    if (!this.selectedTraceSourceId || !this.trace) {
+      return this.renderInstrumentEmptyState();
+    }
+    const mod = patch.modules?.find(
+      (m) => m.id === this.selectedTraceSourceId);
+    if (!mod || !mod.traceSliceSource) {
+      // Selection stale — the module may have been deleted.
+      return this.renderInstrumentEmptyState();
+    }
+    return m(TraceSourcePreview, {
+      trace: this.trace,
+      module: mod,
+    });
+  }
+
   private computeBindingCounts(view: PatchView): Map<string, number> {
     // Show, per process, how many trace sources reference it.
     const counts = new Map<string, number>();
diff --git a/ui/src/plugins/dev.perfetto.SoundSynth/trace_source_preview.ts b/ui/src/plugins/dev.perfetto.SoundSynth/trace_source_preview.ts
new file mode 100644
index 0000000..f692c9d
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.SoundSynth/trace_source_preview.ts
@@ -0,0 +1,459 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Trace source preview panel.
+//
+// When a TraceSliceSource node is selected on the rack canvas, this
+// component renders a timeline visualization of the slices it would
+// match over the current render window. The visualization mirrors the
+// signal that TP's PopulateTraceSources would produce:
+//
+// - GATE:    horizontal blocks covering the duration of each slice
+// - TRIGGER: a vertical tick at the start of each slice
+// - DENSITY: a heat strip proportional to the number of overlapping
+//            slices at each column
+//
+// The query is constrained to the current trace.timeline.visibleWindow
+// so we don't pathologically over-fetch on traces with huge ranges.
+
+import m from 'mithril';
+import protos from '../../protos';
+import {Trace} from '../../public/trace';
+import {NUM, LONG} from '../../trace_processor/query_result';
+
+export interface TraceSourcePreviewAttrs {
+  trace: Trace;
+  module: protos.ISynthModule;
+}
+
+interface SliceRow {
+  ts: number;       // ns since the visible-window start
+  dur: number;      // ns
+}
+
+interface PreviewData {
+  slices: SliceRow[];
+  rangeStart: number;  // ns, zero-based
+  rangeEnd: number;    // ns, zero-based
+  totalSlices: number;
+}
+
+export class TraceSourcePreview
+  implements m.ClassComponent<TraceSourcePreviewAttrs> {
+  private data: PreviewData | null = null;
+  private loading = false;
+  private lastQueriedKey = '';
+  private error: string | null = null;
+
+  view(vnode: m.Vnode<TraceSourcePreviewAttrs>) {
+    const {trace, module} = vnode.attrs;
+    const cfg = module.traceSliceSource;
+    if (!cfg) {
+      return m('.trace-source-preview-empty',
+        {style: {padding: '12px', color: '#888'}},
+        'Not a trace source.');
+    }
+
+    const glob = cfg.trackNameGlob ?? '*';
+    const sliceNameGlob = cfg.sliceNameGlob ?? '';
+    const maxDepth = cfg.maxDepth ?? 0;
+    const signalType = cfg.signalType ?? 0;
+
+    // Query key — re-fetch when any of these change.
+    const visible = trace.timeline.visibleWindow.toTimeSpan();
+    const startTs = Number(visible.start);
+    const endTs = Number(visible.end);
+    const key = [
+      module.id, glob, sliceNameGlob, maxDepth, startTs, endTs,
+    ].join('|');
+    if (key !== this.lastQueriedKey) {
+      this.lastQueriedKey = key;
+      void this.refetch(trace, glob, sliceNameGlob, maxDepth,
+                        startTs, endTs);
+    }
+
+    return m('.trace-source-preview', {
+      style: {
+        padding: '10px 16px',
+        height: '100%',
+        overflowY: 'auto',
+        fontSize: '12px',
+        fontFamily: 'Roboto, sans-serif',
+      },
+    },
+      this.renderHeader(module, glob, sliceNameGlob, maxDepth, signalType),
+      this.renderTimeline(signalType, startTs, endTs),
+      this.renderStats(startTs, endTs),
+      this.renderLegend(signalType),
+    );
+  }
+
+  private renderHeader(
+    module: protos.ISynthModule,
+    glob: string,
+    sliceNameGlob: string,
+    maxDepth: number,
+    signalType: number,
+  ): m.Child {
+    const signalName = ['Gate', 'Trigger', 'Density'][signalType] ?? '?';
+    return m('.trace-source-header', {
+      style: {
+        display: 'flex',
+        alignItems: 'center',
+        gap: '10px',
+        marginBottom: '10px',
+        paddingBottom: '8px',
+        borderBottom: '1px solid #e0e0e0',
+      },
+    },
+      m('span', {
+        style: {
+          fontSize: '14px', fontWeight: 'bold',
+          color: 'hsl(140, 60%, 30%)',
+        },
+      }, 'Trace Source Preview'),
+      m('span', {style: {color: '#999'}}, '·'),
+      m('span',
+        {style: {fontFamily: 'monospace', color: '#333'}},
+        module.id ?? ''),
+      m('.spacer', {style: {flex: '1'}}),
+      m('span', {style: {color: '#666'}},
+        `Glob: `,
+        m('code', {style: {background: '#f0f0f0', padding: '1px 4px'}},
+          glob || '*'),
+      ),
+      sliceNameGlob
+        ? m('span', {style: {color: '#666'}},
+            `Slice: `,
+            m('code', {style: {background: '#f0f0f0', padding: '1px 4px'}},
+              sliceNameGlob))
+        : null,
+      maxDepth > 0
+        ? m('span', {style: {color: '#666'}}, `Max depth: ${maxDepth}`)
+        : null,
+      m('span', {
+        style: {
+          color: 'white',
+          background: 'hsl(140, 60%, 35%)',
+          padding: '2px 8px',
+          borderRadius: '3px',
+          fontWeight: 'bold',
+        },
+      }, `Signal: ${signalName}`),
+    );
+  }
+
+  private renderTimeline(
+    signalType: number, startTs: number, endTs: number,
+  ): m.Child {
+    return m('.trace-source-timeline-wrap', {
+      style: {
+        position: 'relative',
+        background: '#fafafa',
+        border: '1px solid #ddd',
+        borderRadius: '3px',
+        height: '110px',
+        marginBottom: '10px',
+      },
+    },
+      m('canvas.trace-source-canvas', {
+        style: {
+          display: 'block',
+          width: '100%',
+          height: '100%',
+        },
+        oncreate: (v: m.VnodeDOM) =>
+          this.drawCanvas(v.dom as HTMLCanvasElement,
+                          signalType, startTs, endTs),
+        onupdate: (v: m.VnodeDOM) =>
+          this.drawCanvas(v.dom as HTMLCanvasElement,
+                          signalType, startTs, endTs),
+      }),
+      this.loading
+        ? m('.loading-overlay', {
+            style: {
+              position: 'absolute',
+              top: '0', left: '0', right: '0', bottom: '0',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              color: '#888',
+              background: 'rgba(250, 250, 250, 0.85)',
+            },
+          }, 'Loading slices…')
+        : null,
+      this.error
+        ? m('.error-overlay', {
+            style: {
+              position: 'absolute',
+              top: '0', left: '0', right: '0', bottom: '0',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              color: '#c62828',
+              background: 'rgba(255, 235, 238, 0.9)',
+              padding: '8px',
+              fontSize: '11px',
+            },
+          }, `Error: ${this.error}`)
+        : null,
+    );
+  }
+
+  private renderStats(startTs: number, endTs: number): m.Child {
+    const durNs = endTs - startTs;
+    const durMs = durNs / 1e6;
+    const durStr = durMs >= 1000
+      ? `${(durMs / 1000).toFixed(2)} s`
+      : `${durMs.toFixed(1)} ms`;
+    const sliceCount = this.data?.slices.length ?? 0;
+    const totalCount = this.data?.totalSlices ?? sliceCount;
+    const rate = durNs > 0 && sliceCount > 0
+      ? (sliceCount * 1e9 / durNs).toFixed(1)
+      : '-';
+    return m('.trace-source-stats', {
+      style: {
+        display: 'flex',
+        gap: '18px',
+        fontSize: '11px',
+        color: '#555',
+        marginBottom: '10px',
+      },
+    },
+      m('span', `Window: `,
+        m('strong', {style: {color: '#333'}}, durStr)),
+      m('span', `Slices: `,
+        m('strong', {style: {color: '#333'}}, `${sliceCount}`),
+        totalCount > sliceCount
+          ? ` (truncated from ${totalCount})`
+          : ''),
+      m('span', `Rate: `,
+        m('strong', {style: {color: '#333'}}, `${rate} /s`)),
+    );
+  }
+
+  private renderLegend(signalType: number): m.Child {
+    const explanations = [
+      // GATE
+      'GATE is high (1.0) whenever any matching slice is active. ' +
+      'Rising edge = slice starts, falling edge = slice ends. ' +
+      'Drives ADSR envelopes so each slice becomes one note whose ' +
+      'length matches the slice duration.',
+      // TRIGGER
+      'TRIGGER is a single-sample 1.0 impulse at each slice START. ' +
+      'Duration is ignored. Used for percussion: one hit per slice, ' +
+      'regardless of slice length. ' +
+      '(Not yet implemented in TP — shown for design reference.)',
+      // DENSITY
+      'DENSITY is a continuous 0..1 CV proportional to the count of ' +
+      'overlapping active slices. Used as a modulation source: filter ' +
+      'cutoff, drive, LFO depth, etc. ' +
+      '(Not yet implemented in TP — shown for design reference.)',
+    ];
+    return m('.trace-source-legend', {
+      style: {
+        fontSize: '11px',
+        color: '#555',
+        lineHeight: '1.5',
+        padding: '8px 10px',
+        background: '#f8fafd',
+        borderLeft: '3px solid hsl(140, 60%, 50%)',
+        borderRadius: '2px',
+      },
+    },
+      m('strong', {style: {color: '#333'}},
+        ['Gate', 'Trigger', 'Density'][signalType] ?? 'Signal'),
+      ' — ',
+      explanations[signalType] ?? '',
+    );
+  }
+
+  // Redraws the canvas with the current `data` and signal type.
+  private drawCanvas(
+    canvas: HTMLCanvasElement,
+    signalType: number,
+    startTs: number,
+    endTs: number,
+  ) {
+    // Size the canvas using its CSS box.
+    const rect = canvas.getBoundingClientRect();
+    if (rect.width === 0 || rect.height === 0) return;
+    const dpr = window.devicePixelRatio || 1;
+    const w = Math.round(rect.width * dpr);
+    const h = Math.round(rect.height * dpr);
+    if (canvas.width !== w) canvas.width = w;
+    if (canvas.height !== h) canvas.height = h;
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+
+    ctx.clearRect(0, 0, w, h);
+
+    // Background grid.
+    ctx.fillStyle = '#fafafa';
+    ctx.fillRect(0, 0, w, h);
+    ctx.strokeStyle = '#e8e8e8';
+    ctx.lineWidth = 1;
+    for (let i = 1; i < 10; i++) {
+      const x = Math.round((i / 10) * w) + 0.5;
+      ctx.beginPath();
+      ctx.moveTo(x, 0);
+      ctx.lineTo(x, h);
+      ctx.stroke();
+    }
+    // Baseline (0.0) and top (1.0).
+    const yBase = Math.round(h - 10);
+    const yTop = Math.round(10);
+    ctx.strokeStyle = '#cccccc';
+    ctx.beginPath();
+    ctx.moveTo(0, yBase + 0.5);
+    ctx.lineTo(w, yBase + 0.5);
+    ctx.stroke();
+
+    if (!this.data || this.data.slices.length === 0) {
+      ctx.fillStyle = '#999';
+      ctx.font = `${12 * dpr}px Roboto, sans-serif`;
+      ctx.textAlign = 'center';
+      ctx.textBaseline = 'middle';
+      ctx.fillText(this.loading
+        ? 'Loading…'
+        : 'No slices match this glob in the current time window',
+        w / 2, h / 2);
+      return;
+    }
+
+    const durNs = Math.max(1, endTs - startTs);
+    const toX = (ns: number) => Math.round((ns / durNs) * w);
+
+    if (signalType === 0) {
+      // GATE — paint rectangles from (ts, 0) to (ts+dur, 1).
+      ctx.fillStyle = 'hsla(140, 60%, 45%, 0.85)';
+      for (const s of this.data.slices) {
+        const x1 = toX(s.ts);
+        const x2 = Math.max(x1 + 1, toX(s.ts + s.dur));
+        ctx.fillRect(x1, yTop, x2 - x1, yBase - yTop);
+      }
+    } else if (signalType === 1) {
+      // TRIGGER — vertical tick at each slice start.
+      ctx.strokeStyle = 'hsla(30, 80%, 45%, 0.85)';
+      ctx.lineWidth = 2 * dpr;
+      for (const s of this.data.slices) {
+        const x = toX(s.ts) + 0.5;
+        ctx.beginPath();
+        ctx.moveTo(x, yTop);
+        ctx.lineTo(x, yBase);
+        ctx.stroke();
+      }
+    } else if (signalType === 2) {
+      // DENSITY — count overlapping slices per column.
+      const cols = Math.max(1, Math.floor(rect.width));
+      const counts = new Float32Array(cols);
+      for (const s of this.data.slices) {
+        const c1 = Math.floor((s.ts / durNs) * cols);
+        const c2 = Math.max(c1 + 1,
+          Math.floor(((s.ts + s.dur) / durNs) * cols));
+        for (let c = c1; c < c2 && c < cols; c++) {
+          if (c >= 0) counts[c]++;
+        }
+      }
+      let maxCount = 1;
+      for (const c of counts) if (c > maxCount) maxCount = c;
+      ctx.fillStyle = 'hsla(220, 60%, 45%, 0.85)';
+      for (let c = 0; c < cols; c++) {
+        const norm = counts[c] / maxCount;
+        const barH = Math.round(norm * (yBase - yTop));
+        if (barH > 0) {
+          const xp = Math.round((c / cols) * w);
+          const xpNext = Math.round(((c + 1) / cols) * w);
+          ctx.fillRect(xp, yBase - barH, xpNext - xp, barH);
+        }
+      }
+    }
+  }
+
+  private async refetch(
+    trace: Trace,
+    trackGlob: string,
+    sliceNameGlob: string,
+    maxDepth: number,
+    startTs: number,
+    endTs: number,
+  ) {
+    this.loading = true;
+    this.error = null;
+    this.data = null;
+    m.redraw();
+
+    // Cap the number of slices we fetch so huge windows don't kill us.
+    const MAX_SLICES = 20000;
+
+    try {
+      const clauses: string[] = [
+        's.dur > 0',
+        `s.ts >= ${startTs}`,
+        `s.ts < ${endTs}`,
+      ];
+      if (trackGlob && trackGlob !== '*') {
+        // Escape single quotes inside the glob value.
+        const esc = trackGlob.replace(/'/g, `''`);
+        clauses.push(`t.name GLOB '${esc}'`);
+      }
+      if (sliceNameGlob) {
+        const esc = sliceNameGlob.replace(/'/g, `''`);
+        clauses.push(`s.name GLOB '${esc}'`);
+      }
+      if (maxDepth > 0) {
+        clauses.push(`s.depth <= ${maxDepth}`);
+      }
+      const where = clauses.join(' AND ');
+
+      // First: total count (for the "truncated from X" info).
+      const totalResult = await trace.engine.query(
+        `SELECT count(*) AS cnt FROM slice s
+         LEFT JOIN track t ON s.track_id = t.id
+         WHERE ${where}`);
+      const total = totalResult.firstRow({cnt: NUM}).cnt;
+
+      // Then: up to MAX_SLICES rows.
+      const result = await trace.engine.query(
+        `SELECT s.ts AS ts, s.dur AS dur FROM slice s
+         LEFT JOIN track t ON s.track_id = t.id
+         WHERE ${where}
+         ORDER BY s.ts
+         LIMIT ${MAX_SLICES}`);
+
+      const slices: SliceRow[] = [];
+      const it = result.iter({ts: LONG, dur: LONG});
+      for (; it.valid(); it.next()) {
+        // Normalize: store offsets from startTs so the canvas maps
+        // easily to [0, endTs - startTs].
+        slices.push({
+          ts: Number(it.ts - BigInt(startTs)),
+          dur: Number(it.dur),
+        });
+      }
+
+      this.data = {
+        slices,
+        rangeStart: 0,
+        rangeEnd: endTs - startTs,
+        totalSlices: total,
+      };
+    } catch (e) {
+      this.error = String(e);
+    } finally {
+      this.loading = false;
+      m.redraw();
+    }
+  }
+}
diff --git a/ui/src/test/sound_synth_milestone2.test.ts b/ui/src/test/sound_synth_milestone2.test.ts
new file mode 100644
index 0000000..6d70913
--- /dev/null
+++ b/ui/src/test/sound_synth_milestone2.test.ts
@@ -0,0 +1,142 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Smoke test for the SoundSynth plugin (Trace-To-Techno) milestone 2:
+// exercises the new block parameter panels and colored ports by:
+//   1. Loading a trace
+//   2. Navigating to the sound_synth page
+//   3. Adding a preset instrument from the picker
+//   4. Opening the instrument editor
+//   5. Adding several blocks via the palette so each new panel renders
+//   6. Clicking a drawbar organ preset chip
+//
+// The whole flow runs as a single Playwright test so we don't depend on
+// page state persisting between sibling tests in serial mode.
+
+import {test, expect} from '@playwright/test';
+import {PerfettoTestHelper} from './perfetto_ui_test_helper';
+
+test('sound synth milestone 2: panels + ports', async ({browser}) => {
+  test.setTimeout(120_000);
+  const page = await browser.newPage();
+  const pth = new PerfettoTestHelper(page);
+
+  // Any small trace works: the plugin only needs `onTraceLoad` to fire.
+  await pth.openTraceFile('api24_startup_cold.perfetto-trace');
+  await pth.navigate('#!/sound_synth');
+
+  // Wait for the preset library fetch + parse to settle.
+  await page.waitForSelector('.sound-synth-page');
+  await page.waitForFunction(() =>
+    !document.querySelector('.sound-synth-page')!.textContent!.includes(
+      'Loading track data and preset library',
+    ),
+  );
+  await pth.waitForPerfettoIdle();
+
+  await expect(page.locator('.rack-canvas-wrapper')).toBeVisible();
+  await expect(page.locator('.instrument-canvas-wrapper')).toBeVisible();
+  await pth.waitForIdleAndScreenshot('00_initial.png');
+
+  // --- Add an instrument from the preset picker. ---
+  await page.getByRole('button', {name: '+ Instrument'}).click();
+  await expect(page.locator('.preset-picker')).toBeVisible();
+  // Filter to "lead" so we get a representative chain.
+  await page.locator('.preset-picker-cats button', {hasText: 'Lead'}).click();
+  await page.locator('.preset-entry').first().click();
+  await expect(page.locator('.preset-picker')).toHaveCount(0);
+  await pth.waitForPerfettoIdle();
+  await pth.waitForIdleAndScreenshot('01_preset_loaded.png');
+
+  // --- Open the instrument editor by clicking the Edit button. ---
+  await page.getByRole('button', {name: 'Edit'}).first().click();
+  await expect(page.locator('.instrument-toolbar')).toBeVisible();
+  await pth.waitForPerfettoIdle();
+  await pth.waitForIdleAndScreenshot('02_editor_open.png');
+
+  // --- Add new blocks via the palette so every milestone-2 panel renders. ---
+  const newBlocks = [
+    'FM Osc',
+    'SuperSaw',
+    'Wavetable',
+    'Noise',
+    'LFO',
+    'Delay',
+    'Chorus',
+    'Drawbar Organ',
+    'Phase Dist',
+    'Fold Osc',
+    'Sync Osc',
+  ];
+
+  for (const name of newBlocks) {
+    if (!(await page.locator('.block-palette').isVisible())) {
+      await page.getByRole('button', {name: '+ Add Block'}).click();
+      await expect(page.locator('.block-palette')).toBeVisible();
+    }
+    await page.locator('.block-palette button', {hasText: name})
+      .first().click();
+    await pth.waitForPerfettoIdle();
+  }
+
+  // Close the palette if still open so it doesn't cover the canvas.
+  if (await page.locator('.block-palette').isVisible()) {
+    await page.getByRole('button', {name: 'Close Palette'}).click();
+  }
+  await pth.waitForIdleAndScreenshot('03_all_blocks.png');
+
+  // --- Drawbar organ: verify panel rendered + click the "Jazz" chip. ---
+  // The drawbar organ may be placed off-screen on the canvas, so we
+  // scroll its node into view and use `force: true` to bypass the
+  // canvas hit-testing that can otherwise hide hits behind nodes.
+  const drawbarNode = page.locator('.pf-node', {hasText: 'Drawbar Organ'});
+  await expect(drawbarNode).toHaveCount(1);
+  await drawbarNode.scrollIntoViewIfNeeded();
+  // The Jazz preset chip should exist as a button inside the drawbar node.
+  const jazzBtn = drawbarNode.locator('button', {hasText: 'Jazz'});
+  await expect(jazzBtn).toHaveCount(1);
+  await jazzBtn.click({force: true});
+  await pth.waitForPerfettoIdle();
+  await pth.waitForIdleAndScreenshot('04_drawbar_jazz.png');
+
+  // --- High-res focused screenshots of individual panels. ---
+  // These are not a snapshot comparison — they're written to the test
+  // results directory for visual inspection. We restrict to nodes
+  // inside the instrument editor to avoid accidentally matching the
+  // rack-level instrument card (whose chain preview text can contain
+  // block names like "Delay").
+  const instrumentCanvas = page.locator('.instrument-canvas-container');
+  for (const name of ['Drawbar Organ', 'Chorus', 'Delay', 'LFO',
+       'Wavetable', 'FM Osc', 'SuperSaw', 'Noise']) {
+    // Match by the .pf-node-title element exactly (not arbitrary text
+    // inside the body) so e.g. "Delay" doesn't pick up the Chorus
+    // node whose description mentions "delay".
+    const node = instrumentCanvas
+      .locator('.pf-node')
+      .filter({has: page.locator('.pf-node-title', {hasText: name})})
+      .first();
+    if (await node.count() === 0) continue;
+    await node.scrollIntoViewIfNeeded();
+    const safe = name.replace(/\s+/g, '_').toLowerCase();
+    await node.screenshot({
+      path: `../out/ui/ui-test-results/panel_${safe}.png`,
+      scale: 'css',
+    });
+  }
+  // Whole-page full screenshot (no clip) for the final overview.
+  await page.screenshot({
+    path: '../out/ui/ui-test-results/sound_synth_full_page.png',
+    fullPage: true,
+  });
+});