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, + }); +});