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
6 changes: 3 additions & 3 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ yt_dlp/extractor/vice.py
# GHSA-wrw7-89jp-8q8g / RUSTSEC-2024-0429: glib 0.18.5 VariantStrIter
# unsoundness inherited only through the Tauri/wry/webkit2gtk/gtk GTK3 stack.
# BandScope ships Windows/macOS artifacts only; Cargo target trees for those
# release targets do not include this Linux GTK stack. Guarded by
# scripts/checks/verify_supply_chain.py and remove when upstream drops or
# patches the chain.
# release targets do not include this Linux GTK stack. No compatible glib >=0.20
# path exists for this owner chain yet. Guarded by scripts/checks/verify_supply_chain.py
# and remove when upstream drops or patches the chain.
GHSA-wrw7-89jp-8q8g exp:2026-10-31
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

## [0.1.3] - 2026-04-29
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<CardHeader className="border-b border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.16),transparent_32%),linear-gradient(135deg,rgba(15,23,42,0.92),rgba(2,6,23,0.96))] p-5 pb-6 md:p-7">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">{t("workspaceRehearsalMapLabel")}</p>
<div className="flex items-center gap-2">
<p className="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">{t("workspaceRehearsalMapLabel")}</p>
{song.tempo && (
<span className="rounded-full border border-cyan-300/30 bg-cyan-300/10 px-2.5 py-0.5 text-[0.65rem] font-bold text-cyan-100">
{t("workspaceTempoLabel")}: {song.tempo} BPM
</span>
)}
</div>
<h2 className="text-3xl font-black tracking-tight text-white md:text-4xl">{song.title}</h2>
<CardDescription className="text-base font-medium text-slate-300">
{song.exportSummary?.headline || t("workspaceRehearsalFallback")}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
"workspaceRehearsalMapLabel": "오늘의 합주 지도",
"workspaceRehearsalFallback": "합주 작업 공간",
"workspaceTempoLabel": "템포",
"workspaceSongStructureLabel": "곡 구조",
"workspaceRehearsalTimelineLabel": "합주 타임라인",
"workspaceSongTimelineLabel": "곡 타임라인",
Expand Down
10 changes: 9 additions & 1 deletion packages/shared-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export type RehearsalWorkspace = {
export type RehearsalSong = {
id: string;
title: string;
tempo?: number;
sections: RehearsalSection[];
exportSummary: ExportSummary;
collaboration?: RehearsalCollaboration;
Expand Down Expand Up @@ -412,6 +413,7 @@ function invalidProjectSummaryField(path: string): string {
const demoRehearsalSongSeed: RehearsalSong = {
id: "demo-song",
title: "Late Night Set",
tempo: 120,
sections: [
{
id: "verse-1",
Expand Down Expand Up @@ -1746,7 +1748,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;
}
Expand All @@ -1756,6 +1758,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");
}
Expand Down
27 changes: 27 additions & 0 deletions packages/shared-types/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("validates practiceProgress successfully when valid", () => {
const validPracticeProgressSong = createDemoRehearsalSong();
validPracticeProgressSong.sections[0]!.roles[0]!.practiceProgress = 0;
Expand Down
37 changes: 32 additions & 5 deletions scripts/checks/verify_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1891,23 +1891,50 @@ def rust_osv_exception_violations(

def rust_trivy_exception_violations(
trivy_config: Path = TRIVY_IGNORE_CONFIG,
audit_config: Path = RUST_AUDIT_CONFIG,
osv_config: Path = RUST_OSV_SCANNER_CONFIG,
) -> list[str]:
"""Return Trivy exception drift for the remaining upstream-owned glib advisory."""
"""Return Trivy exception drift from repo-owned Rust advisory policy."""
violations: list[str] = []
try:
audit_ignores = rust_audit_ignored_advisories(audit_config)
except tomllib.TOMLDecodeError as error:
return [toml_decode_violation(audit_config, error)]
try:
osv_ignores = rust_osv_ignored_advisories(osv_config)
except tomllib.TOMLDecodeError as error:
return [toml_decode_violation(osv_config, error)]

glib_policy_active = (
RUST_GLIB_ADVISORY_ID in audit_ignores
or RUST_GLIB_ADVISORY_ID in osv_ignores
)
if not trivy_config.exists():
return [f"Trivy ignore config missing: {trivy_config}"]

trivy_ignores = trivy_ignored_advisories(trivy_config)
entry = trivy_ignores.get(RUST_GLIB_TRIVY_ADVISORY_ID)
if entry is None:
return [

if glib_policy_active and entry is None:
violations.append(
f"{trivy_config}: missing Trivy ignore for {RUST_GLIB_TRIVY_ADVISORY_ID} "
f"tracked as {RUST_GLIB_ADVISORY_ID}"
]
)
return violations
if not glib_policy_active and RUST_GLIB_TRIVY_ADVISORY_ID in trivy_ignores:
violations.append(
f"{trivy_config}: unexpected Trivy ignore for "
f"{RUST_GLIB_TRIVY_ADVISORY_ID} without matching cargo-audit/OSV policy"
)
return violations
if not glib_policy_active:
return violations

violations: list[str] = []
reason = entry["reason"]
required_reason_tokens = (
RUST_GLIB_ADVISORY_ID,
"glib 0.18.5",
"glib >=0.20",
"Tauri/wry/webkit2gtk/gtk GTK3 stack",
"Windows/macOS artifacts only",
"verify_supply_chain.py",
Expand Down
29 changes: 27 additions & 2 deletions services/analysis-engine/src/bandscope_analysis/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ class RehearsalSong(TypedDict):

id: str
title: str
tempo: NotRequired[int]
sections: list[RehearsalSectionPayload]
exportSummary: ExportSummaryPayload

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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": [
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions services/analysis-engine/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading