Skip to content
Draft
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: 2 additions & 2 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
96 changes: 95 additions & 1 deletion apps/desktop/src/features/workspace/SectionRoadmap.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 { 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";
Expand All @@ -15,6 +15,7 @@ function setNavigatorLanguage(language: string) {
describe("SectionRoadmap", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
vi.useRealTimers();
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -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(<SectionRoadmap song={song} activeRole={null} />);

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(<SectionRoadmap song={song} activeRole={null} />);
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(<SectionRoadmap song={song} activeRole={null} />);

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(<SectionRoadmap song={song} activeRole={null} loopedSectionIndex={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("localizes tonight's count-in action", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();

render(<SectionRoadmap song={song} activeRole={null} />);

expect(screen.getByRole("button", { name: "오늘 템포로 verse 0:10부터 0:30까지 카운트인" })).toBeTruthy();
expect(screen.getByText("verse · 0:10–0:30 카운트인")).toBeTruthy();
});
});
168 changes: 160 additions & 8 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,134 @@
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
onSongUpdate?: (song: RehearsalSong) => void;
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)
.replace("{sectionLabel}", sectionLabel)
.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);
Expand Down Expand Up @@ -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 <AlertCircle className="size-4 text-rose-300" aria-hidden="true" />;
if (priority === "medium") return <Info className="size-4 text-amber-200" aria-hidden="true" />;
return <CheckCircle2 className="size-4 text-emerald-200" aria-hidden="true" />;
};

/** 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 (
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
Expand All @@ -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"
Expand All @@ -126,6 +237,47 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
<span className="mr-2 text-[0.65rem] font-bold uppercase tracking-wider text-slate-400">{t("sectionGrooveLabel")}</span>
{section.groove}
</div>
{countInSectionIndex === sectionIndex ? (
<div className="mt-3 space-y-2">
<Button
type="button"
disabled={beatMs === null || countInPhase === "counting"}
aria-label={
beatMs === null
? t("workspaceCountInNeedsTempo")
: countInCopy(t("workspaceCountInAria"), section)
}
title={
beatMs === null
? t("workspaceCountInNeedsTempo")
: countInCopy(t("workspaceCountInAria"), section)
}
onClick={startCountIn}
variant="outline"
className="min-h-11 w-full border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/5 disabled:text-slate-400"
>
{countInCopy(t("workspaceCountInAction"), section)}
</Button>
{countInPhase === "counting" ? (
<p
className="text-sm font-semibold text-cyan-100"
role="status"
aria-live="polite"
aria-label={t("workspaceCountInBeatAria").replace("{beat}", String(countInBeat))}
data-testid="workspace-count-in-beat"
>
{t("workspaceCountInCounting")
.replace("{label}", section.label)
.replace("{beat}", String(countInBeat))}
</p>
) : null}
{countInPhase === "ready" ? (
<p className="text-sm font-semibold text-cyan-100" role="status" aria-live="polite">
{countInCopy(t("workspaceCountInReady"), section)}
</p>
) : null}
</div>
) : null}
</CardHeader>

<CardContent className="p-4 space-y-4">
Expand Down
26 changes: 24 additions & 2 deletions 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, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
Expand Down Expand Up @@ -84,7 +84,8 @@ describe("Workspace", () => {

render(<Workspace song={song} />);

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", () => {
Expand Down Expand Up @@ -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(<Workspace song={song} />);
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();
Expand Down
Loading