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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Last updated: 2026-03-11
- Core rehearsal artifacts should include:
- likely harmony by section and by role
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- groove and timing cues relevant to locking the band together
- groove and timing cues relevant to locking the band together, including tempo stability and sustained tempo changes when real audio supports them
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### Added

- Keep temporal-analysis logs free of original local audio paths.
- Surface tempo stability and sustained tempo changes as rehearsal cues when real local audio supports them.
- Invalidate pre-tempo analysis caches so existing local tracks receive the new tempo cue on their next analysis.
- Preserve reusable stem feature caches while final analysis cache schemas evolve.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,40 @@ describe("Workspace", () => {
);
});

it("names a sustained tempo transition from real-audio guidance", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.tempoStability = {
bpmMedian: 108,
bpmStdev: 18,
stability: "variable",
tempoChanges: [{ time: 62.4, fromBpm: 120, toBpm: 96 }]
};

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

expect(screen.getByTestId("tempo-stability")).toHaveTextContent(
"Tempo moves from 120 to 96 BPM around 1:02. Mark that transition before rehearsal."
);
});

it("keeps tempo movement guidance honest when only a steady pass is available", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.tempoStability = {
bpmMedian: 120,
bpmStdev: 0.5,
stability: "steady",
tempoChanges: []
};

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

expect(screen.getByTestId("tempo-stability")).toHaveTextContent(
"The pulse stays steady around 120 BPM. Keep the click even through the first pass."
);
});

it("limits the range callout to the selected role", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ function formatTimelineTime(totalSeconds: number): string {
return `${minutes}:${seconds}`;
}

/** Documented. */
function formatTempoBpm(bpm: number): string {
return Number.isInteger(bpm) ? String(bpm) : String(Number(bpm.toFixed(1)));
}

/** Documented. */
function downloadTextFile(contents: string, type: string, filename: string): void {
const blob = new Blob([contents], { type });
Expand Down Expand Up @@ -71,6 +76,53 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro
}
}

/** Turn analyzed tempo movement into one rehearsal-first next action. */
function tempoStabilityCopy(
value: RehearsalSong["tempoStability"],
t: Translator
): string {
if (
!value ||
!Number.isFinite(value.bpmMedian) ||
value.bpmMedian <= 0 ||
!Array.isArray(value.tempoChanges)
) {
return t("workspaceTempoStabilityMissing");
}

const change = value.tempoChanges[0];
if (
change &&
Number.isFinite(change.time) &&
change.time >= 0 &&
Number.isFinite(change.fromBpm) &&
change.fromBpm > 0 &&
Number.isFinite(change.toBpm) &&
change.toBpm > 0
) {
return fillRangeCopy(t("workspaceTempoChange"), {
fromBpm: formatTempoBpm(change.fromBpm),
toBpm: formatTempoBpm(change.toBpm),
time: formatTimelineTime(change.time)
});
}

const copyKey =
value.stability === "steady"
? "workspaceTempoStabilitySteady"
: value.stability === "loose"
? "workspaceTempoStabilityLoose"
: value.stability === "variable"
? "workspaceTempoStabilityVariable"
: undefined;
if (!copyKey) {
return t("workspaceTempoStabilityMissing");
}
return fillRangeCopy(t(copyKey), {
bpm: formatTempoBpm(value.bpmMedian)
});
}

