Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

### Fixed

- Kept the rehearsal player section picker aligned with the selected player or
vocal role while preserving the full song-form roadmap.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.

## [0.1.3] - 2026-04-29
Expand Down Expand Up @@ -75,4 +77,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
85 changes: 85 additions & 0 deletions apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,91 @@ describe("RehearsalPlayer", () => {
).not.toMatch(/Count in 4 beats/i);
});

it("limits the section picker to sections containing the active role", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const chorus = structuredClone(song.sections[0]!);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: 40, end: 64 };
chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal");
song.sections.push(chorus);

render(
<RehearsalPlayer
song={song}
activeRole="lead-vocal"
activeRoleName="Lead Vocal"
/>,
);

expect(
screen.getByRole("group", { name: "Playable sections for Lead Vocal" }),
).toBeTruthy();
expect(screen.getByRole("button", { name: /verse/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /chorus/i })).toBeNull();
expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent(
"Showing sections that include Lead Vocal.",
);
});

it("keeps the selected loop by section ID when an earlier section is filtered out", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = structuredClone(song.sections[0]!);
verse.id = "verse-no-lead-vocal";
verse.roles = verse.roles.filter((role) => role.id !== "lead-vocal");
const chorus = structuredClone(song.sections[0]!);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: 40, end: 64 };
song.sections = [verse, chorus];

const { rerender } = render(<RehearsalPlayer song={song} />);
fireEvent.click(screen.getByRole("button", { name: /chorus/i }));
expect(
screen
.getByRole("button", { name: /chorus/i })
.getAttribute("aria-pressed"),
).toBe("true");

rerender(
<RehearsalPlayer
song={song}
activeRole="lead-vocal"
activeRoleName="Lead Vocal"
/>,
);

expect(
screen
.getByRole("button", { name: /chorus/i })
.getAttribute("aria-pressed"),
).toBe("true");
expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
/Map chorus from 0:40–1:04/i,
);
});

it("explains when the active role has no playable sections", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles = [];

render(
<RehearsalPlayer
song={song}
activeRole="lead-vocal"
activeRoleName="Lead Vocal"
/>,
);

expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
"No playable sections include Lead Vocal yet.",
);
expect(screen.queryByRole("group")).toBeNull();
});

it("stops active count-in and loop ticking when local-audio authority is revoked", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
Expand Down
84 changes: 60 additions & 24 deletions apps/desktop/src/features/workspace/RehearsalPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface RehearsalPlayerProps {
song: RehearsalSong;
hasLocalAudio?: boolean;
audioSourcePath?: string | null;
activeRole?: string | null;
activeRoleName?: string | null;
startNonce?: number;
}

Expand Down Expand Up @@ -89,19 +91,30 @@ function hasSameLoopTiming(
);
}

/** Return a stable selection key when analysis emits duplicate section IDs. */
function loopSelectionKey(loop: RehearsalLoopWindow): string {
return `${loop.sectionId}:${loop.startSeconds}:${loop.endSeconds}`;
}

/** Render tonight's first section loop with a count-in and a named next action. */
export function RehearsalPlayer({
song,
hasLocalAudio = false,
audioSourcePath = null,
activeRole = null,
activeRoleName = null,
startNonce = 0,
}: RehearsalPlayerProps): ReactElement {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const playableLoops = useMemo(() => resolveLoopWindows(song), [song]);
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
const selectedRendererIndex = playableLoops[selectedSectionIndex]
? selectedSectionIndex
: 0;
const playableLoops = useMemo(
() => resolveLoopWindows(song, activeRole),
[activeRole, song],
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
);
const [selectedLoopKey, setSelectedLoopKey] = useState<string | null>(null);
const selectedLoop =
playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
playableLoops[0] ??
null;
const [transport, setTransport] = useState<RehearsalTransportState>(() =>
reduceRehearsalTransport(createIdleTransportState(), {
type: "arm",
Expand Down Expand Up @@ -184,24 +197,26 @@ export function RehearsalPlayer({
}, [audioSourceUrl, hasNativeAudioConversionError]);

useEffect(() => {
const nextLoop = playableLoops[selectedRendererIndex] ?? null;
setTransport((current) => {
if (
current.loop &&
nextLoop &&
hasSameLoopTiming(current.loop, nextLoop)
selectedLoop &&
hasSameLoopTiming(current.loop, selectedLoop)
) {
if (
current.loop.sectionLabel === nextLoop.sectionLabel &&
current.loop.tempoAssumed === nextLoop.tempoAssumed
current.loop.sectionLabel === selectedLoop.sectionLabel &&
current.loop.tempoAssumed === selectedLoop.tempoAssumed
) {
return current;
}
return { ...current, loop: nextLoop };
return { ...current, loop: selectedLoop };
}
return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop });
return reduceRehearsalTransport(current, {
type: "arm",
loop: selectedLoop,
});
});
}, [playableLoops, selectedRendererIndex]);
}, [selectedLoop]);

