From 5fde3e975ee70b2451718a886dd8ddac64ad7672 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 6 Oct 2026 19:36:07 +0200 Subject: [PATCH] Reset a track slider with a double-click in the Song tab The heads of the Song tab had four rough spots. Panned down, the first track had no line above it. The M and S letters sat high in their buttons. A volume or pan slider could only be dragged back to the middle by eye. Muting a track paled its volume slider. - The first track draws a border along its top, in the lanes and in its head, like the one below every track. - M and S sit in the middle of their buttons. A label of the heads can now carry a height and is centred in its box. - A double-click on a head's volume or pan sets it back to 0: the volume to 0 dB, the pan to the middle. It is one undo step of its own after the step the first click set. - Mute only lights its own button. The volume slider keeps its colour, so a muted head no longer looks out of step. Tests: - e2e 65 on melektaus-atx: ALL PASSED, 27 checks (+1) - typecheck, ESLint, Prettier: clean (also after the mute change) - screenshot on stein2: border above track 1, M and S with 4 px free above and below the letter in a 16 px button Co-Authored-By: Claude Opus 5.5 --- src/editor/song/SongTimeline.ts | 28 ++++++++++++++++++++++++---- tools/e2e/65-song.mjs | 17 ++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index e85a4ea0..86f37d99 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -120,6 +120,8 @@ interface Placed { x: number; y: number; w: number; + /** Given, the text sits in the middle of the box `w` by `h`. */ + h?: number; dark?: boolean; } @@ -657,6 +659,17 @@ export class SongTimeline extends Widget { { tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: !t[part] } : t)) }, label, ); + } else if ( + (part === 'volume' || part === 'pan') && + pressCount(e, `song-slider:${track.id}:${part}`) === 2 + ) { + // A double-click sets it back to the middle: 0 dB, or straight ahead. + this.commit( + asset, + { tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: 0 } : t)) }, + part === 'volume' ? 'Reset track volume' : 'Reset track pan', + ); + this.onTracksPreview(this.asset()?.tracks ?? asset.tracks); } else if (part === 'volume' || part === 'pan') { const value = this.sliderAt(part, x); this.gesture = { kind: 'slider', track: track.id, part, value }; @@ -1207,6 +1220,7 @@ export class SongTimeline extends Widget { this.lanesG .rect(0, y, lw, th) .fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 }); + if (i === 0) this.lanesG.rect(0, y, lw, 1).fill({ color: c.border, alpha: 0.8 }); this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 }); }); // Above the first track and below the last, panned to, is empty. @@ -1358,14 +1372,15 @@ export class SongTimeline extends Widget { const lit = track.id === this.pickedTrack; const th = this.view.trackHeight; this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel); + if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border); this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border); const { name, mute, solo, volume, pan } = HEAD_BOXES; labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w }); this.toggle(mute, y, track.mute, MUTE_COLOR); this.toggle(solo, y, track.solo, SOLO_COLOR); labels.push( - { text: 'M', x: mute.x + 5, y: y + mute.y, w: mute.w, dark: track.mute }, - { text: 'S', x: solo.x + 5, y: y + solo.y, w: solo.w, dark: track.solo }, + { text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute }, + { text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo }, ); if (th < SLIDERS_MIN_HEIGHT) return; const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB); @@ -1375,7 +1390,7 @@ export class SongTimeline extends Widget { .stroke({ width: 1, color: c.border, alignment: 1 }); this.headsG .rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2) - .fill({ color: c.accent, alpha: track.mute ? 0.35 : 0.8 }); + .fill({ color: c.accent, alpha: 0.8 }); const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB); this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 }); this.headsG @@ -1414,7 +1429,12 @@ export class SongTimeline extends Widget { for (let cut = label.text.length; text.width > label.w && cut > 1; cut--) text.text = `${label.text.slice(0, cut - 1)}…`; text.visible = true; - text.position.set(label.x, label.y); + if (label.h === undefined) text.position.set(label.x, label.y); + else + text.position.set( + Math.round(label.x + (label.w - text.width) / 2), + Math.round(label.y + (label.h - text.height) / 2), + ); }); for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false; } diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index f5551de4..48321aa3 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -10,7 +10,7 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; * copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click * on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the * wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets - * the volume of its track, its menu adds one. Space plays the song through the scheduler: + * the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler: * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. * Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips @@ -407,6 +407,21 @@ export async function song(t) { (await json('__gm.song.info()')) === '5 tracks · 2 items', JSON.stringify(heads.tracks), ); + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.tracks[2].pan = 0.5;`); + await sleep(100); + p = await json("__gm.song.headAt(2, 'volume', 0.2)"); + await doubleClick(p.x, p.y); + p = await json("__gm.song.headAt(2, 'pan', 0.9)"); + await doubleClick(p.x, p.y); + await sleep(150); + const reset = (await model()).tracks[2]; + check( + 'a double-click on a slider sets it back to 0: volume to 0 dB, pan to the middle', + reset.volume === 0 && reset.pan === 0, + JSON.stringify(reset), + ); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.tracks[2].volume = -6;