feat(audio): open the audio FX, group and mute features to everyone (#3401)

* feat(audio): open the audio FX, group and mute features to everyone

The twelve-PR audio stack landed on main with all three of its canaries still
at 0%, so the FX rack, the group rows, mute and solo are in the build and
reachable by nobody. This removes the gates rather than raising the numbers: a
canary that gates nothing is a branch every future reader has to evaluate.

Gone:
- the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries;
- the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`,
  the mute label, the muted strike-through and the solo button in
  `TimelineTrackPlainHeader`, and the group-row derivation in
  `useTimelineTrackDerivations`. Each feature now renders on its own
  precondition (an audio track, a grouped track) exactly as it did for an
  enrolled user.

The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which
is the opposite of what should hold now. Replaced with a pin that no
`audio-*` canary exists at all: re-registering one silently re-hides a shipped
feature, and nothing else in the tree would say so. Verified it fails when one
is added back.

The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that
branch is 105 commits and 310 files divergent from main now that the stack has
squash-merged past it.

* docs(audio): retire the last references to the audio canaries

Two leftovers the gate removal did not reach.

`TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the
canary, unlike the preview fix" above the function that picks Mute vs Hide.
Nothing gates it now, so the comment asserted the opposite of the code.

`docs/weekly-updates.mdx` is published, and it told readers the audio work is
"staged behind a canary at zero percent, so none of it is visible by default"
and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var
maps to no registry entry any more, so following the instruction does nothing
at all. The week's record stays — it is a dated entry — but it now says the
rollout completed and that the variable is inert.

* fix(studio): name the mute action per track, and pin the newly-live audio rows

Review findings on the canary removal. All three are in code the 0% gate made
unreachable, so this is the first time any of it runs for a user.

*blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute":
the current STATE rather than the action, so nothing told a screen-reader user
that activating an already-muted row would unmute it, and it dropped `suffix`,
so every audio row shared one accessible name. Music plus VO is the ordinary
case, which makes that two identical buttons. Now `Unmute track N` /
`Mute track N`, matching the wording `timelineTrackVisibility` already writes
into undo history for the same click. `showAsMute` also picks the icon, so this
is the control's whole identity, not a tooltip.

Tests, for paths that had never executed enabled — a canary at 0% returns
`out_of_cohort` before bucketing, and studio additionally excludes
`navigator.webdriver`, so no suite could reach them:

- `VisibilityButton` — both audio states, two rows staying distinguishable, the
  visual branch unchanged, and the callback still taking the real track key
  rather than the display row. Fails on the old label.
- `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending
  order with no groups, and an interleaved group's members become contiguous
  under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between
  them keeps its place. Plus label/volume/mute mirroring and the id fallback.

Also pins the three retired canary names individually rather than by prefix:
`audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a
`startsWith("audio-")` check. The family guard stays alongside it.

* fix(studio): record the row the mute button announced, not a second derivation

Review finding: the header's track number and the undo-history label's are
computed from two different orderings, and un-gating `audio-groups` is what
makes them diverge.

The header's row comes from the group-aware list — `groupTimelineTracks` emits a
synthetic anchor row per group and pulls members contiguous. The history's comes
from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no
anchors. On the fixture in this PR's own derivation test, grouped order
`[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's
first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this
could not happen — the old branch returned raw tracks, so both sides sorted the
same way.

`onToggleTrackHidden` now carries the display row the clicked control rendered,
and `toggleTimelineTrackHidden` prefers it over deriving its own. One number
instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of
what track number does the user see" already promised. The callback still acts on
the real fractional key, so nothing muted the wrong row before or now — only the
announced and recorded row was wrong.

Also pins the rest of the newly-live surface: the solo button's presence and
pressed state, its absence on a visual track, and the strike-through for both a
row's own mute and a group mute (with the title that says which). Three existing
call-site assertions now check the threaded row too.
This commit is contained in:
Vance Ingalls
2026-08-21 18:22:40 -07:00
committed by GitHub
parent ea95b7d44e
commit 0e9a4f371d
14 changed files with 376 additions and 84 deletions
@@ -223,6 +223,37 @@ describe("toggleTimelineTrackHidden", () => {
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
});
// The header derives its row from the GROUP-AWARE order (a synthetic anchor
// row, members pulled contiguous); this function's own fallback derives it
// from ascending element-bearing keys. Once a group exists those disagree, so
// the same click announced "Mute track 2" and recorded "Mute track 1". The
// clicked control passes the row it showed, and that is what gets recorded.
it("records the display row the clicked control announced, not its own derivation", async () => {
const files = new Map([
["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
]);
stubProjectFiles(files);
const recordEdit = vi.fn();
await toggleTimelineTrackHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
track: 0,
hidden: true,
// Row 2 in the header: the group's anchor row sits above this member.
displayNumber: 2,
previewIframe: null,
writeProjectFile: async () => {},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 2");
});
it("labels unmuting an audio-only track back on", async () => {
const files = new Map([
["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
@@ -32,6 +32,13 @@ interface ToggleTimelineTrackHiddenInput {
timelineElements: readonly TimelineElement[];
track: number;
hidden: boolean;
/**
* The display row the clicked control announced, when the caller has one.
* Absent (a keyboard path or a programmatic call), the row is derived here
* from ascending element-bearing keys correct whenever no group has
* reordered the header.
*/
displayNumber?: number | null;
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
@@ -209,6 +216,7 @@ export async function toggleTimelineTrackHidden({
timelineElements,
track,
hidden,
displayNumber,
previewIframe,
writeProjectFile,
recordEdit,
@@ -216,9 +224,12 @@ export async function toggleTimelineTrackHidden({
pendingTimelineEditPathRef,
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
// `track` is the fractional sort key the callback needs; the history entry is
// read by a human, so it gets the display row instead.
// read by a human, so it gets the display row instead — the one the clicked
// control announced, when the caller passed it. Deriving it again here would
// use ascending element-bearing keys, which stop matching the header as soon
// as an audio group reorders the rows and inserts an anchor.
const suffix = trackDisplaySuffix(
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
);
const trackElements = timelineElements.filter((element) => element.track === track);
const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
@@ -397,14 +408,18 @@ export function useTimelineTrackVisibilityEditing({
pendingTimelineEditPathRef,
isRecordingRef,
forceReloadSdkSession,
}: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
}: UseTimelineTrackVisibilityEditingInput): (
track: number,
hidden: boolean,
displayNumber?: number | null,
) => Promise<void> {
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
// virtual sub-comp children carry their own (display.track + idx) track numbers,
// so filtering the raw store list by a virtual track number would hide the wrong
// outer-scene sibling sharing that index.
const expandedElements = useExpandedTimelineElements();
return useCallback(
async (track: number, hidden: boolean) => {
async (track: number, hidden: boolean, displayNumber?: number | null) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
@@ -418,6 +433,7 @@ export function useTimelineTrackVisibilityEditing({
timelineElements: expandedElements,
track,
hidden,
displayNumber,
previewIframe: previewIframeRef.current,
writeProjectFile,
recordEdit,