/** Documented. */
const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) {
return (
Expand Down Expand Up @@ -310,6 +362,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<p className="mt-2 text-sm leading-6 text-slate-100">{firstRangeCopy}</p>
</section>

<section
className="rounded-2xl border border-amber-300/20 bg-amber-300/[0.07] p-4"
data-testid="tempo-stability"
aria-label={t("workspaceTempoStabilityTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-amber-200">{t("workspaceTempoStabilityTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{tempoStabilityCopy(song.tempoStability, t)}</p>
</section>

<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<section className="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4 md:col-span-2">
<p className="text-xs font-black uppercase tracking-[0.24em] text-cyan-300">{t("workspaceSongTimelineLabel")}</p>
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/lib/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,21 @@ describe("export generation", () => {
});

it("generates chart summary JSON", () => {
const jsonStr = generateChartSummaryJson(mockSong);
const jsonStr = generateChartSummaryJson({
...mockSong,
tempo: 120,
tempoStability: {
bpmMedian: 120,
bpmStdev: 0.5,
stability: "steady",
tempoChanges: []
}
});
const parsed = JSON.parse(jsonStr);
expect(parsed.title).toBe("Test");
expect(parsed.sections[0].roles[0].chord).toBe("=Cmaj7");
expect(parsed.tempo).toBe(120);
expect(parsed.tempoStability.stability).toBe("steady");
});

it("generates chart summary JSON when headline is missing", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function generateChartSummaryJson(song: RehearsalSong): string {
const summary = {
title: song.title,
headline: song.exportSummary?.headline || "",
tempo: song.tempo,
tempoStability: song.tempoStability,
sections: song.sections.map(s => ({
label: s.label,
groove: s.groove,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@
"workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.",
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
"workspaceTempoStabilityTitle": "Tempo movement",
"workspaceTempoStabilityMissing": "Tempo movement still needs a real audio pass. Set the opening click by ear before rehearsal.",
"workspaceTempoStabilitySteady": "The pulse stays steady around {bpm} BPM. Keep the click even through the first pass.",
"workspaceTempoStabilityLoose": "The pulse breathes around {bpm} BPM. Agree on the opening click before the first pass.",
"workspaceTempoStabilityVariable": "The pulse moves around {bpm} BPM. Check the marked transition before rehearsal.",
"workspaceTempoChange": "Tempo moves from {fromBpm} to {toBpm} BPM around {time}. Mark that transition before rehearsal.",
"sectionRangeLabel": "Range",
"sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
}
6 changes: 6 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
"workspaceTempoStabilityTitle": "템포 움직임",
"workspaceTempoStabilityMissing": "템포 움직임은 아직 실제 오디오 확인이 필요합니다. 합주 전에 시작 클릭을 귀로 맞춰 보세요.",
"workspaceTempoStabilitySteady": "박이 {bpm} BPM 주변에서 안정적입니다. 첫 번째 패스에서도 클릭을 고르게 유지해 보세요.",
"workspaceTempoStabilityLoose": "박이 {bpm} BPM 주변에서 조금 흔들립니다. 첫 번째 패스 전에 시작 클릭을 함께 정해 보세요.",
"workspaceTempoStabilityVariable": "박이 {bpm} BPM 주변에서 움직입니다. 합주 전에 표시된 전환을 확인해 보세요.",
"workspaceTempoChange": "약 {time}에서 템포가 {fromBpm}에서 {toBpm} BPM으로 움직입니다. 합주 전에 그 전환을 표시해 두세요.",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
75 changes: 74 additions & 1 deletion packages/shared-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ export type SectionTimeRange = {
end: number;
};

/** Documented. */
export type TempoChange = {
time: number;
fromBpm: number;
toBpm: number;
};

/** Documented. */
export type TempoStability = {
bpmMedian: number;
bpmStdev: number;
stability: "steady" | "loose" | "variable";
tempoChanges: TempoChange[];
};

/** Documented. */
export type RehearsalSection = {
id: string;
Expand Down Expand Up @@ -223,6 +238,7 @@ export type RehearsalSong = {
id: string;
title: string;
tempo?: number;
tempoStability?: TempoStability;
sections: RehearsalSection[];
exportSummary: ExportSummary;
collaboration?: RehearsalCollaboration;
Expand Down Expand Up @@ -1645,6 +1661,57 @@ function validateSectionTimeRange(value: unknown, path: string): string | null {
return null;
}

/** Documented. */
function validateTempoChange(value: unknown, path: string): string | null {
if (!isRecord(value)) {
return invalidField(path);
}
const extraKey = unexpectedKey(value, ["time", "fromBpm", "toBpm"], path);
if (extraKey) {
return extraKey;
}
if (typeof value.time !== "number" || !Number.isFinite(value.time) || value.time < 0) {
return invalidField(`${path}.time`);
}
Comment thread
seonghobae marked this conversation as resolved.
if (typeof value.fromBpm !== "number" || !Number.isFinite(value.fromBpm) || value.fromBpm <= 0) {
return invalidField(`${path}.fromBpm`);
}
if (typeof value.toBpm !== "number" || !Number.isFinite(value.toBpm) || value.toBpm <= 0) {
return invalidField(`${path}.toBpm`);
}
return null;
}

/** Documented. */
function validateTempoStability(value: unknown, path: string): string | null {
if (!isRecord(value)) {
return invalidField(path);
}
const extraKey = unexpectedKey(value, ["bpmMedian", "bpmStdev", "stability", "tempoChanges"], path);
if (extraKey) {
return extraKey;
}
if (typeof value.bpmMedian !== "number" || !Number.isFinite(value.bpmMedian) || value.bpmMedian <= 0) {
return invalidField(`${path}.bpmMedian`);
}
if (typeof value.bpmStdev !== "number" || !Number.isFinite(value.bpmStdev) || value.bpmStdev < 0) {
return invalidField(`${path}.bpmStdev`);
}
if (!isOneOf(["steady", "loose", "variable"] as const, value.stability)) {
return invalidField(`${path}.stability`);
}
if (!isDenseArray(value.tempoChanges)) {
return invalidField(`${path}.tempoChanges`);
}
for (const [index, change] of value.tempoChanges.entries()) {
const changeError = validateTempoChange(change, `${path}.tempoChanges[${index}]`);
if (changeError) {
return changeError;
}
}
return null;
}

/** Documented. */
function validateRehearsalSection(value: unknown, path: string): string | null {
if (!isRecord(value)) {
Expand Down Expand Up @@ -1787,7 +1854,7 @@ function validateRehearsalSong(
}
const extraKey = unexpectedKey(
normalized,
["id", "title", "tempo", "sections", "exportSummary", "collaboration", "scoreAttachments"],
["id", "title", "tempo", "tempoStability", "sections", "exportSummary", "collaboration", "scoreAttachments"],
""
);
if (extraKey) {
Expand All @@ -1805,6 +1872,12 @@ function validateRehearsalSong(
) {
return invalidField("tempo");
}
if (normalized.tempoStability !== undefined) {
const tempoStabilityError = validateTempoStability(normalized.tempoStability, "tempoStability");
if (tempoStabilityError) {
return tempoStabilityError;
}
}
if (!isDenseArray(normalized.sections)) {
return invalidField("sections");
}
Expand Down
31 changes: 31 additions & 0 deletions packages/shared-types/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,37 @@ describe("shared type helpers", () => {
expect(() => parseRehearsalSong(invalidTempoInfinity)).toThrow("tempo");
});

it("validates optional tempo stability guidance", () => {
const song = createDemoRehearsalSong();
song.tempoStability = {
bpmMedian: 120,
bpmStdev: 0.5,
stability: "variable",
tempoChanges: [{ time: 32.5, fromBpm: 120, toBpm: 96 }]
};
expect(isRehearsalSong(song)).toBe(true);
expect(parseRehearsalSong(song).tempoStability).toEqual(song.tempoStability);

expect(() => parseRehearsalSong({
...song,
tempoStability: {
...song.tempoStability,
tempoChanges: [{ time: -1, fromBpm: 120, toBpm: 96 }]
}
})).toThrow("tempoStability.tempoChanges[0].time");
expect(() => parseRehearsalSong({
...song,
tempoStability: {
...song.tempoStability,
tempoChanges: [{ time: 32.5, fromBpm: 120, toBpm: 0 }]
}
})).toThrow("tempoStability.tempoChanges[0].toBpm");
expect(() => parseRehearsalSong({
...song,
tempoStability: { ...song.tempoStability, bpmMedian: 0 }
})).toThrow("tempoStability.bpmMedian");
});

it("validates practiceProgress successfully when valid", () => {
const validPracticeProgressSong = createDemoRehearsalSong();
validPracticeProgressSong.sections[0]!.roles[0]!.practiceProgress = 0;
Expand Down
Loading