From 3fb96a3f01a127520e726fbe99023dafa4701f14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:48:56 +0900 Subject: [PATCH 01/15] test(temporal): require semantic stability identifiers --- .../tests/test_temporal_naming_contract.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 services/analysis-engine/tests/test_temporal_naming_contract.py diff --git a/services/analysis-engine/tests/test_temporal_naming_contract.py b/services/analysis-engine/tests/test_temporal_naming_contract.py new file mode 100644 index 000000000..053eab323 --- /dev/null +++ b/services/analysis-engine/tests/test_temporal_naming_contract.py @@ -0,0 +1,73 @@ +"""Naming-contract regression tests for temporal stability internals.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +TEMPORAL_STABILITY_MODULE = ( + Path(__file__).resolve().parents[1] + / "src" + / "bandscope_analysis" + / "temporal" + / "stability.py" +) + +LEGACY_UNDERSPECIFIED_IDENTIFIERS = frozenset( + { + "after", + "beats", + "before", + "bpms", + "changes", + "cv", + "deviation", + "entry", + "flagged", + "index", + "intervals", + "run", + "ties", + "window", + } +) + +REQUIRED_SEMANTIC_IDENTIFIERS = frozenset( + { + "after_bpm_median", + "beat_intervals", + "beat_times_array", + "before_bpm_median", + "bpm_variation_coefficient", + "comparison_window_beats", + "flagged_boundaries", + "flagged_boundary", + "local_bpm_values", + "relative_bpm_deviation", + "tempo_change_run", + "tempo_changes", + "tied_boundaries", + } +) + + +def _module_identifiers(source_text: str) -> set[str]: + """Return organization-owned Python identifiers declared or referenced by the module.""" + syntax_tree = ast.parse(source_text) + identifiers = { + node.id for node in ast.walk(syntax_tree) if isinstance(node, ast.Name) + } + identifiers.update( + node.arg for node in ast.walk(syntax_tree) if isinstance(node, ast.arg) + ) + return identifiers + + +def test_temporal_stability_internal_identifiers_are_semantically_specific() -> None: + """Require bounded-context names instead of generic one-word temporal identifiers.""" + source_text = TEMPORAL_STABILITY_MODULE.read_text(encoding="utf-8") + identifiers = _module_identifiers(source_text) + + assert not LEGACY_UNDERSPECIFIED_IDENTIFIERS.intersection(identifiers) + assert REQUIRED_SEMANTIC_IDENTIFIERS.issubset(identifiers) From 3f08af6eadf8bb4defcc5ba8020d5f142cee14aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:49:46 +0900 Subject: [PATCH 02/15] refactor(temporal): use semantic stability identifiers --- .../bandscope_analysis/temporal/stability.py | 140 +++++++++++------- 1 file changed, 88 insertions(+), 52 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/stability.py b/services/analysis-engine/src/bandscope_analysis/temporal/stability.py index 316b36602..a6863499f 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/stability.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/stability.py @@ -94,25 +94,28 @@ def _safe_default() -> TempoStability: } -def _classify_stability(cv: float) -> Literal["steady", "loose", "variable"]: +def _classify_stability( + bpm_variation_coefficient: float, +) -> Literal["steady", "loose", "variable"]: """Map a coefficient of variation to a stability label. Args: - cv: Coefficient of variation of the local BPM series. + bpm_variation_coefficient: Coefficient of variation of the local BPM series. Returns: - "steady" if cv < 0.04, "loose" if cv < 0.10, else "variable". + "steady" if the coefficient is below 0.04, "loose" if it is below + 0.10, else "variable". """ - if cv < STEADY_CV_THRESHOLD: + if bpm_variation_coefficient < STEADY_CV_THRESHOLD: return "steady" - if cv < LOOSE_CV_THRESHOLD: + if bpm_variation_coefficient < LOOSE_CV_THRESHOLD: return "loose" return "variable" def _summarize_run( - run: list[tuple[int, float, float, float]], - beats: NDArray[np.float64], + tempo_change_run: list[tuple[int, float, float, float]], + beat_times_array: NDArray[np.float64], ) -> TempoChange: """Collapse a run of adjacent flagged boundaries into one tempo change. @@ -121,26 +124,31 @@ def _summarize_run( chosen so the reported time sits at the center of the transition. Args: - run: Adjacent flagged boundaries as (index, deviation, before - median BPM, after median BPM) tuples, in ascending index order. - beats: Beat times in seconds, aligned with boundary indices. + tempo_change_run: Adjacent flagged boundaries as (index, deviation, + before median BPM, after median BPM) tuples, in ascending index + order. + beat_times_array: Beat times in seconds, aligned with boundary indices. Returns: The merged TempoChange with rounded outputs. """ - max_deviation = max(entry[1] for entry in run) - ties = [entry for entry in run if entry[1] >= max_deviation - 1e-9] - index, _, before_bpm, after_bpm = ties[len(ties) // 2] + max_deviation = max(flagged_boundary[1] for flagged_boundary in tempo_change_run) + tied_boundaries = [ + flagged_boundary + for flagged_boundary in tempo_change_run + if flagged_boundary[1] >= max_deviation - 1e-9 + ] + boundary_index, _, before_bpm, after_bpm = tied_boundaries[len(tied_boundaries) // 2] return { - "time": round(float(beats[index]), 3), + "time": round(float(beat_times_array[boundary_index]), 3), "from_bpm": round(before_bpm, 1), "to_bpm": round(after_bpm, 1), } def _detect_tempo_changes( - bpms: NDArray[np.float64], - beats: NDArray[np.float64], + local_bpm_values: NDArray[np.float64], + beat_times_array: NDArray[np.float64], ) -> list[TempoChange]: """Detect sustained tempo shifts in a local BPM series. @@ -150,37 +158,65 @@ def _detect_tempo_changes( merged into a single change. Args: - bpms: Per-beat local BPM series (one value per inter-beat - interval); ``bpms[i]`` spans beats ``i`` to ``i + 1``. - beats: Beat times in seconds; ``len(beats) == len(bpms) + 1``. + local_bpm_values: Per-beat local BPM series (one value per inter-beat + interval); each value spans one consecutive beat interval. + beat_times_array: Beat times in seconds; there is one more beat time + than local BPM value. Returns: Detected tempo changes in chronological order; empty if the track is too short for a robust window comparison. """ - n_bpm = len(bpms) - window = min(CHANGE_WINDOW_BEATS, n_bpm // 2) - if window < MIN_CHANGE_WINDOW_BEATS: + bpm_value_count = len(local_bpm_values) + comparison_window_beats = min(CHANGE_WINDOW_BEATS, bpm_value_count // 2) + if comparison_window_beats < MIN_CHANGE_WINDOW_BEATS: return [] - flagged: list[tuple[int, float, float, float]] = [] - for k in range(window, n_bpm - window + 1): - before = float(np.median(bpms[k - window : k])) - after = float(np.median(bpms[k : k + window])) - deviation = abs(after - before) / before - if deviation > CHANGE_RATIO_THRESHOLD: - flagged.append((k, deviation, before, after)) - - changes: list[TempoChange] = [] - run: list[tuple[int, float, float, float]] = [] - for entry in flagged: - if run and entry[0] != run[-1][0] + 1: - changes.append(_summarize_run(run, beats)) - run = [] - run.append(entry) - if run: - changes.append(_summarize_run(run, beats)) - return changes + flagged_boundaries: list[tuple[int, float, float, float]] = [] + for boundary_index in range( + comparison_window_beats, + bpm_value_count - comparison_window_beats + 1, + ): + before_bpm_median = float( + np.median( + local_bpm_values[ + boundary_index - comparison_window_beats : boundary_index + ] + ) + ) + after_bpm_median = float( + np.median( + local_bpm_values[ + boundary_index : boundary_index + comparison_window_beats + ] + ) + ) + relative_bpm_deviation = ( + abs(after_bpm_median - before_bpm_median) / before_bpm_median + ) + if relative_bpm_deviation > CHANGE_RATIO_THRESHOLD: + flagged_boundaries.append( + ( + boundary_index, + relative_bpm_deviation, + before_bpm_median, + after_bpm_median, + ) + ) + + tempo_changes: list[TempoChange] = [] + tempo_change_run: list[tuple[int, float, float, float]] = [] + for flagged_boundary in flagged_boundaries: + if ( + tempo_change_run + and flagged_boundary[0] != tempo_change_run[-1][0] + 1 + ): + tempo_changes.append(_summarize_run(tempo_change_run, beat_times_array)) + tempo_change_run = [] + tempo_change_run.append(flagged_boundary) + if tempo_change_run: + tempo_changes.append(_summarize_run(tempo_change_run, beat_times_array)) + return tempo_changes def analyze_tempo_stability( @@ -203,28 +239,28 @@ def analyze_tempo_stability( beats, non-finite values, or non-increasing times) yields the safe default instead of raising. """ - beats: NDArray[np.float64] = np.asarray(beat_times, dtype=np.float64) - if beats.ndim != 1 or len(beats) < MIN_BEATS: + beat_times_array: NDArray[np.float64] = np.asarray(beat_times, dtype=np.float64) + if beat_times_array.ndim != 1 or len(beat_times_array) < MIN_BEATS: return _safe_default() - if not np.all(np.isfinite(beats)): + if not np.all(np.isfinite(beat_times_array)): return _safe_default() - intervals = np.diff(beats) - if not np.all(intervals > 0.0): + beat_intervals = np.diff(beat_times_array) + if not np.all(beat_intervals > 0.0): return _safe_default() with np.errstate(divide="ignore", over="ignore"): - bpms: NDArray[np.float64] = 60.0 / intervals - if not np.all(np.isfinite(bpms)): + local_bpm_values: NDArray[np.float64] = 60.0 / beat_intervals + if not np.all(np.isfinite(local_bpm_values)): return _safe_default() - bpm_median = float(np.median(bpms)) - bpm_stdev = float(np.std(bpms)) - cv = bpm_stdev / bpm_median + bpm_median = float(np.median(local_bpm_values)) + bpm_stdev = float(np.std(local_bpm_values)) + bpm_variation_coefficient = bpm_stdev / bpm_median return { "bpm_median": round(bpm_median, 2), "bpm_stdev": round(bpm_stdev, 2), - "stability": _classify_stability(cv), - "tempo_changes": _detect_tempo_changes(bpms, beats), + "stability": _classify_stability(bpm_variation_coefficient), + "tempo_changes": _detect_tempo_changes(local_bpm_values, beat_times_array), } From 3c6bb2d0a34b9f41b0f4986b6bd9ddf7bc99e1b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:50:29 +0900 Subject: [PATCH 03/15] test(temporal): keep naming guard semantic --- .../tests/test_temporal_naming_contract.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/tests/test_temporal_naming_contract.py b/services/analysis-engine/tests/test_temporal_naming_contract.py index 053eab323..919e01665 100644 --- a/services/analysis-engine/tests/test_temporal_naming_contract.py +++ b/services/analysis-engine/tests/test_temporal_naming_contract.py @@ -55,19 +55,19 @@ def _module_identifiers(source_text: str) -> set[str]: """Return organization-owned Python identifiers declared or referenced by the module.""" syntax_tree = ast.parse(source_text) - identifiers = { + module_identifiers = { node.id for node in ast.walk(syntax_tree) if isinstance(node, ast.Name) } - identifiers.update( + module_identifiers.update( node.arg for node in ast.walk(syntax_tree) if isinstance(node, ast.arg) ) - return identifiers + return module_identifiers def test_temporal_stability_internal_identifiers_are_semantically_specific() -> None: """Require bounded-context names instead of generic one-word temporal identifiers.""" source_text = TEMPORAL_STABILITY_MODULE.read_text(encoding="utf-8") - identifiers = _module_identifiers(source_text) + module_identifiers = _module_identifiers(source_text) - assert not LEGACY_UNDERSPECIFIED_IDENTIFIERS.intersection(identifiers) - assert REQUIRED_SEMANTIC_IDENTIFIERS.issubset(identifiers) + assert not LEGACY_UNDERSPECIFIED_IDENTIFIERS.intersection(module_identifiers) + assert REQUIRED_SEMANTIC_IDENTIFIERS.issubset(module_identifiers) From 4e0b2b5133e673f670e4d685500d2e1fa1afd544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:58:15 +0900 Subject: [PATCH 04/15] test(score): require semantic PDF byte prop --- .../src/features/score/ScoreViewer.test.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx index 3ac2dd605..6f06ea55a 100644 --- a/apps/desktop/src/features/score/ScoreViewer.test.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -86,9 +86,9 @@ describe("ScoreViewer", () => { vi.unstubAllGlobals(); }); - it("renders the empty placeholder without loading when no data is attached", () => { + it("renders the empty placeholder without loading when no score PDF bytes are attached", () => { const onStatusChange = vi.fn(); - render(); + render(); expect( screen.getByText("No score PDF attached. Attach a validated score PDF to view it here.") @@ -103,7 +103,7 @@ describe("ScoreViewer", () => { const { doc, page } = createFakeDocument(3); const onStatusChange = vi.fn(); - render(); + render(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading score PDF...")).toBeInTheDocument(); @@ -128,7 +128,7 @@ describe("ScoreViewer", () => { const { doc } = createFakeDocument(1); mockLoadTaskOnce(Promise.resolve(doc)); - render(); + render(); expect(await screen.findByText("setlist-opener.pdf")).toBeInTheDocument(); }); @@ -139,7 +139,7 @@ describe("ScoreViewer", () => { mockLoadTaskOnce(Promise.resolve(doc)); const onStatusChange = vi.fn(); - render(); + render(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(screen.getByText("Could not display the score")).toBeInTheDocument(); @@ -159,7 +159,7 @@ describe("ScoreViewer", () => { it("stringifies non-Error load failures", async () => { mockLoadTaskOnce(Promise.reject("password protected")); - render(); + render(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(screen.getByText("password protected")).toBeInTheDocument(); @@ -169,7 +169,7 @@ describe("ScoreViewer", () => { const { doc } = createFakeDocument(3); mockLoadTaskOnce(Promise.resolve(doc)); - render(); + render(); expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); const previousButton = screen.getByRole("button", { name: "Previous page" }); @@ -198,7 +198,7 @@ describe("ScoreViewer", () => { const { doc, page } = createFakeDocument(1); mockLoadTaskOnce(Promise.resolve(doc)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); const zoomInButton = screen.getByRole("button", { name: "Zoom in" }); @@ -245,7 +245,7 @@ describe("ScoreViewer", () => { const { doc, page } = createFakeDocument(1); mockLoadTaskOnce(Promise.resolve(doc)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); @@ -278,7 +278,7 @@ describe("ScoreViewer", () => { const { doc } = createFakeDocument(1, page); mockLoadTaskOnce(Promise.resolve(doc)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); await waitFor(() => { @@ -294,7 +294,7 @@ describe("ScoreViewer", () => { } as unknown as PDFDocumentProxy; mockLoadTaskOnce(Promise.resolve(doc)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); await waitFor(() => { @@ -311,7 +311,7 @@ describe("ScoreViewer", () => { const onStatusChange = vi.fn(); const { unmount } = render( - + ); unmount(); @@ -330,7 +330,7 @@ describe("ScoreViewer", () => { const onStatusChange = vi.fn(); const { unmount } = render( - + ); unmount(); From 492346e2183a6bc0714377acf9a58fdab32169c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:59:15 +0900 Subject: [PATCH 05/15] refactor(score): use semantic viewer identifiers --- .../src/features/score/ScoreViewer.tsx | 104 +++++++++--------- 1 file changed, 55 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx index 82692469e..fa34ef066 100644 --- a/apps/desktop/src/features/score/ScoreViewer.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -27,7 +27,7 @@ export interface ScoreViewerProps { * loads arbitrary URLs; callers (PR3 wires Tauri `read_score_pdf`) must * hand it bytes they already validated. */ - data: Uint8Array | null; + scorePdfBytes: Uint8Array | null; /** Optional display name of the attached score file. */ fileName?: string; /** Optional observer notified on every LOADING/FAILED/READY transition. */ @@ -45,14 +45,14 @@ const MAX_ZOOM = 4; * error with retry, READY canvas) plus rehearsal-friendly page navigation * and zoom in/out/fit-width controls. */ -export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const [status, setStatus] = useState("LOADING"); +export function ScoreViewer({ scorePdfBytes, fileName, onStatusChange }: ScoreViewerProps) { + const scoreTranslator = useMemo(() => createTranslator(detectPreferredLocale()), []); + const [viewerStatus, setViewerStatus] = useState("LOADING"); const [errorMessage, setErrorMessage] = useState(null); const [pdfDocument, setPdfDocument] = useState(null); const [pageNumber, setPageNumber] = useState(1); const [pageCount, setPageCount] = useState(0); - const [zoom, setZoom] = useState(1); + const [zoomScale, setZoomScale] = useState(1); const [fitWidth, setFitWidth] = useState(true); const [containerWidth, setContainerWidth] = useState(0); const [retryToken, setRetryToken] = useState(0); @@ -60,22 +60,22 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps const containerRef = useRef(null); useEffect(() => { - if (data !== null) { - onStatusChange?.(status); + if (scorePdfBytes !== null) { + onStatusChange?.(viewerStatus); } - }, [data, status, onStatusChange]); + }, [scorePdfBytes, viewerStatus, onStatusChange]); useEffect(() => { - if (data === null) { + if (scorePdfBytes === null) { return; } let cancelled = false; - setStatus("LOADING"); + setViewerStatus("LOADING"); setErrorMessage(null); setPdfDocument(null); - const loadingTask = loadScorePdf(data); + const loadingTask = loadScorePdf(scorePdfBytes); loadingTask.promise .then((loadedDocument) => { if (cancelled) { @@ -84,40 +84,44 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps setPdfDocument(loadedDocument); setPageCount(loadedDocument.numPages); setPageNumber(1); - setStatus("READY"); + setViewerStatus("READY"); }) .catch((error: unknown) => { if (cancelled) { return; } setErrorMessage(error instanceof Error ? error.message : String(error)); - setStatus("FAILED"); + setViewerStatus("FAILED"); }); return () => { cancelled = true; void loadingTask.destroy().catch(() => undefined); }; - }, [data, retryToken]); + }, [scorePdfBytes, retryToken]); useEffect(() => { - const container = containerRef.current; - if (status !== "READY" || !container || typeof ResizeObserver === "undefined") { + const viewerContainer = containerRef.current; + if ( + viewerStatus !== "READY" || + !viewerContainer || + typeof ResizeObserver === "undefined" + ) { return; } - const observer = new ResizeObserver((entries) => { + const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { setContainerWidth(entry.contentRect.width); } }); - observer.observe(container); - return () => observer.disconnect(); - }, [status]); + resizeObserver.observe(viewerContainer); + return () => resizeObserver.disconnect(); + }, [viewerStatus]); useEffect(() => { - const canvas = canvasRef.current; - if (status !== "READY" || !pdfDocument || !canvas) { + const scoreCanvas = canvasRef.current; + if (viewerStatus !== "READY" || !pdfDocument || !scoreCanvas) { return; } @@ -126,17 +130,17 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps pdfDocument .getPage(pageNumber) - .then((page) => { + .then((pdfPage) => { if (cancelled) { return; } - const baseViewport = page.getViewport({ scale: 1 }); - const scale = - fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoom; - const viewport = page.getViewport({ scale }); - canvas.width = Math.floor(viewport.width); - canvas.height = Math.floor(viewport.height); - renderTask = page.render({ canvas, viewport }); + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const viewportScale = + fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoomScale; + const pageViewport = pdfPage.getViewport({ scale: viewportScale }); + scoreCanvas.width = Math.floor(pageViewport.width); + scoreCanvas.height = Math.floor(pageViewport.height); + renderTask = pdfPage.render({ canvas: scoreCanvas, viewport: pageViewport }); renderTask.promise.catch(() => { // Cancelled renders (rapid page/zoom changes) are expected. }); @@ -149,7 +153,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps cancelled = true; renderTask?.cancel(); }; - }, [status, pdfDocument, pageNumber, zoom, fitWidth, containerWidth]); + }, [viewerStatus, pdfDocument, pageNumber, zoomScale, fitWidth, containerWidth]); /** Move to the previous page, clamped at the first page. */ const goToPreviousPage = () => { @@ -164,13 +168,13 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps /** Switch to manual zoom and enlarge, clamped at the maximum scale. */ const zoomIn = () => { setFitWidth(false); - setZoom((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP)); + setZoomScale((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP)); }; /** Switch to manual zoom and shrink, clamped at the minimum scale. */ const zoomOut = () => { setFitWidth(false); - setZoom((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP)); + setZoomScale((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP)); }; /** Re-enable fit-width so the page tracks the container size. */ @@ -179,24 +183,24 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps }; /** Re-run the load state machine with the same validated bytes. */ - const retry = () => { + const retryPdfLoad = () => { setRetryToken((current) => current + 1); }; - if (data === null) { + if (scorePdfBytes === null) { return (
-

{t("scoreViewerEmpty")}

+

{scoreTranslator("scoreViewerEmpty")}

); } - if (status === "LOADING") { + if (viewerStatus === "LOADING") { return ( ); } - if (status === "FAILED") { + if (viewerStatus === "FAILED") { return ( ); } - const pageIndicator = t("scoreViewerPageIndicator") + const pageIndicator = scoreTranslator("scoreViewerPageIndicator") .replace("{current}", String(pageNumber)) .replace("{total}", String(pageCount)); @@ -257,7 +263,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps variant="outline" size="icon-lg" className="size-12" - aria-label={t("scoreViewerZoomOut")} + aria-label={scoreTranslator("scoreViewerZoomOut")} onClick={zoomOut} >