From 29c6e31a2ed8b3f6eb86be440b85b96aed7a5f66 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:04:29 +0000 Subject: [PATCH 1/2] perf: vectorize SSM novelty curve computation in segmenter --- .jules/bolt.md | 4 + .../bandscope_analysis/sections/segmenter.py | 13 +- .../sections/segmenter.py.orig | 414 ++++++++++++++++++ 3 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 services/analysis-engine/src/bandscope_analysis/sections/segmenter.py.orig diff --git a/.jules/bolt.md b/.jules/bolt.md index 38d4b732..6c10f968 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -41,3 +41,7 @@ ## 2025-02-15 - Replace Array.from(map.values()).map with a for...of loop **Learning:** Using `Array.from(map.values()).map(...)` creates an unnecessary intermediate array which wastes memory allocation and garbage collection time, particularly for frequently re-rendered components handling large collections. **Action:** Use a `for...of` loop over `map.values()` to iterate and push mapped elements directly into the final array for O(1) memory and avoiding intermediate array allocations. + +## 2025-02-15 - Vectorize diagonal windowed operations +**Learning:** Iterating through a large NumPy matrix along the diagonal using a Python `for` loop with array slicing and summation causes high constant time overhead due to repeated inner-loop allocations. +**Action:** When performing windowed operations strictly along the diagonal of large matrices, use sub-matrix diagonal vectorization (`numpy.lib.stride_tricks.sliding_window_view` coupled with `np.diagonal` and `np.einsum`) to delegate iteration and computation to optimized C-level routines. diff --git a/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py b/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py index cdb8d395..4d388de8 100644 --- a/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py +++ b/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py @@ -18,6 +18,7 @@ import librosa import numpy as np +from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import NDArray from .model import ALL_SECTION_LABELS, CueAnchorStrategy, SectionCandidate @@ -109,11 +110,13 @@ def _checkerboard_novelty( kernel[:half, :half] = -1.0 kernel[half:, half:] = -1.0 - # Extract all valid diagonal patches and compute dot products. - valid_range = range(half, n - half) - for i in valid_range: - patch = ssm[i - half : i + half, i - half : i + half] - novelty[i] = np.sum(patch * kernel) + # Vectorized computation along the diagonal using sliding_window_view and einsum + valid_len = n - 2 * half + if valid_len > 0: + views = sliding_window_view(ssm, (kernel_size, kernel_size)) + diag_views = np.diagonal(views, axis1=0, axis2=1) + res = np.einsum("ijk,ij->k", diag_views, kernel) + novelty[half : half + valid_len] = res[:valid_len] # Normalize by peak absolute magnitude, preserving sign. max_val = np.max(np.abs(novelty)) diff --git a/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py.orig b/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py.orig new file mode 100644 index 00000000..cdb8d395 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/sections/segmenter.py.orig @@ -0,0 +1,414 @@ +"""Structural segmentation via self-similarity matrix (SSM) novelty curve. + +Detects structural boundaries (Intro, Verse, Chorus, Bridge, etc.) from audio +features using a checkerboard kernel convolution over the SSM diagonal. + +Security Notes: +- Processes untrusted audio features (numpy arrays from stems). +- No shell execution, network access, or user-controlled output paths. +- All numpy operations are bounded by array sizes; no unbounded allocations. +- Fails safely with an empty segment list when features are insufficient. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any, Literal + +import librosa +import numpy as np +from numpy.typing import NDArray + +from .model import ALL_SECTION_LABELS, CueAnchorStrategy, SectionCandidate + +logger = logging.getLogger(__name__) + +# Minimum segment duration in seconds to avoid micro-segments. +MIN_SEGMENT_DURATION_SECONDS = 4.0 + +# Maximum number of segments to return (avoids over-segmentation). +MAX_SEGMENTS = 20 + +# Maximum frame count for dense SSM construction. +MAX_SSM_FRAMES = 4096 + +# Canonical section label assignment order for repeating patterns. +_LABEL_ORDER: tuple[str, ...] = ( + "intro", + "verse", + "chorus", + "verse", + "chorus", + "bridge", + "chorus", + "outro", +) + + +def compute_novelty_curve( + audio: NDArray[np.floating[Any]], + sr: int, + hop_length: int = 512, +) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]: + """Compute a novelty curve from the self-similarity matrix of chroma features. + + Args: + audio: Mono audio signal as a 1D float array. + sr: Sample rate. + hop_length: Hop length for feature extraction. + + Returns: + Tuple of (novelty_curve, frame_times). + """ + effective_hop_length = max(hop_length, math.ceil(audio.size / MAX_SSM_FRAMES)) + + # Extract chroma features for structural comparison + chroma = librosa.feature.chroma_cqt(y=audio, sr=sr, hop_length=effective_hop_length) + + # Build self-similarity matrix from chroma + ssm = librosa.segment.recurrence_matrix( + chroma, + mode="affinity", + metric="cosine", + sparse=False, + self=True, + ) + + # Compute novelty curve via checkerboard kernel along the diagonal + novelty = _checkerboard_novelty(ssm) + + # Frame times for each novelty value + frame_times = librosa.frames_to_time( + np.arange(len(novelty)), sr=sr, hop_length=effective_hop_length + ) + + return novelty, frame_times + + +def _checkerboard_novelty( + ssm: NDArray[np.floating[Any]], + kernel_size: int = 64, +) -> NDArray[np.floating[Any]]: + """Apply a checkerboard kernel along the SSM diagonal to detect boundaries. + + The checkerboard kernel highlights transitions where the local structure + changes (i.e., moving from one repeated section to a new one). + + Iterates over valid diagonal patches while keeping the SSM frame count bounded. + """ + n = ssm.shape[0] + half = kernel_size // 2 + novelty = np.zeros(n, dtype=np.float64) + + if n < kernel_size: + return novelty + + # Build checkerboard kernel + kernel = np.ones((kernel_size, kernel_size), dtype=np.float64) + kernel[:half, :half] = -1.0 + kernel[half:, half:] = -1.0 + + # Extract all valid diagonal patches and compute dot products. + valid_range = range(half, n - half) + for i in valid_range: + patch = ssm[i - half : i + half, i - half : i + half] + novelty[i] = np.sum(patch * kernel) + + # Normalize by peak absolute magnitude, preserving sign. + max_val = np.max(np.abs(novelty)) + if max_val > 0: + novelty = novelty / max_val + + return novelty + + +def detect_boundaries( + novelty: NDArray[np.floating[Any]], + frame_times: NDArray[np.floating[Any]], + duration: float, + min_segment_seconds: float = MIN_SEGMENT_DURATION_SECONDS, +) -> list[float]: + """Detect segment boundary times from novelty curve peaks. + + Args: + novelty: The novelty curve values. + frame_times: Time in seconds for each novelty frame. + duration: Total audio duration in seconds. + min_segment_seconds: Minimum distance between boundaries. + + Returns: + Sorted list of boundary times (always starts with 0.0). + """ + if len(novelty) < 3: + return [0.0] + + # Find peaks in novelty curve using adaptive threshold + threshold = float(np.mean(novelty) + 0.5 * np.std(novelty)) + threshold = max(threshold, 0.1) + + # Simple peak detection: local maxima above threshold + peaks: list[int] = [] + for i, value in enumerate(novelty): + left = novelty[i - 1] if i > 0 else float("-inf") + right = novelty[i + 1] if i + 1 < len(novelty) else float("-inf") + if value > threshold and value > left and value > right: + peaks.append(i) + + # Convert peak frames to times + boundary_times: list[float] = [0.0] + for peak_idx in peaks: + if peak_idx < len(frame_times): + t = float(frame_times[peak_idx]) + # Enforce minimum segment duration + if t - boundary_times[-1] >= min_segment_seconds and t < duration - 1.0: + boundary_times.append(t) + + # Limit total segments + if len(boundary_times) > MAX_SEGMENTS: + boundary_times = boundary_times[:MAX_SEGMENTS] + + return boundary_times + + +def assign_section_labels( + boundaries: list[float], + duration: float, +) -> list[tuple[str, int]]: + """Assign canonical section labels to detected segments. + + Uses structural position heuristics: + - First short segment -> intro + - Last segment -> outro + - Repeating patterns -> verse/chorus alternation + + Args: + boundaries: Sorted boundary start times. + duration: Total audio duration. + + Returns: + List of (label, sequence_index) tuples, one per segment. + """ + n_segments = len(boundaries) + if n_segments == 0: + return [] + + labels: list[tuple[str, int]] = [] + label_counts: dict[str, int] = {} + + for i in range(n_segments): + start = boundaries[i] + end = boundaries[i + 1] if i + 1 < n_segments else duration + + segment_duration = end - start + relative_position = start / max(duration, 1.0) + + # Heuristic label assignment + if i == 0 and segment_duration < duration * 0.15: + label = "intro" + elif i == n_segments - 1 and relative_position > 0.85: + label = "outro" + elif i < len(_LABEL_ORDER): + label = _LABEL_ORDER[i] + else: + # Cycle through verse/chorus for remaining segments + cycle_idx = (i - 1) % 2 + label = "verse" if cycle_idx == 0 else "chorus" + + label_counts[label] = label_counts.get(label, 0) + 1 + labels.append((label, label_counts[label])) + + return labels + + +def segment_audio( + audio: NDArray[np.floating[Any]], + sr: int, + duration: float | None = None, +) -> list[SectionCandidate]: + """Run full structural segmentation pipeline on audio. + + Args: + audio: Mono audio signal. + sr: Sample rate. + duration: Optional pre-computed duration. Calculated if not provided. + + Returns: + List of SectionCandidate dicts with detected boundaries and labels. + """ + if audio.size == 0: + return [] + + if duration is None: + duration = float(audio.size) / sr + + if duration < MIN_SEGMENT_DURATION_SECONDS * 2: + return _single_section_fallback("Audio too short for structural analysis") + + try: + boundaries = _compute_boundaries(audio, sr, duration) + except Exception as e: + logger.warning("Structural segmentation failed, falling back to single section: %s", e) + return _single_section_fallback(f"Segmentation fallback: {e}") + + return _sections_from_boundaries(boundaries, duration) + + +def segment_boundaries_from_audio( + audio: NDArray[np.floating[Any]], + sr: int, + duration: float | None = None, +) -> list[tuple[float, float]]: + """Return raw (start, end) boundary pairs from audio segmentation. + + Useful for downstream role activity detection which needs time ranges. + + Args: + audio: Mono audio signal. + sr: Sample rate. + duration: Optional pre-computed duration. + + Returns: + List of (start_seconds, end_seconds) tuples for each segment. + """ + if audio.size == 0: + return [] + + if duration is None: + duration = float(audio.size) / sr + + if duration < MIN_SEGMENT_DURATION_SECONDS * 2: + return [(0.0, duration)] + + try: + boundaries = _compute_boundaries(audio, sr, duration) + except Exception as e: + logger.warning("Boundary detection failed: %s", e) + return [(0.0, duration)] + + return _boundary_pairs_from_boundaries(boundaries, duration) + + +def segment_with_boundaries( + audio: NDArray[np.floating[Any]], + sr: int, + duration: float | None = None, +) -> tuple[list[SectionCandidate], list[tuple[float, float]]]: + """Run segmentation and return both section candidates and boundary pairs. + + This preserves one helper call-site for downstream code that needs both + section candidates and boundary pairs. + + Args: + audio: Mono audio signal. + sr: Sample rate. + duration: Optional pre-computed duration. + + Returns: + Tuple of (section_candidates, boundary_pairs). + """ + if audio.size == 0: + return [], [] + + if duration is None: + duration = float(audio.size) / sr + + if duration < MIN_SEGMENT_DURATION_SECONDS * 2: + return _single_section_fallback("Audio too short for structural analysis"), [ + (0.0, duration) + ] + + try: + boundaries = _compute_boundaries(audio, sr, duration) + except Exception as e: + logger.warning("Structural segmentation failed, falling back to single section: %s", e) + return _single_section_fallback(f"Segmentation fallback: {e}"), [(0.0, duration)] + + return _sections_from_boundaries(boundaries, duration), _boundary_pairs_from_boundaries( + boundaries, duration + ) + + +def _single_section_fallback(confidence_notes: str) -> list[SectionCandidate]: + """Build a single low-confidence verse section for segmentation fallback.""" + return [ + { + "id": "verse-1", + "form_label": "verse", + "sequence_index": 1, + "groove": "standard", + "confidence_level": "low", + "confidence_source": "model", + "confidence_notes": confidence_notes, + "cue_anchor": { + "strategy": CueAnchorStrategy.COUNT.value, + "value": "Enter on beat 1 of bar 1", + }, + } + ] + + +def _sections_from_boundaries(boundaries: list[float], duration: float) -> list[SectionCandidate]: + """Build section candidates from precomputed boundary start times.""" + labels = assign_section_labels(boundaries, duration) + sections: list[SectionCandidate] = [] + n_boundaries = len(boundaries) + + for i, (label, seq_idx) in enumerate(labels): + start_time = boundaries[i] + end_time = boundaries[i + 1] if i + 1 < n_boundaries else duration + + confidence_level: Literal["low", "medium", "high"] = ( + "high" if label in ALL_SECTION_LABELS else "low" + ) + + sections.append( + { + "id": f"{label}-{seq_idx}", + "form_label": label, + "sequence_index": seq_idx, + "groove": "standard", + "confidence_level": confidence_level, + "confidence_source": "model", + "confidence_notes": ( + f"Detected via SSM novelty at {start_time:.1f}s-{end_time:.1f}s" + ), + "cue_anchor": { + "strategy": CueAnchorStrategy.COUNT.value, + "value": "Enter on beat 1 of bar 1", + }, + } + ) + + return sections + + +def _boundary_pairs_from_boundaries( + boundaries: list[float], duration: float +) -> list[tuple[float, float]]: + """Build `(start, end)` boundary pairs from precomputed boundary start times.""" + pairs: list[tuple[float, float]] = [] + for i in range(len(boundaries)): + start = boundaries[i] + end = boundaries[i + 1] if i + 1 < len(boundaries) else duration + pairs.append((start, end)) + return pairs + + +def _compute_boundaries( + audio: NDArray[np.floating[Any]], + sr: int, + duration: float, +) -> list[float]: + """Compute raw boundary times from audio (shared implementation). + + Args: + audio: Mono audio signal. + sr: Sample rate. + duration: Total audio duration. + + Returns: + Sorted list of boundary start times. + """ + novelty, frame_times = compute_novelty_curve(audio, sr) + return detect_boundaries(novelty, frame_times, duration) From 02d3710e562171c89bd4dc35fe7acc74ca19fa55 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:30:47 +0000 Subject: [PATCH 2/2] perf: vectorize SSM novelty curve computation in segmenter --- .github/workflows/build-baseline.yml | 32 - .jules/sentinel.md | 6 - CHANGELOG.md | 8 - CLAUDE.md | 75 - README.md | 2 +- apps/desktop/.gitignore | 1 - apps/desktop/.storybook/main.ts | 23 - apps/desktop/.storybook/preview.ts | 14 - apps/desktop/package.json | 5 - apps/desktop/src-tauri/src/main.rs | 9 - apps/desktop/src/App.tsx | 3 - apps/desktop/src/components/ui/accordion.tsx | 76 - apps/desktop/src/components/ui/breadcrumb.tsx | 107 - .../src/components/ui/button.stories.tsx | 22 - .../src/components/ui/checkbox.stories.tsx | 17 - apps/desktop/src/components/ui/checkbox.tsx | 39 - .../src/components/ui/dialog.stories.tsx | 39 - apps/desktop/src/components/ui/dialog.tsx | 120 - .../desktop/src/components/ui/in-page-nav.tsx | 67 - apps/desktop/src/components/ui/label.tsx | 21 - .../desktop/src/components/ui/radio-group.tsx | 50 - apps/desktop/src/components/ui/select.tsx | 128 - apps/desktop/src/components/ui/sonner.tsx | 33 - .../src/components/ui/step-indicator.tsx | 113 - apps/desktop/src/components/ui/switch.tsx | 34 - apps/desktop/src/components/ui/table.tsx | 122 - apps/desktop/src/components/ui/tooltip.tsx | 53 - .../src/components/ui/ui-added.test.tsx | 248 -- .../src/features/chords/index.test.tsx | 77 - apps/desktop/src/features/chords/index.tsx | 10 +- .../src/features/ranges/index.test.tsx | 88 - apps/desktop/src/features/ranges/index.tsx | 5 - apps/desktop/src/setupTests.ts | 17 +- eslint.config.js | 2 +- package-lock.json | 3182 ++--------------- .../sections/segmenter.py.orig | 414 --- 36 files changed, 262 insertions(+), 5000 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 apps/desktop/.gitignore delete mode 100644 apps/desktop/.storybook/main.ts delete mode 100644 apps/desktop/.storybook/preview.ts delete mode 100644 apps/desktop/src/components/ui/accordion.tsx delete mode 100644 apps/desktop/src/components/ui/breadcrumb.tsx delete mode 100644 apps/desktop/src/components/ui/button.stories.tsx delete mode 100644 apps/desktop/src/components/ui/checkbox.stories.tsx delete mode 100644 apps/desktop/src/components/ui/checkbox.tsx delete mode 100644 apps/desktop/src/components/ui/dialog.stories.tsx delete mode 100644 apps/desktop/src/components/ui/dialog.tsx delete mode 100644 apps/desktop/src/components/ui/in-page-nav.tsx delete mode 100644 apps/desktop/src/components/ui/label.tsx delete mode 100644 apps/desktop/src/components/ui/radio-group.tsx delete mode 100644 apps/desktop/src/components/ui/select.tsx delete mode 100644 apps/desktop/src/components/ui/sonner.tsx delete mode 100644 apps/desktop/src/components/ui/step-indicator.tsx delete mode 100644 apps/desktop/src/components/ui/switch.tsx delete mode 100644 apps/desktop/src/components/ui/table.tsx delete mode 100644 apps/desktop/src/components/ui/tooltip.tsx delete mode 100644 apps/desktop/src/components/ui/ui-added.test.tsx delete mode 100644 apps/desktop/src/features/chords/index.test.tsx delete mode 100644 apps/desktop/src/features/ranges/index.test.tsx delete mode 100644 services/analysis-engine/src/bandscope_analysis/sections/segmenter.py.orig diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 6454e8b4..600cfe47 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -94,18 +94,10 @@ jobs: - name: Package Windows amd64 artifact run: python scripts/release/package_desktop_artifact.py - name: Upload Windows amd64 artifact - id: upload-windows-amd64 - continue-on-error: ${{ github.event_name == 'pull_request' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bandscope-windows-amd64-${{ github.sha }} path: artifacts/* - - name: Explain non-blocking Windows amd64 artifact upload failure - if: ${{ steps.upload-windows-amd64.outcome == 'failure' }} - run: | - echo "Artifact upload failed after the Windows amd64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" build-windows-arm64: name: build / windows / arm64 @@ -181,18 +173,10 @@ jobs: - name: Package Windows arm64 artifact run: python scripts/release/package_desktop_artifact.py - name: Upload Windows arm64 artifact - id: upload-windows-arm64 - continue-on-error: ${{ github.event_name == 'pull_request' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bandscope-windows-arm64-${{ github.sha }} path: artifacts/* - - name: Explain non-blocking Windows arm64 artifact upload failure - if: ${{ steps.upload-windows-arm64.outcome == 'failure' }} - run: | - echo "Artifact upload failed after the Windows arm64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" gate-windows: name: gate / build / windows @@ -247,18 +231,10 @@ jobs: - name: Package macOS amd64 artifact run: python3 scripts/release/package_desktop_artifact.py - name: Upload macOS amd64 artifact - id: upload-macos-amd64 - continue-on-error: ${{ github.event_name == 'pull_request' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bandscope-macos-amd64-${{ github.sha }} path: artifacts/* - - name: Explain non-blocking macOS amd64 artifact upload failure - if: ${{ steps.upload-macos-amd64.outcome == 'failure' }} - run: | - echo "Artifact upload failed after the macOS amd64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" build-macos-arm64: name: build / macos / arm64 @@ -303,18 +279,10 @@ jobs: - name: Package macOS arm64 artifact run: python3 scripts/release/package_desktop_artifact.py - name: Upload macOS arm64 artifact - id: upload-macos-arm64 - continue-on-error: ${{ github.event_name == 'pull_request' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bandscope-macos-arm64-${{ github.sha }} path: artifacts/* - - name: Explain non-blocking macOS arm64 artifact upload failure - if: ${{ steps.upload-macos-arm64.outcome == 'failure' }} - run: | - echo "Artifact upload failed after the macOS arm64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" gate-macos: name: gate / build / macos diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6a57e902..e88c357a 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -17,9 +17,3 @@ **Vulnerability:** Any project identifier that can reach a filesystem path join must be treated as untrusted, even when it is generated internally or passed through IPC lookup flows. **Learning:** Reject only dangerous path segments (`.` and `..`) and path separators (`/` and `\`) so the guard blocks traversal without rejecting ordinary identifiers such as `my..id`. **Prevention:** Keep project ID validation centralized before `base_root.join(project_id)`, and cover forward-slash, backslash, parent-component, and benign interior-dot cases in unit tests. - -## 2025-02-09 - Ensure Maximum URL Length Limit on Backend - -**Vulnerability:** The Rust backend (`apps/desktop/src-tauri/src/main.rs`) did not enforce a maximum URL length limit when processing YouTube URLs via `import_youtube_url`. While the frontend enforced `MAX_YOUTUBE_URL_LENGTH = 2000` via the input element, this could be bypassed by an attacker sending requests directly to the Tauri backend API, potentially causing a Denial of Service (DoS) due to unbounded URL parsing and regex matching. -**Learning:** Input validation must occur at the entry point of untrusted data on the backend, even if it is also validated on the frontend. Relying solely on frontend validation for constraints like string length can expose the backend to resource exhaustion vulnerabilities. -**Prevention:** Always enforce constraints like maximum length, format validation, and sanitization at the earliest possible point on the backend, typically at the API boundary, regardless of frontend safeguards. diff --git a/CHANGELOG.md b/CHANGELOG.md index eea69689..3b228b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,11 +58,3 @@ - Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports - Issue #30: Added policy-constrained YouTube import with local fallback - Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 82c2c704..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,75 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Canonical agent guide - -`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. - -Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. - -## Common commands - -Setup (Node >=22.13 <23, Python >=3.12 via `uv`, Rust stable only for the Tauri shell): - -```bash -npm install -uv sync --project services/analysis-engine --group dev -``` - -Primary verification — run before claiming any change complete; CI (`ci / build-and-test`) runs exactly this: - -```bash -./scripts/harness/quickcheck.sh # lint + typecheck + test + build + doc/security/supply-chain gates -BANDSCOPE_ENABLE_RUST_CHECK=1 ./scripts/harness/quickcheck.sh # adds the opt-in Rust/Tauri cargo check lane -``` - -Individual root gates (all part of quickcheck): - -```bash -npm run lint # workspace ESLint + doc/security/supply-chain checks + ruff + ruff format + bandit + docstring gate -npm run typecheck # tsc per workspace + mypy --strict on the Python engine -npm run test # JS workspace vitest suites + pytest with 100% coverage gate -npm run build # vite builds per workspace -``` - -Per-workspace and single-test: - -```bash -npm run test --workspace @bandscope/desktop # desktop suite (vitest + coverage) -npm --workspace @bandscope/desktop exec vitest run src/lib/export.test.ts # one frontend test file -npm run dev --workspace @bandscope/desktop # Vite dev server (browser fallback mode) -npm run storybook --workspace @bandscope/desktop # component workbench - -uv run --project services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate) -uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100 # full Python gate -``` - -## Architecture - -BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. - -Three layers, decoupled through shared contracts: - -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. -- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. -- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. - -Data flow: React UI → Tauri IPC command → Rust validation + Python subprocess over stdin/stdout → job status and progress events emitted back to the UI. - -Supporting packages: - -- `packages/shared-types` — the stable TypeScript contracts (rehearsal domain, analysis jobs, confidence, provenance, export summaries) shared by the UI and the orchestration layer. Both sides must use these types instead of inventing parallel schemas; contracts are property-tested with fast-check. -- `packages/shared-config` — shared tsconfig/ESLint bases. -- `scripts/harness/quickcheck.sh` and `scripts/checks/` — fail-fast mechanical gates, including doc presence, plan `Security Notes`, forbidden patterns, and supply-chain/workflow-pinning verification. - -## Key conventions - -- Coverage is a hard gate: the Python engine requires 100% test coverage and 100% docstring coverage (Ruff `D100`–`D107` across `src`, `tests`, and repo scripts). Exported TypeScript declarations in `packages/shared-types` and `apps/desktop/src` require JSDoc with a description; `no-console` is an error. -- Gitflow: `develop` is the default branch; `feature/*` targets `develop`, `main` is the protected release branch. Direct pushes to protected branches are not allowed, and every merge needs the required checks plus a passing CodeRabbit review (see `CONTRIBUTING.md` and `docs/repository/gitflow.md`). -- The PR template (`.github/PULL_REQUEST_TEMPLATE.md`) requires a quickcheck confirmation, `Security Notes` (attack surface, trust boundary, mitigations, test points), a dependency/supply-chain checklist, and i18n impact. -- i18n: the UI ships Korean and English locales (`apps/desktop/src/locales/ko`, `en`). Any user-visible string change must update both. -- Documents under `docs/plans/` must include `Security Notes`; `scripts/checks/verify_security_notes.py` enforces this mechanically. -- Lockfiles (`package-lock.json`, `uv.lock`, `Cargo.lock`) are committed and must stay in sync; GitHub Actions are SHA-pinned. Adding a direct dependency requires the admission rationale defined in `AGENTS.md` and `docs/security/dependency-policy.md`. -- CI beyond quickcheck: `gate / ci / rust-check` (Tauri cargo check on macOS) and `build-baseline` Windows/macOS amd64+arm64 native builds are merge gates, alongside CodeQL, dependency-review, sbom, bandit, trivy, secret-scan, and security-audit workflows. Do not weaken or skip them. -- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). diff --git a/README.md b/README.md index 74312e3e..6ae16c44 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BandScope -BandScope is a public GitHub project for a local-first desktop app that turns a song into a practical rehearsal view: likely harmony by section and by instrument or vocal role, section roadmap, tempo and groove cues, rough stem previews, playable ranges, simplification hints, transposition or capo guidance, overlap cues, visible confidence, and rehearsal priorities without DAW complexity. +BandScope is a public GitHub project for a local-first desktop app that turns a song into a practical rehearsal view: likely harmony by section and by instrument or vocal role, section roadmap, tempo and groove cues, separated stems, playable ranges, simplification hints, transposition or capo guidance, overlap cues, visible confidence, and rehearsal priorities without DAW complexity. It does not promise notation-grade full arrangement transcription or DAW-style production editing. diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore deleted file mode 100644 index 20687473..00000000 --- a/apps/desktop/.gitignore +++ /dev/null @@ -1 +0,0 @@ -storybook-static diff --git a/apps/desktop/.storybook/main.ts b/apps/desktop/.storybook/main.ts deleted file mode 100644 index fa5c0b52..00000000 --- a/apps/desktop/.storybook/main.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { StorybookConfig } from "@storybook/react-vite" -import tailwindcss from "@tailwindcss/vite" -import path from "node:path" -import { fileURLToPath } from "node:url" - -const dir = path.dirname(fileURLToPath(import.meta.url)) - -const config: StorybookConfig = { - stories: ["../src/**/*.stories.@(ts|tsx)"], - addons: [], - framework: { name: "@storybook/react-vite", options: {} }, - viteFinal: async (viteConfig) => { - viteConfig.plugins = [...(viteConfig.plugins ?? []), tailwindcss()] - viteConfig.resolve = viteConfig.resolve ?? {} - viteConfig.resolve.alias = { - ...(viteConfig.resolve.alias ?? {}), - "@": path.resolve(dir, "../src"), - } - return viteConfig - }, -} - -export default config diff --git a/apps/desktop/.storybook/preview.ts b/apps/desktop/.storybook/preview.ts deleted file mode 100644 index 81f8c701..00000000 --- a/apps/desktop/.storybook/preview.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Preview } from "@storybook/react-vite" - -import "../src/index.css" - -const preview: Preview = { - parameters: { - layout: "centered", - controls: { - matchers: { color: /(background|color)$/i, date: /Date$/i }, - }, - }, -} - -export default preview diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3a3f2011..e8b42e09 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,8 +6,6 @@ "scripts": { "dev": "vite", "build": "vite build", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", "lint": "eslint \"src/**/*.{ts,tsx}\" vite.config.ts", "typecheck": "tsc --noEmit", "test": "node -e \"require('node:fs').mkdirSync('coverage/.tmp', { recursive: true })\" && vitest run --coverage" @@ -22,12 +20,10 @@ "lucide-react": "^1.20.0", "react": "^19.2.4", "react-dom": "^19.2.7", - "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@storybook/react-vite": "^10.4.6", "@tailwindcss/vite": "^4.3.1", "@tauri-apps/cli": "^2.11.2", "@testing-library/jest-dom": "^6.6.3", @@ -39,7 +35,6 @@ "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.5.0", "jsdom": "^29.1.1", - "storybook": "^10.4.6", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", "typescript-eslint": "^8.60.1", diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index af45bdfc..27645d0c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1107,13 +1107,7 @@ async fn import_youtube_url( Err("YouTube import failed with an unknown error.".to_string()) } -const MAX_YOUTUBE_URL_LENGTH: usize = 2000; - fn is_supported_youtube_url(url: &str) -> bool { - if url.len() > MAX_YOUTUBE_URL_LENGTH { - return false; - } - let parsed_url = match url::Url::parse(url) { Ok(u) => u, Err(_) => return false, @@ -1526,9 +1520,6 @@ mod tests { )); assert!(!is_supported_youtube_url("https://youtu.be/abc123")); assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); - - let long_url = format!("https://youtube.com/watch?v={}", "a".repeat(2000)); - assert!(!is_supported_youtube_url(&long_url)); } #[test] diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 838dcb8c..66a59ff1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -49,7 +49,6 @@ import { EmptyState, ErrorState, LoadingState } from "./features/workspace/Works import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; -import { Toaster } from "@/components/ui/sonner"; const ANALYSIS_POLL_INTERVAL_MS = 250; const MAX_ERROR_DETAIL_LENGTH = 220; @@ -801,8 +800,6 @@ export function App() { - - ); } diff --git a/apps/desktop/src/components/ui/accordion.tsx b/apps/desktop/src/components/ui/accordion.tsx deleted file mode 100644 index 752e33a0..00000000 --- a/apps/desktop/src/components/ui/accordion.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client" - -import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" - -/** Render a vertically stacked set of collapsible sections. */ -function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { - return ( - - ) -} - -/** Render one collapsible accordion item. */ -function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { - return ( - - ) -} - -/** Render the clickable header that toggles an item open or closed. */ -function AccordionTrigger({ - className, - children, - ...props -}: AccordionPrimitive.Trigger.Props) { - return ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - - ) -} - -/** Render the collapsible content panel of an item. */ -function AccordionContent({ - className, - children, - ...props -}: AccordionPrimitive.Panel.Props) { - return ( - -
{children}
-
- ) -} - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/apps/desktop/src/components/ui/breadcrumb.tsx b/apps/desktop/src/components/ui/breadcrumb.tsx deleted file mode 100644 index 76061293..00000000 --- a/apps/desktop/src/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from "react" -import { ChevronRight, MoreHorizontal } from "lucide-react" - -import { cn } from "@/lib/utils" - -/** Render the breadcrumb navigation landmark. */ -function Breadcrumb(props: React.ComponentProps<"nav">) { - return