diff --git a/CHANGELOG.md b/CHANGELOG.md index 071a641d..e50ddf40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 2f990eec..8338597a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -209,7 +209,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
-

{t("workspaceRehearsalMapLabel")}

+
+

{t("workspaceRehearsalMapLabel")}

+ {song.tempo && ( + + {t("workspaceTempoLabel")}: {song.tempo} BPM + + )} +

{song.title}

{song.exportSummary?.headline || t("workspaceRehearsalFallback")} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 4e098662..c837bfc5 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -34,6 +34,7 @@ "workspaceErrorState": "An error occurred during analysis. Please try again.", "workspaceRehearsalMapLabel": "Tonight's rehearsal map", "workspaceRehearsalFallback": "Rehearsal Workspace", + "workspaceTempoLabel": "Tempo", "workspaceSongStructureLabel": "Song Structure", "workspaceRehearsalTimelineLabel": "Rehearsal timeline", "workspaceSongTimelineLabel": "Song Timeline", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 2b59d5ee..ac05b783 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -34,6 +34,7 @@ "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", "workspaceRehearsalMapLabel": "오늘의 합주 지도", "workspaceRehearsalFallback": "합주 작업 공간", + "workspaceTempoLabel": "템포", "workspaceSongStructureLabel": "곡 구조", "workspaceRehearsalTimelineLabel": "합주 타임라인", "workspaceSongTimelineLabel": "곡 타임라인", diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ff04618b..af008812 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -215,6 +215,7 @@ export type RehearsalWorkspace = { export type RehearsalSong = { id: string; title: string; + tempo?: number; sections: RehearsalSection[]; exportSummary: ExportSummary; collaboration?: RehearsalCollaboration; @@ -411,6 +412,7 @@ function invalidProjectSummaryField(path: string): string { const demoRehearsalSongSeed: RehearsalSong = { id: "demo-song", title: "Late Night Set", + tempo: 120, sections: [ { id: "verse-1", @@ -1738,7 +1740,7 @@ function validateRehearsalSong( if (!isRecord(normalized)) { return invalidField("root"); } - const extraKey = unexpectedKey(normalized, ["id", "title", "sections", "exportSummary", "collaboration"], ""); + const extraKey = unexpectedKey(normalized, ["id", "title", "tempo", "sections", "exportSummary", "collaboration"], ""); if (extraKey) { return extraKey; } @@ -1748,6 +1750,12 @@ function validateRehearsalSong( if (typeof normalized.title !== "string") { return invalidField("title"); } + if ( + normalized.tempo !== undefined && + (typeof normalized.tempo !== "number" || !Number.isFinite(normalized.tempo) || normalized.tempo <= 0) + ) { + return invalidField("tempo"); + } if (!isDenseArray(normalized.sections)) { return invalidField("sections"); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index bd2bc6fa..ae40f19c 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -892,6 +892,33 @@ describe("shared type helpers", () => { })).toThrow("sections[0].roles[0].manualOverrides[0].extraField"); }); + it("validates tempo correctly", () => { + const validSong = createDemoRehearsalSong(); + expect(isRehearsalSong(validSong)).toBe(true); + validSong.tempo = 140; + expect(isRehearsalSong(validSong)).toBe(true); + + const withoutTempo = createDemoRehearsalSong(); + delete withoutTempo.tempo; + expect(isRehearsalSong(withoutTempo)).toBe(true); + expect(parseRehearsalSong(withoutTempo)).toEqual(withoutTempo); + + const invalidTempoString = { ...createDemoRehearsalSong(), tempo: "120" }; + expect(() => parseRehearsalSong(invalidTempoString)).toThrow("tempo"); + + const invalidTempoZero = { ...createDemoRehearsalSong(), tempo: 0 }; + expect(() => parseRehearsalSong(invalidTempoZero)).toThrow("tempo"); + + const invalidTempoNegative = { ...createDemoRehearsalSong(), tempo: -10 }; + expect(() => parseRehearsalSong(invalidTempoNegative)).toThrow("tempo"); + + const invalidTempoNaN = { ...createDemoRehearsalSong(), tempo: NaN }; + expect(() => parseRehearsalSong(invalidTempoNaN)).toThrow("tempo"); + + const invalidTempoInfinity = { ...createDemoRehearsalSong(), tempo: Infinity }; + expect(() => parseRehearsalSong(invalidTempoInfinity)).toThrow("tempo"); + }); + it("covers detailed validation branches", () => { const createInvalidSong = (mutate: (song: RehearsalSong) => unknown) => { const song = createDemoRehearsalSong(); diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index a193bce2..17cb1743 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -153,6 +153,7 @@ class RehearsalSong(TypedDict): id: str title: str + tempo: NotRequired[int] sections: list[RehearsalSectionPayload] exportSummary: ExportSummaryPayload @@ -422,7 +423,7 @@ def _build_from_pipeline( # Build export summary from detected structure headline = _build_export_headline(detected_sections) - return { + song: RehearsalSong = { "id": "analyzed-song", "title": features.get("title", "Analyzed Track"), "sections": payload_sections, @@ -432,6 +433,8 @@ def _build_from_pipeline( "focusSections": focus_sections, }, } + _apply_tempo(song, features) + return song def _build_from_arrangement(audio_features: dict[str, Any] | None = None) -> RehearsalSong: @@ -445,7 +448,7 @@ def _build_from_arrangement(audio_features: dict[str, Any] | None = None) -> Reh verse_topology = role_result["topologies"][0] verse_roles = verse_topology["active_roles"] - return { + song: RehearsalSong = { "id": "demo-song", "title": "Late Night Set", "sections": [ @@ -469,6 +472,28 @@ def _build_from_arrangement(audio_features: dict[str, Any] | None = None) -> Reh "focusSections": ["verse"], }, } + _apply_tempo(song, audio_features) + return song + + +def _coerce_tempo_bpm(bpm_val: Any) -> int | None: + """Return an integer tempo if the input represents a finite positive number.""" + if isinstance(bpm_val, bool): + return None + if not isinstance(bpm_val, (int, float)): + return None + if np.isnan(bpm_val) or np.isinf(bpm_val) or bpm_val <= 0: + return None + return int(round(bpm_val)) + + +def _apply_tempo(song: RehearsalSong, audio_features: dict[str, Any] | None) -> None: + """Attach a sanitized integer tempo property to a rehearsal song.""" + if not audio_features: + return + bpm = _coerce_tempo_bpm(audio_features.get("bpm")) + if bpm is not None: + song["tempo"] = bpm def _reconstruct_mix(stems: dict[str, Any]) -> Any: diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index ea55cba2..73f392db 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -277,11 +277,37 @@ def test_build_demo_rehearsal_song_matches_expected_fixture() -> None: song = build_demo_rehearsal_song() assert song["title"] == "Late Night Set" + assert song.get("tempo") is None assert song["sections"][0]["timeRange"] == {"start": 10, "end": 30} assert song["sections"][0]["roles"][0]["id"] == "bass-guitar" assert song["sections"][0]["roles"][4]["manualOverrides"][0]["value"]["source"] == "user" +def test_build_demo_rehearsal_song_with_tempo() -> None: + """Ensure build_demo_rehearsal_song incorporates tempo from audio features.""" + song = build_demo_rehearsal_song({"bpm": 120.4}) + assert song.get("tempo") == 120 + + +def test_coerce_tempo_bpm() -> None: + """Ensure _coerce_tempo_bpm handles various edge cases correctly.""" + import numpy as np + + from bandscope_analysis.api import _coerce_tempo_bpm + + assert _coerce_tempo_bpm(120.4) == 120 + assert _coerce_tempo_bpm(120) == 120 + assert _coerce_tempo_bpm(True) is None + assert _coerce_tempo_bpm(False) is None + assert _coerce_tempo_bpm("120") is None + assert _coerce_tempo_bpm(None) is None + assert _coerce_tempo_bpm(np.nan) is None + assert _coerce_tempo_bpm(np.inf) is None + assert _coerce_tempo_bpm(-np.inf) is None + assert _coerce_tempo_bpm(0) is None + assert _coerce_tempo_bpm(-120) is None + + def test_build_section_time_range_matches_desktop_bounds() -> None: """Ensure Python output cannot exceed the shared Rust u32 timing contract.""" assert build_section_time_range(10, 30) == {"start": 10, "end": 30}