Repository navigation
fix(studio): an audio track can be muted again after it is unmuted - #4977
Conversation
The audio track header showed its toggle only while the track was muted, so a track unmuted once could never be muted again. The toggle now shows on every audio-only track as a speaker (Mute track N / Unmute track N), decided by the same rule the undo entry already used.
… the mute fix made false
Edit accuracy: accurate 1556 (base branch 1556), smooth 1460 of thoseThe gate passes. Quarantined, measured but not gated (0) |
… property panel The panel offered its hide toggle on audio only while already hidden, and a selected <hf-audio-group> had no toggle at all (it has no timeline row). The panel now offers Mute/Unmute both ways; a group writes data-hidden through the group attribute writer. Track rows, the panel and their undo entries share one verb and icon helper.
…inspector does not re-render on hover
somanshreddy
left a comment
There was a problem hiding this comment.
Reviewed at e02bd463. This is a comment, not an approval. No Codex pass (small PR); this is my own pass.
The main fix holds. An audio-only track now always shows a speaker on its timeline row, and it toggles both ways. A selected <hf-audio-group> finally gets a working mute and unmute in the flat panel, through the group writer, with one undo entry per click. I found no blockers. I have one should-fix, two smaller notes, and one test gap.
1. Should-fix: a track that mixes audio and visual clips now gets a "Hide track" eye, and clicking it silently mutes the audio on that track.
TimelineTrackHeader.tsx:207 / :389 and TimelineTrackPlainHeader.tsx:105-111 now always render the button, with asMute = isAudioOnlyTrack(trackElements) (every clip is audio). On main the gate was visible={!isAudioTrack || isTrackHidden}, and isAudioTrack is some clip is audio (TimelineLanes.tsx:225, unchanged). So on main a mixed row had no control while audible. The PR body says these rows "keep the eye", but they never had one. The click goes to toggleTimelineTrackHidden, which writes data-hidden on every clip on the track (timelineTrackVisibility.ts:209), audio included. The row still shows the Music icon (isAudioTrack), but the button says "Hide" and the audio goes silent in preview and in the render. That is the exact case canHideSelections (timelineInspector.ts:~115-127) refuses for "Hide all": "a control whose label promises visibility". Verified by tracing at the merge-base and at head. The new test "offers the eye on a track mixing audio and a visual clip" pins this behaviour as intended. Options: keep main's gate for mixed rows, name the button for both effects, or hide only the visual clips. Your call, but the current label understates what the click does.
2. Low (the read predates this PR, but it's inside the both-ways claim): after you unmute from the timeline row, or after an undo, the panel still says "Unmute", and the first panel click does nothing.
PropertyPanelFlat.tsx:209 sets hiddenNow = isSelectionHidden(storeHidden, snapshot), and that is an OR with the selection snapshot's data-hidden (propertyPanelFlatClosedGroup.tsx:41-46). The new post-write refresh (usePanelHiddenToggle.ts:36-42) only covers writes made from the panel. Two other paths leave the snapshot stale:
- The row toggle (
setElementsHidden,timelineTrackVisibility.ts:~145-176) updates the store and the live DOM, but nothing refreshes the selection. - The soft undo path (
usePreviewPersistence.ts:202-240) syncs only automation. The selection is refreshed only on an iframe reload (useStudioSelectionPublisher.ts:81-102).
I checked this with a scratch test (reverted): store vo-1 hidden:false plus a snapshot with data-hidden="". The panel rendered "Unmute element", and the click called onToggleElementHidden("vo-1", false), which writes nothing new. A group is worse off, because its state comes only from the snapshot, so it stays stale after an undo until the next reload. A fix: for a clip that has a timeline row, read the store alone; or refresh the selection after the row toggle and after a soft undo. I didn't reproduce this in a running studio.
3. Low, design: an audio clip's flat panel now has two mutes next to each other.
The header shows "Mute element" (data-hidden, PropertyPanelFlat.tsx:541-542), and the media section shows the "Muted" toggle (the HTML muted attribute, propertyPanelFlatMediaSection.tsx:367-371). My probe rendered both labels on one clip. The comment this PR deletes cited groups doc §2.1 against exactly that: "two controls that silence a track… differing only in a distinction the author cannot see." I'm not asking you to revert. But the header icon can read "audible" while muted is set, and the reverse, so this needs a decision rather than an accident.
4. Note: a group with mixed member state.
The panel toggle writes only the bus's own data-hidden (usePanelHiddenToggle.ts:23-31). In the runtime, a member is silent if it or its bus is hidden (core/src/runtime/init.ts:456). So one click always lands the bus in a consistent state. Members that were muted on their own stay muted after you unmute the bus, and the panel icon reflects only the bus, with no mixed indicator. That's reasonable bus semantics. I'd just mention it in the doc or in a tooltip.
Test gap: the panel's single-clip unmute isn't exercised. The clip test clicks "Mute element" once on an audible clip. When I made the clip path one-way (!hidden → true), it survived 209/209. One extra test would close this: a hidden clip, "Unmute element", and the call is ("vo-1", false).
Mutation table (8 test files: the 6 changed + timelineTrackVisibility.test.ts + timelineInspector.test.ts; each mutation was reverted after its run)
| # | Mutation | Result |
|---|---|---|
| M1 | Group panel toggle one-way (always writes "") |
caught (group mute/unmute/mute) |
| M2 | Clip panel toggle one-way (!hidden → true) |
survived, 209/209 |
| M3 | Restore main's "audio only while hidden" gate on VisibilityButton |
caught (4 tests) |
| M4 | Drop the group branch (audioGroupId = null) |
caught (2) |
| M5 | isAudioOnlyTrack every → some (mixed row becomes a mute) |
caught (2) |
| M6 | Drop the post-write selection refresh | caught (2) |
| M7 | Panel asMute={false} |
caught (3) |
| M8 | Element undo label hiddenBefore inverted |
caught (2, through the existing Hide-label tests) |
| M9 | Group undo label inverted | caught (1) |
Things I checked that hold up
- Toggles both ways on the timeline:
VisibilityButtoncallsonToggle(track, !hidden), andhiddenis read from the store. The Timeline test goes unmute → mute → unmute → mute. - One write and one undo entry per click: a clip goes through
setElementsHidden→saveProjectFilesWithHistory, and a group throughsetAudioGroupAttributewithrecordEdit. The labels share one owner,hiddenToggleVerb. - The undo labels match the button.
hiddenBefore = !hiddenis right for both the track entry and the element entry, and the track entry is unchanged in meaning from main. - Video: a
<video>clip (with or without audio) isn't audio underisAudioTimelineElement, so it keeps the eye, and hide stays distinct from mute. Pure visual rows behave exactly as on main. - The classic inspector still withholds the toggle on audio, as the PR body says (
PropertyPanel.tsx:313). "Hide all" still refuses audio (canHideSelections). - No handler-level audio refusal sits under the panel's clip path:
handleToggleElementHiddenonly guards by element. - The post-write refresh re-reads whatever is selected now, not the muted item. M6 and the switch-mid-save test pin that.
- CI at
e02bd463: 169 check-runs, 157 success, 12 skipped, 0 failed. The skips are 6 names × 2 (skills, catalog index and GCP BeginFrame, all path-filtered). The Windowsstudio-1/studio-2jobs did run on this PR and passed. - The PR body has the Before/After captures: 3 before and 4 after, on the fixture project. I couldn't open the attachment images, so I haven't checked what they show.
What I ran
- Fetched the head and main into a parked clone. Diffed against merge-base
2cfbe3d2. bun install --frozen-lockfile, then built parsers, lint, studio-server and core.NODE_ENV=test vitest runon the 8 files above: 209/209 at head.- One scratch probe test for finding 2, reverted.
- 9 mutations, each one followed by the same 8-file run.
gh pr checksand the check-runs API for the exact SHA.
— Somu
jrusso1020
left a comment
There was a problem hiding this comment.
Approving at e02bd463. This review adds to Somu's comment. The mute now works both ways for an audio track, a clip and a group, and the state is saved to the file.
Checked
- Single clip: the timeline row and the panel both write
!hidden. The undo labels come from the samehiddenToggleVerb, so they match the button. - Group: the panel writes
data-hiddenon the<hf-audio-group>itself (hidden ? null : ""), and the label follows the state. - Group with mixed members: only the bus attribute changes. Members muted on their own stay muted, because the runtime silences a member if it or its bus is hidden. I agree with Somu that this is the right bus behaviour; a tooltip could say so.
- Save and reload: both paths write
data-hiddeninto the source file. On reload,timelineSyncHydration.ts:142reads it back into the track'shiddenstate, and the group's state comes from the attribute (isSelectionHidden). - Rows with no audio: visual rows get the same eye as on main (
asMutefalse, and they werevisiblebefore). Only the label and icon code moved, intohiddenToggle.tsx.
On Somu's should-fix (a row mixing audio and visual clips): I don't think a running studio can show that row. Every element set into the store goes through normalizeToZones (useTimelinePlayer.ts:90). It packs visual and audio clips into separate display lanes using classifyZone, which is isAudioTimelineElement, the same predicate as isAudioOnlyTrack. Drags are clamped to their own zone (clampTrackToZone). timelineZones.test.ts already pins this case ("drops audio below the visual lanes even when sharing a track index"). So every real row is all audio or all visual, and the old some check and the new every check agree on all of them. The new test "offers the eye on a track mixing audio and a visual clip" builds a row the store never holds. That isn't a blocker. The PR body's "keep the eye" is also inaccurate for those rows, but no user reaches them.
Notes, not blocking
- I agree with Somu's test gap. Nothing exercises unmuting a single clip from the panel (hidden clip, "Unmute element", call is
("vo-1", false)). - The stale "Unmute" after a row toggle or undo also happens on main. Somu traced it. On main the panel showed the toggle only while it was muted, so it had the same stale read.
- Two mutes now sit together on an audio clip's panel: the header's
data-hiddenand the media section's HTMLmuted. That's a product call for the author, as Somu said.
Ran: CI at this head is 88 passed, 6 path-skipped and 0 failed. Locally, the 6 changed test files plus timelineTrackVisibility, timelineInspector and timelineZones pass 232/232.
— Rames
What was wrong
On audio,
data-hiddenis the mute: preview silences the clip and the render drops it. Studio offered that toggle on audio only while it was already on, so a mute went one way:<hf-audio-group>had no toggle at all, muted or not: the panel offered it only with a timeline selection id, and the timeline's element hook refuses a group because a bus has no timeline row.What changed
isAudioOnlyTrack, now inutils/timelineInspector.ts), so the button and its undo entry cannot disagree.data-hiddenon the<hf-audio-group>through the group attribute writer (onSetAudioGroupAttributeQuiet), with its own undo entry. The timeline group header is unchanged: no mute or solo there. After the write lands, the panel re-reads whatever is selected at that moment, so its label follows without a reselect.player/components/hiddenToggle.tsx(hiddenToggleVerb,HiddenToggleIcon) is used by the track rows, the panel header and both undo labels.VisibilityButtonloses itsvisibleprop and the spacer it rendered when withheld; nothing withholds it now.aria-pressedis not set: the label already changes with the state, as the eye's does, and a toggle should not carry both.Not changed: the classic inspector (used only with the flat inspector turned off) still shows no visibility toggle on audio, in either state.
Tests
The new tests fail on main and pass here:
Timeline.test.ts: an audio track written muted is unmuted, muted, unmuted and muted again from its header.TimelineTrackHeader.test.tsx: an audio track offers "Mute track 1" / "Unmute track 1"; a track mixing audio and a visual clip offers the eye.PropertyPanel.test.tsx: an audible audio clip offers "Mute element" and toggles through the timeline; an audio group is unmuted, muted and unmuted from the panel header, each adata-hiddenwrite on the group, with the label following only through the panel's own selection refresh; and a selection the user moved to while the write was pending is the one refreshed, never the muted bus.PropertyPanelFlatHeader.test.tsx: the header names a mute on an audio selection.The touched and neighbouring test files pass 3 runs in a row; studio
tsc --noEmitis clean.Before
A fixture project: one audible music track, two muted alternatives and a muted group of two voice clips. On main the audible track has no control, and after "Show track 3" the third track plays with no control left to mute it again. With the muted group selected, the panel offers nothing.
After
Same fixture. Every audio track has a speaker: "Unmute track 3", then "Mute track 3", and
data-hiddenis back on the clip in the file. With the group selected, the panel's speaker reads "Unmute element", then "Mute element", anddata-hiddencomes off and back on the<hf-audio-group>in the file.Independent review
Adversarial review at the final head: 0 blocking, 0 should fix. Earlier rounds found and this PR fixed: a refresh that re-selected the muted item if the selection moved before the save landed (now refreshes the live selection, with a test that switches selection mid-save), and an inspector re-render on every canvas hover (the selection ref is now read from the actions context, next to
previewIframeRef). The panel toggle moved tousePanelHiddenToggle.tsto keepPropertyPanelFlat.tsxunder the 600-line limit.Notes left open on purpose:
useAutomationLanesstill reads the selection ref from the selection context (hover re-render predates this PR); a one-line follow-up.