useEffect(() => {
const audio = audioRef.current;
Expand All @@ -226,7 +241,6 @@ export function RehearsalPlayer({
if (!hasPlayableAudio) {
return;
}
const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
if (selectedLoop) {
setPlaybackError(false);
startAudio(selectedLoop, false);
Expand All @@ -242,8 +256,7 @@ export function RehearsalPlayer({
startAudio,
startNonce,
hasPlayableAudio,
playableLoops,
selectedRendererIndex,
selectedLoop,
]);

useEffect(() => {
Expand Down Expand Up @@ -397,10 +410,20 @@ export function RehearsalPlayer({
]);

const actionKey = nextActionTemplateKey(transport, hasPlayableAudio);
const nextAction = fillRehearsalCopy(
t(actionKey as TranslationKey),
nextActionValues(transport),
);
const nextAction =
activeRoleName && playableLoops.length === 0
? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), {
roleName: activeRoleName,
})
: fillRehearsalCopy(
t(actionKey as TranslationKey),
nextActionValues(transport),
);
const sectionPickerLabel = activeRoleName
? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), {
roleName: activeRoleName,
})
: t("workspaceLoopSectionPickerLabel");
const canStart =
transport.loop !== null &&
hasPlayableAudio &&
Expand Down Expand Up @@ -429,17 +452,30 @@ export function RehearsalPlayer({
>
{nextAction}
</p>
{activeRoleName && playableLoops.length > 0 ? (
<p
className="mt-3 text-xs font-semibold text-cyan-100"
data-testid="rehearsal-loop-role-filter"
>
{fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), {
roleName: activeRoleName,
})}
</p>
) : null}
{playableLoops.length > 0 ? (
<div
className="mt-3 flex flex-wrap gap-2"
role="group"
aria-label={t("workspaceLoopSectionPickerLabel")}
aria-label={sectionPickerLabel}
>
{playableLoops.map((loop, index) => {
const selected = index === selectedRendererIndex;
const selectionKey = loopSelectionKey(loop);
const selected =
selectedLoop !== null &&
selectionKey === loopSelectionKey(selectedLoop);
return (
<Button
key={`rehearsal-loop-section-${index}`}
key={`rehearsal-loop-section-${selectionKey}-${index}`}
type="button"
variant={selected ? "default" : "outline"}
size="sm"
Expand All @@ -449,7 +485,7 @@ export function RehearsalPlayer({
? "min-h-10 border-cyan-300/30 bg-cyan-300 font-semibold text-slate-950"
: "min-h-10 border-white/10 bg-white/5 font-semibold text-slate-100"
}
onClick={() => setSelectedSectionIndex(index)}
onClick={() => setSelectedLoopKey(selectionKey)}
>
<span>{loop.sectionLabel}</span>
<span> · </span>
Expand Down
48 changes: 47 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import {
createDemoRehearsalSong,
type ProjectBootstrapSummary,
Expand Down Expand Up @@ -231,6 +231,52 @@ describe("Workspace", () => {
).toBeNull();
});

it("passes the selected role into the player section filter", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const chorus = structuredClone(song.sections[0]!);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: 40, end: 64 };
chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal");
song.sections.push(chorus);

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

const playerSections = screen.getByRole("group", {
name: "Playable sections for Lead Vocal",
});
expect(within(playerSections).getByRole("button", { name: /verse/i })).toBeTruthy();
expect(within(playerSections).queryByRole("button", { name: /chorus/i })).toBeNull();
});

it("clears a role that is absent after replacing the song", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const replacement = createDemoRehearsalSong();
replacement.sections = replacement.sections.map((section) => ({
...section,
roles: section.roles.filter((role) => role.id !== "lead-vocal"),
}));

const { rerender } = render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent(
"Showing sections that include Lead Vocal.",
);

rerender(<Workspace song={replacement} />);

expect(screen.queryByTestId("rehearsal-loop-role-filter")).toBeNull();
expect(
screen.getByRole("tab", { name: "All Roles", selected: true }),
).toBeTruthy();
expect(screen.getByTestId("rehearsal-loop-next-action")).not.toHaveTextContent(
/No playable sections include Lead Vocal/i,
);
});

it("enables bass transcription from selected role metadata rather than role id text", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
Expand Down
Loading