Skip to content

fix(studio): an audio track can be muted again after it is unmuted - #4977

Merged
miguel-heygen merged 8 commits into
mainfrom
fix/audio-track-mute-toggle
Oct 4, 2026
Merged

miguel-heygen merged 8 commits into
mainfrom
fix/audio-track-mute-toggle

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

What was wrong

On audio, data-hidden is 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:

  • Timeline track header: an audio track showed its toggle only while muted. A track that was muted (for example music alternatives an agent writes muted) could be unmuted once, then the control disappeared and nothing could mute it again.
  • Property panel: the same rule for an audio clip. A selected <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

  • Track header: every audio-only track shows a speaker, "Mute track N" / "Unmute track N". Visual tracks, and tracks that mix audio with a visual clip, keep the eye ("Hide track N" / "Show track N"). This is the rule the undo entry already used (isAudioOnlyTrack, now in utils/timelineInspector.ts), so the button and its undo entry cannot disagree.
  • Property panel (flat inspector): any audio selection shows "Mute element" / "Unmute element" in both states. A clip toggles through the same timeline path as before. A group writes data-hidden on 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.
  • One owner for the words and the icon: player/components/hiddenToggle.tsx (hiddenToggleVerb, HiddenToggleIcon) is used by the track rows, the panel header and both undo labels.
  • VisibilityButton loses its visible prop and the spacer it rendered when withheld; nothing withholds it now.
  • Comments elsewhere that said the timeline withholds the audio control are corrected.

aria-pressed is 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 a data-hidden write 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 --noEmit is 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.

Before: start
Before: unmuted, no control left
Before: muted group selected, no toggle in the panel

After

Same fixture. Every audio track has a speaker: "Unmute track 3", then "Mute track 3", and data-hidden is back on the clip in the file. With the group selected, the panel's speaker reads "Unmute element", then "Mute element", and data-hidden comes off and back on the <hf-audio-group> in the file.

After: unmuted
After: muted again
After: group unmuted from the panel
After: group muted again from the panel

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 to usePanelHiddenToggle.ts to keep PropertyPanelFlat.tsx under the 600-line limit.

Notes left open on purpose:

  • A fast double-click writes the same value twice.
  • An audio clip inside a sub-composition shows Mute, and its refusal toast still says "hide".
  • useAutomationLanes still reads the selection ref from the selection context (hover re-render predates this PR); a one-line follow-up.
  • The shared group attribute writer resolves by DOM id, so a clip that shares its group's id can receive the group's write. Predates this PR; fixed separately.

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.
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 1556 (base branch 1556), smooth 1460 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

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.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 4, 2026 02:15

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: VisibilityButton calls onToggle(track, !hidden), and hidden is 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 through setAudioGroupAttribute with recordEdit. The labels share one owner, hiddenToggleVerb.
  • The undo labels match the button. hiddenBefore = !hidden is 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 under isAudioTimelineElement, 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: handleToggleElementHidden only 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 Windows studio-1/studio-2 jobs 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 run on 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 checks and the check-runs API for the exact SHA.

— Somu

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 same hiddenToggleVerb, so they match the button.
  • Group: the panel writes data-hidden on 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-hidden into the source file. On reload, timelineSyncHydration.ts:142 reads it back into the track's hidden state, and the group's state comes from the attribute (isSelectionHidden).
  • Rows with no audio: visual rows get the same eye as on main (asMute false, and they were visible before). Only the label and icon code moved, into hiddenToggle.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-hidden and the media section's HTML muted. 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

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 9876389 Oct 4, 2026
169 checks passed
@miguel-heygen
miguel-heygen deleted the fix/audio-track-mute-toggle branch October 4, 2026 02:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants