-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace): set up tonight's part before the first entrance #910
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
5e55876
25bd74f
1db3ff0
12b1c4a
ef4b354
1c841b0
6f686fe
6d838ca
098f634
d27bff6
fd36651
184ad06
b06be3f
0ad6315
16abeb1
512c860
61aa51f
c49d0f3
d14634b
a6bc38d
4850852
8d1fdd2
1cc2699
4e252a4
cce6ff4
fbb6973
b6bcecb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,14 +5,16 @@ import { Loader2 } from "lucide-react"; | |
|
|
||
| const EMPTY_NOTES: TranscriptionNote[] = []; | ||
|
|
||
| /** Documented. */ | ||
| /** Inputs for the selected role's rehearsal groove map. */ | ||
| interface GrooveMapProps { | ||
| notes?: TranscriptionNote[]; | ||
| isLoading?: boolean; | ||
| entranceOnset?: number; | ||
| roleName: string; | ||
| } | ||
|
|
||
| /** Documented. */ | ||
| function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | ||
| /** Render the selected role's transcription and optional first-entrance emphasis. */ | ||
| function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName }: GrooveMapProps) { | ||
| const renderedNotes = notes ?? EMPTY_NOTES; | ||
|
|
||
| // Find max offset to determine timeline width | ||
|
|
@@ -36,6 +38,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | |
| return map; | ||
| }, [uniquePitches]); | ||
|
|
||
| const entranceIndex = useMemo(() => { | ||
| if (entranceOnset === undefined) { | ||
| return -1; | ||
| } | ||
| return renderedNotes.findIndex((note) => note.onset === entranceOnset); | ||
| }, [entranceOnset, renderedNotes]); | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <div | ||
|
|
@@ -44,7 +53,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | |
| > | ||
| <span className="flex items-center font-medium text-teal-100"> | ||
| <Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" /> | ||
| Checking the bass line... 45% | ||
| Checking the {roleName} line... 45% | ||
| </span> | ||
| <Button variant="outline" size="sm" className="border-teal-300/20 bg-teal-300/10 text-teal-100 hover:bg-teal-300/20 hover:text-white"> | ||
| Cancel | ||
|
|
@@ -55,10 +64,8 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | |
|
|
||
| if (renderedNotes.length === 0) { | ||
| return ( | ||
| <div | ||
| className="mt-4 rounded-lg border border-dashed border-cyan-200/15 bg-slate-950/60 p-6 text-center text-sm text-slate-400" | ||
| > | ||
| No bass line transcription yet. Use it when you want to check the groove before rehearsal. | ||
| <div className="mt-4 rounded-lg border border-dashed border-cyan-200/15 bg-slate-950/60 p-6 text-center text-sm text-slate-300"> | ||
| No {roleName} transcription yet. Use it when you want to check the groove before rehearsal. | ||
|
Comment on lines
64
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: GrooveMap copy stays English under Korean locale The loading, empty, and aria-label strings in (Refers to this code) Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| </div> | ||
| ); | ||
| } | ||
|
|
@@ -68,7 +75,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | |
| className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" | ||
| role="region" | ||
| tabIndex={0} | ||
| aria-label="Bass transcription groove map" | ||
| aria-label={`${roleName} transcription groove map`} | ||
| > | ||
| <div className="sr-only"> | ||
| Transcription complete. {renderedNotes.length} notes analyzed. | ||
|
|
@@ -95,20 +102,26 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | |
| const leftPercent = (note.onset / maxTime) * 100; | ||
| const widthPercent = ((note.offset - note.onset) / maxTime) * 100; | ||
| const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`; | ||
| const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset; | ||
|
|
||
| return ( | ||
| <div | ||
| key={index} | ||
| className="absolute h-6 rounded bg-gradient-to-r from-teal-300 via-cyan-300 to-violet-300 shadow-[0_0_18px_rgba(94,234,212,0.28)]" | ||
| id={isEntrance && index === entranceIndex ? "workspace-groove-entrance" : undefined} | ||
|
Comment on lines
+105
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Single entrance anchor for simultaneous notes GrooveMap.tsx:110 gates the Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| className={`absolute h-6 rounded shadow-[0_0_18px_rgba(94,234,212,0.28)] ${ | ||
| isEntrance | ||
| ? "bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300 ring-2 ring-amber-200" | ||
| : "bg-gradient-to-r from-teal-300 via-cyan-300 to-violet-300" | ||
| }`} | ||
| style={{ | ||
| top: `${pitchIndex * 40 + 8}px`, | ||
| left: `${leftPercent}%`, | ||
| width: `${widthPercent}%` | ||
| }} | ||
| title={noteLabel} | ||
| title={isEntrance ? `Tonight's entrance · ${noteLabel}` : noteLabel} | ||
| > | ||
| <span className="sr-only"> | ||
| {noteLabel} | ||
| {isEntrance ? `Tonight's entrance. ${noteLabel}` : noteLabel} | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </span> | ||
| </div> | ||
| ); | ||
|
|
@@ -120,4 +133,4 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { | |
|
|
||
| const GrooveMap = memo(GrooveMapComponent); | ||
|
|
||
| export { GrooveMap }; | ||
| export { GrooveMap }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { resolve } from "node:path"; | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { GrooveMap } from "./GrooveMap"; | ||
| import { Workspace } from "./Workspace"; | ||
|
|
||
| /** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */ | ||
| function replaceRole(song: ReturnType<typeof createDemoRehearsalSong>, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) { | ||
| song.sections = song.sections.map((section) => ({ | ||
| ...section, | ||
| roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role)) | ||
| })); | ||
| } | ||
|
|
||
| describe("Workspace review regressions", () => { | ||
| it("keeps copy interpolation free of dynamically constructed regular expressions", () => { | ||
| const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8"); | ||
| expect(source).not.toContain("new RegExp("); | ||
| }); | ||
|
|
||
| it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => { | ||
| render( | ||
| <GrooveMap | ||
| roleName="Lead Guitar" | ||
| entranceOnset={1} | ||
| notes={[ | ||
| { pitch: "E4", onset: 1, offset: 1.5, velocity: 0.8 }, | ||
| { pitch: "G4", onset: 1, offset: 1.5, velocity: 0.75 } | ||
| ]} | ||
| /> | ||
| ); | ||
|
|
||
| const region = screen.getByRole("region", { name: "Lead Guitar transcription groove map" }); | ||
| expect(region.className).toContain("focus-visible:ring-2"); | ||
| expect(document.querySelectorAll("#workspace-groove-entrance")).toHaveLength(1); | ||
| expect(screen.getAllByTitle(/Tonight's entrance/)).toHaveLength(2); | ||
| }); | ||
|
|
||
| it("uses the selected role name in groove-map empty and loading copy", () => { | ||
| const { rerender } = render(<GrooveMap roleName="Lead Guitar" notes={[]} />); | ||
| expect(screen.getByText("No Lead Guitar transcription yet. Use it when you want to check the groove before rehearsal.")).toBeTruthy(); | ||
|
|
||
| rerender(<GrooveMap roleName="Lead Guitar" notes={[]} isLoading />); | ||
| expect(screen.getByText("Checking the Lead Guitar line... 45%")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("keeps range-backed setup available when no exact first note exists", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const roleId = song.sections[0]!.roles[0]!.id; | ||
| replaceRole(song, roleId, (role) => ({ | ||
| ...role, | ||
| setupNote: "Tune down a whole step.", | ||
| transcription: undefined, | ||
| range: { | ||
| ...role.range, | ||
| lowestNote: "C#2", | ||
| highestNote: "E3" | ||
| } | ||
| })); | ||
|
|
||
| render(<Workspace song={song} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); | ||
|
|
||
| const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i }); | ||
| expect(setupButton).toBeEnabled(); | ||
| const visibleLabel = setupButton.textContent?.trim() ?? ""; | ||
| expect(visibleLabel).not.toBe(""); | ||
| expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel); | ||
| }); | ||
|
|
||
| it("keeps placeholder-looking role names literal in setup copy", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const roleId = song.sections[0]!.roles[0]!.id; | ||
| replaceRole(song, roleId, (role) => ({ | ||
| ...role, | ||
| name: "{low}", | ||
| setupNote: "Tune down a whole step.", | ||
| transcription: undefined, | ||
| range: { | ||
| ...role.range, | ||
| lowestNote: "C#2", | ||
| highestNote: "E3" | ||
| } | ||
| })); | ||
|
|
||
| render(<Workspace song={song} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "{low}" })); | ||
|
|
||
| expect( | ||
| screen.getByRole("button", { | ||
| name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map" | ||
| }) | ||
| ).toBeEnabled(); | ||
| }); | ||
|
|
||
| it("natively disables setup when a cue has neither an entrance nor a playable range", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const roleId = song.sections[0]!.roles[0]!.id; | ||
| replaceRole(song, roleId, (role) => ({ | ||
| ...role, | ||
| setupNote: "Tune down a whole step.", | ||
| transcription: undefined, | ||
| range: { | ||
| ...role.range, | ||
| lowestNote: " ", | ||
| highestNote: " " | ||
| } | ||
| })); | ||
|
|
||
| render(<Workspace song={song} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); | ||
|
|
||
| const setupButton = screen.getByRole("button", { | ||
| name: "No first entrance or playable range yet. Stay on tonight's map." | ||
| }); | ||
| expect(setupButton).toBeDisabled(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.