diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..9948eeba7 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -313,10 +313,10 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy();
});
- expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy();
+ const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
+ expect(within(timelineRegion).getByText(/verse · 0:10–0:30/i)).toBeTruthy();
expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy();
expect(screen.queryByText(/Mock-board/i)).toBeNull();
- const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
expect(timelineRegion.className).toContain("overflow-x-auto");
expect(timelineRegion.getAttribute("tabindex")).toBe("0");
expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull();
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
index a32deb8e7..38dbdc089 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SectionRoadmap } from "./SectionRoadmap";
@@ -15,6 +15,7 @@ function setNavigatorLanguage(language: string) {
describe("SectionRoadmap", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
+ vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -72,4 +73,97 @@ describe("SectionRoadmap", () => {
expect(card?.getAttribute("tabindex")).toBe("-1");
expect(card?.id).not.toContain(song.sections[0].id);
});
+
+ it("names tonight's count-in on the first section card", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ const countIn = screen.getByRole("button", {
+ name: "Count in verse from 0:10 to 0:30 at tonight's tempo"
+ });
+ expect(countIn).toBeTruthy();
+ expect((countIn as HTMLButtonElement).disabled).toBe(false);
+ expect(screen.getByText("Count in verse · 0:10–0:30")).toBeTruthy();
+ });
+
+ it("counts four beats at the analyzed tempo then names the first pass", () => {
+ vi.useFakeTimers();
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" })
+ );
+
+ expect(screen.getByLabelText("Count-in beat 1 of 4")).toBeTruthy();
+ expect(screen.getByText("Counting in verse · 1")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.getByLabelText("Count-in beat 2 of 4")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.getByLabelText("Count-in beat 3 of 4")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.getByLabelText("Count-in beat 4 of 4")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.getByText("Counted in verse · 0:10–0:30. Start the first pass.")).toBeTruthy();
+ });
+
+ it("fails closed when tonight's song has no tempo", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ delete song.tempo;
+
+ render();
+
+ const countIn = screen.getByRole("button", { name: "Add a tempo before counting in tonight." });
+ expect((countIn as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.click(countIn);
+ expect(screen.queryByText(/Counting in/)).toBeNull();
+ });
+
+ it("counts in the renderer-selected section even when analysis ids collide", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections.push({
+ ...song.sections[0]!,
+ id: song.sections[0]!.id,
+ label: "chorus",
+ timeRange: { start: 30, end: 50 }
+ });
+
+ render();
+
+ expect(
+ screen.getByRole("button", {
+ name: "Count in chorus from 0:30 to 0:50 at tonight's tempo"
+ })
+ ).toBeTruthy();
+ expect(
+ screen.queryByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" })
+ ).toBeNull();
+ });
+
+ it("localizes tonight's count-in action", () => {
+ setNavigatorLanguage("ko-KR");
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ expect(screen.getByRole("button", { name: "오늘 템포로 verse 0:10부터 0:30까지 카운트인" })).toBeTruthy();
+ expect(screen.getByText("verse · 0:10–0:30 카운트인")).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index 97ca6664e..433f09bf8 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -1,12 +1,15 @@
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
-import { useId, useMemo } from "react";
+import { useEffect, useId, useMemo, useState } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucide-react";
+const COUNT_IN_BEATS = 4;
+
interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
@@ -14,13 +17,110 @@ interface SectionRoadmapProps {
loopedSectionIndex?: number | null;
}
-/** Documented. */
-export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) {
+/** Format a timeline instant as m:ss for rehearsal cards. */
+function formatTimelineTime(totalSeconds: number): string {
+ const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0;
+ const minutes = Math.floor(safeSeconds / 60);
+ const seconds = Math.floor(safeSeconds % 60)
+ .toString()
+ .padStart(2, "0");
+ return `${minutes}:${seconds}`;
+}
+
+/** Fill count-in copy with a section label and its start–end window. */
+function countInCopy(
+ template: string,
+ section: RehearsalSong["sections"][number]
+): string {
+ return template
+ .replace("{label}", section.label)
+ .replace("{start}", formatTimelineTime(section.timeRange.start))
+ .replace("{end}", formatTimelineTime(section.timeRange.end));
+}
+
+/** Return milliseconds per beat when the analyzed tempo can drive a count-in. */
+function countInBeatMs(tempo: number | undefined): number | null {
+ if (typeof tempo !== "number" || !Number.isFinite(tempo) || tempo <= 0) {
+ return null;
+ }
+
+ return 60_000 / tempo;
+}
+
+/** Return the renderer-owned position of the section this player should count in tonight. */
+function firstCountInSectionIndex(
+ song: RehearsalSong,
+ activeRole: string | null,
+ loopedSectionIndex: number | null
+): number | undefined {
+ if (
+ loopedSectionIndex !== null &&
+ Number.isSafeInteger(loopedSectionIndex) &&
+ loopedSectionIndex >= 0 &&
+ loopedSectionIndex < song.sections.length
+ ) {
+ return loopedSectionIndex;
+ }
+
+ if (activeRole) {
+ const forRoleIndex = song.sections.findIndex((section) =>
+ section.roles.some((role) => role.id === activeRole)
+ );
+ if (forRoleIndex !== -1) {
+ return forRoleIndex;
+ }
+ }
+
+ return song.sections.length > 0 ? 0 : undefined;
+}
+
+/** Render the rehearsal section roadmap and optional tempo-driven count-in. */
+export function SectionRoadmap({
+ song,
+ activeRole,
+ onSongUpdate,
+ loopedSectionIndex = null
+}: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
+ const countInSectionIndex = firstCountInSectionIndex(song, activeRole, loopedSectionIndex);
+ const countInSection =
+ countInSectionIndex === undefined ? undefined : song.sections[countInSectionIndex];
+ const beatMs = countInBeatMs(song.tempo);
+ const [countInPhase, setCountInPhase] = useState<"idle" | "counting" | "ready">("idle");
+ const [countInBeat, setCountInBeat] = useState(0);
+
+ useEffect(() => {
+ setCountInPhase("idle");
+ setCountInBeat(0);
+ }, [countInSectionIndex]);
+
+ useEffect(() => {
+ if (countInPhase !== "counting") {
+ return;
+ }
+
+ if (beatMs === null) {
+ setCountInPhase("idle");
+ setCountInBeat(0);
+ return;
+ }
+
+ if (countInBeat >= COUNT_IN_BEATS) {
+ const readyTimer = window.setTimeout(() => {
+ setCountInPhase("ready");
+ }, beatMs);
+ return () => window.clearTimeout(readyTimer);
+ }
+
+ const nextTimer = window.setTimeout(() => {
+ setCountInBeat((current) => current + 1);
+ }, beatMs);
+ return () => window.clearTimeout(nextTimer);
+ }, [beatMs, countInBeat, countInPhase]);
- /** Documented. */
+ /** Build the localized accessible label for a role's chord-edit control. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
return t("chordEditAriaLabel")
.replace("{roleName}", role.name)
@@ -28,7 +128,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
.replace("{chord}", role.harmony.chord);
};
- /** Documented. */
+ /** Apply a user-entered chord override to the matching role. */
const handleChordEdit = (sectionId: string, role: RehearsalRole) => {
if (!onSongUpdate) return;
const newChord = window.prompt(t("chordEditPrompt"), role.harmony.chord);
@@ -74,20 +174,31 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
if (changed) onSongUpdate(updatedSong);
};
- /** Documented. */
+
+ /** Return the visual treatment for a rehearsal priority. */
const getPriorityColor = (priority: string) => {
if (priority === "high") return "border-rose-400 bg-rose-400/[0.08] shadow-[0_0_30px_rgba(251,113,133,0.10)]";
if (priority === "medium") return "border-amber-300 bg-amber-300/[0.08] shadow-[0_0_30px_rgba(252,211,77,0.08)]";
return "border-emerald-300 bg-emerald-300/[0.08] shadow-[0_0_30px_rgba(110,231,183,0.08)]";
};
- /** Documented. */
+ /** Return the icon that communicates rehearsal priority. */
const getPriorityIcon = (priority: string) => {
if (priority === "high") return ;
if (priority === "medium") return ;
return ;
};
+ /** Start a four-beat count-in on tonight's section at the analyzed tempo. */
+ const startCountIn = (): void => {
+ if (!countInSection || beatMs === null) {
+ return;
+ }
+
+ setCountInPhase("counting");
+ setCountInBeat(1);
+ };
+
return (
@@ -110,7 +221,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
id={`workspace-section-card-${sectionIndex}`}
tabIndex={-1}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
- loopedSectionIndex === sectionIndex
+ countInSectionIndex === sectionIndex
? "border-cyan-300/50 bg-cyan-950/40 ring-2 ring-cyan-300/70"
: section.confidence.level === "low"
? "border-rose-300/30 bg-rose-950/30"
@@ -126,6 +237,47 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
{t("sectionGrooveLabel")}
{section.groove}
+ {countInSectionIndex === sectionIndex ? (
+
+
+ {countInPhase === "counting" ? (
+
+ {t("workspaceCountInCounting")
+ .replace("{label}", section.label)
+ .replace("{beat}", String(countInBeat))}
+
+ ) : null}
+ {countInPhase === "ready" ? (
+
+ {countInCopy(t("workspaceCountInReady"), section)}
+
+ ) : null}
+
+ ) : null}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index b84f356a8..c2ff8dcea 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -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, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
@@ -84,7 +84,8 @@ describe("Workspace", () => {
render();
- expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
+ const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
+ expect(within(timelineRegion).getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});
it("enables bass transcription from selected role metadata rather than role id text", () => {
@@ -303,6 +304,27 @@ describe("Workspace", () => {
expect(scrollIntoView).toHaveBeenCalled();
});
+ it("routes a selected map loop into the renderer-owned count-in target", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const firstSectionId = song.sections[0]!.id;
+ song.sections[1]!.id = firstSectionId;
+ song.sections[1]!.label = "chorus";
+ song.sections[1]!.timeRange = { start: 30, end: 50 };
+ HTMLElement.prototype.scrollIntoView = vi.fn();
+
+ render();
+ const loopButtons = screen.getAllByRole("button", { name: /Loop .* from .* to .*/ });
+ fireEvent.click(loopButtons[1]!);
+
+ expect(
+ screen.getByRole("button", { name: "Count in chorus from 0:30 to 0:50 at tonight's tempo" })
+ ).toBeTruthy();
+ expect(
+ screen.queryByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" })
+ ).toBeNull();
+ });
+
it("names the first loop from the selected role strip instead of coming soon", () => {
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 8b4703fb2..e385ae780 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -63,6 +63,13 @@
"sectionGrooveLabel": "Groove",
"sectionChordLabel": "Chord",
"sectionCueLabel": "Cue",
+ "workspaceCountInAction": "Count in {label} · {start}–{end}",
+ "workspaceCountInAria": "Count in {label} from {start} to {end} at tonight's tempo",
+ "workspaceCountInUnavailable": "No section is ready to count in yet.",
+ "workspaceCountInNeedsTempo": "Add a tempo before counting in tonight.",
+ "workspaceCountInBeatAria": "Count-in beat {beat} of 4",
+ "workspaceCountInCounting": "Counting in {label} · {beat}",
+ "workspaceCountInReady": "Counted in {label} · {start}–{end}. Start the first pass.",
"priorityLabel": "Priority",
"chordEditAriaLabel": "Edit chord for {roleName} in {sectionLabel}, current {chord}",
"chordEditPrompt": "Enter new chord:",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index c16d8b987..92dec8820 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -63,6 +63,13 @@
"sectionGrooveLabel": "그루브",
"sectionChordLabel": "코드",
"sectionCueLabel": "큐",
+ "workspaceCountInAction": "{label} · {start}–{end} 카운트인",
+ "workspaceCountInAria": "오늘 템포로 {label} {start}부터 {end}까지 카운트인",
+ "workspaceCountInUnavailable": "아직 카운트인할 구간이 없습니다.",
+ "workspaceCountInNeedsTempo": "오늘 카운트인하려면 템포를 먼저 넣어 주세요.",
+ "workspaceCountInBeatAria": "카운트인 {beat}박 / 4박",
+ "workspaceCountInCounting": "{label} 카운트인 중 · {beat}",
+ "workspaceCountInReady": "{label} · {start}–{end} 카운트인 완료. 첫 패스를 시작하세요.",
"priorityLabel": "우선순위",
"chordEditAriaLabel": "{roleName}의 {sectionLabel} 코드 수정, 현재 {chord}",
"chordEditPrompt": "새 코드 입력:",