diff --git a/CHANGELOG.md b/CHANGELOG.md
index de75b99cf..64e4c894a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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`).
\ No newline at end of file
+- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 80ac13db2..453cc3ecf 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -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(
+ ,
+ );
+
+ 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();
+ fireEvent.click(screen.getByRole("button", { name: /chorus/i }));
+ expect(
+ screen
+ .getByRole("button", { name: /chorus/i })
+ .getAttribute("aria-pressed"),
+ ).toBe("true");
+
+ rerender(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index cb438d19c..d344b002b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -33,6 +33,8 @@ interface RehearsalPlayerProps {
song: RehearsalSong;
hasLocalAudio?: boolean;
audioSourcePath?: string | null;
+ activeRole?: string | null;
+ activeRoleName?: string | null;
startNonce?: number;
}
@@ -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],
+ );
+ const [selectedLoopKey, setSelectedLoopKey] = useState(null);
+ const selectedLoop =
+ playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
+ playableLoops[0] ??
+ null;
const [transport, setTransport] = useState(() =>
reduceRehearsalTransport(createIdleTransportState(), {
type: "arm",
@@ -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;
@@ -226,7 +241,6 @@ export function RehearsalPlayer({
if (!hasPlayableAudio) {
return;
}
- const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
if (selectedLoop) {
setPlaybackError(false);
startAudio(selectedLoop, false);
@@ -242,8 +256,7 @@ export function RehearsalPlayer({
startAudio,
startNonce,
hasPlayableAudio,
- playableLoops,
- selectedRendererIndex,
+ selectedLoop,
]);
useEffect(() => {
@@ -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 &&
@@ -429,17 +452,30 @@ export function RehearsalPlayer({
>
{nextAction}
+ {activeRoleName && playableLoops.length > 0 ? (
+
+ {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), {
+ roleName: activeRoleName,
+ })}
+
+ ) : null}
{playableLoops.length > 0 ? (
{playableLoops.map((loop, index) => {
- const selected = index === selectedRendererIndex;
+ const selectionKey = loopSelectionKey(loop);
+ const selected =
+ selectedLoop !== null &&
+ selectionKey === loopSelectionKey(selectedLoop);
return (
- {activeRole && (
+ {resolvedActiveRole && (
Stem Player
-
{activeRoleDetails?.name ?? activeRole}
+
{activeRoleDetails?.name ?? resolvedActiveRole}
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
index c07c41fc5..0310d4e0c 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
@@ -13,6 +13,7 @@ import {
rehearsalPlaybackRates,
reduceRehearsalTransport,
resolveLoopWindow,
+ resolveLoopWindows,
resolveRehearsalTempo,
wrapPlayhead,
} from "./rehearsalTransport";
@@ -59,6 +60,27 @@ describe("rehearsalTransport", () => {
expect(window?.endSeconds).toBe(64);
});
+ it("filters loop windows to sections containing the selected role", () => {
+ 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);
+
+ expect(
+ resolveLoopWindows(song, "lead-vocal").map((window) => window.sectionId),
+ ).toEqual(["verse-1"]);
+ expect(
+ resolveLoopWindows(song, "bass-guitar").map((window) => window.sectionId),
+ ).toEqual(["verse-1", "chorus-1"]);
+ expect(resolveLoopWindows(song).map((window) => window.sectionId)).toEqual([
+ "verse-1",
+ "chorus-1",
+ ]);
+ });
+
it("rejects a sparse hostile section array without scanning its declared length", () => {
const song = createDemoRehearsalSong();
song.sections = new Array(0xffffffff) as typeof song.sections;
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index e8a465c98..5cc0e9899 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -166,6 +166,20 @@ function playableSectionSnapshot(
};
}
+/** Return whether a snapshotted section contains the selected rehearsal role. */
+function sectionContainsRole(section: object, roleId: string): boolean {
+ const roles = ownedDenseArray(ownDataValue(section, "roles"));
+ if (!roles) {
+ return false;
+ }
+ return roles.some(
+ (role) =>
+ role !== null &&
+ typeof role === "object" &&
+ ownDataValue(role, "id") === roleId,
+ );
+}
+
/** Return whether a section exposes a usable closed loop window. */
export function isPlayableLoopSection(
section: RehearsalSection | undefined | null,
@@ -236,6 +250,7 @@ export function createLoopWindow(
/** Snapshot every playable loop window from one untrusted song record. */
export function resolveLoopWindows(
song: RehearsalSong | null | undefined,
+ roleId: string | null | undefined = null,
): RehearsalLoopWindow[] {
if (!song || typeof song !== "object") {
return [];
@@ -245,10 +260,15 @@ export function resolveLoopWindows(
return [];
}
const tempo = ownDataValue(song, "tempo");
+ const selectedRoleId =
+ typeof roleId === "string" && roleId.trim() ? roleId : null;
return sections.flatMap((section) => {
if (!section || typeof section !== "object") {
return [];
}
+ if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) {
+ return [];
+ }
const window = createLoopWindow(section as RehearsalSection, tempo);
return window ? [window] : [];
});
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 2c0308977..e51e2a962 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -152,6 +152,9 @@
"workspaceLoopRegionLabel": "Tonight's section loop",
"workspaceLoopTitle": "Tonight's loop",
"workspaceLoopSectionPickerLabel": "Playable sections",
+ "workspaceLoopSectionPickerForRole": "Playable sections for {roleName}",
+ "workspaceLoopRoleFilterHint": "Showing sections that include {roleName}.",
+ "workspaceLoopNoRoleSections": "No playable sections include {roleName} yet. Choose All Roles or map this role first.",
"workspaceLoopStart": "Start the count-in",
"workspaceLoopThisSection": "Start selected section loop",
"workspaceLoopResume": "Continue rehearsal clock",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 6f78ff5b5..b489229c3 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -152,6 +152,9 @@
"workspaceLoopRegionLabel": "오늘 밤 구간 루프",
"workspaceLoopTitle": "오늘 밤 루프",
"workspaceLoopSectionPickerLabel": "연습할 구간",
+ "workspaceLoopSectionPickerForRole": "{roleName} 역할의 연습 구간",
+ "workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.",
+ "workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.",
"workspaceLoopStart": "카운트인 시작",
"workspaceLoopThisSection": "선택한 구간 루프 시작",
"workspaceLoopResume": "합주 시계 계속",