Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,16 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- `docs/security/github-required-checks.md`
- `docs/plans/2026-03-10-bandscope-harness-design.md`
- `docs/plans/2026-03-10-bandscope-harness.md`
- `docs/doctoring/section-harmony-known-progression.md`
- `docs/plans/2026-08-16-section-harmony-known-progression.md`

## Code style
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
- Do not replace the known-progression section-harmony lock with a mocked recognizer. A verse/chorus take must keep distinct section answers.

## Safety
- Do not add network-dependent runtime paths for local analysis.
Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-16

## Brand source

Expand Down Expand Up @@ -115,6 +115,7 @@ Last updated: 2026-03-11
- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase.
- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace.
- Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy.
- Section harmony must stay a `song -> section` answer. The known-progression lock in `docs/doctoring/section-harmony-known-progression.md` requires the recognizer plus section summary to recover C then G (and the reverse order) on annotated two-section windows with duration-weighted recall at or above 0.70. Minor labels must not satisfy a major window.
- Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs.

## Verification model
Expand Down
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

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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ 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.
- `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. Section harmony is locked by a known-progression take in `docs/doctoring/section-harmony-known-progression.md`.

Data flow: React UI → Tauri IPC command → Rust validation + Python subprocess over stdin/stdout → job status and progress events emitted back to the UI.

Expand Down
54 changes: 54 additions & 0 deletions docs/doctoring/section-harmony-known-progression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Section harmony known-progression accuracy

Next action: keep verse and chorus as separate harmony answers. Do not let a
song-wide chord track replace the `song -> section -> role` contract. Run
`uv run --project services/analysis-engine pytest tests/test_section_harmony_audio_accuracy.py`
before claiming a recognizer or section-summary change is rehearsal-ready.

## Why this lock exists

Players copy from the recording. If the engine hears a C-major verse and a
G-major chorus and then prints one song-level chord, the room wastes the first
pass arguing about the form. Duration-weighted chord symbol recall against
annotated windows is the evaluation unit used in automatic chord estimation
reviews (Harte et al., 2005; McVicar et al., 2014). Fujishima (1999) established
chroma-template matching as the baseline this engine still uses.

The lock synthesizes a dry two-section take (C major, then G major, and the
reverse order) so CI does not depend on a copyrighted master. The true
parameters are the section windows and triad roots. The estimate must recover
each section's main chord and keep weighted recall at or above 0.70.

## Held values

- Sample rate: 22050 Hz
- Section length: 4.0 s
- Minimum duration-weighted recall: 0.70
- Canonical roots: `C` then `G`, and `G` then `C`
- Minor labels (`Cm`, `Gm`) must not satisfy a major window

## References

Fujishima, T. (1999). Realtime chord recognition of musical sound: A system
using Common Lisp Music. In *Proceedings of the International Computer Music
Conference* (pp. 464–467). International Computer Music Association.

Harte, C., Sandler, M., Abdallah, S., & Gómez, E. (2005). Symbolic
representation of musical chords: A proposed syntax for text annotations. In
*Proceedings of the 6th International Conference on Music Information Retrieval*
(pp. 66–71).

McVicar, M., Santos-Rodríguez, R., Ni, Y., & De Bie, T. (2014). Automatic chord
estimation from audio: A review of the state of the art. *IEEE/ACM Transactions
on Audio, Speech, and Language Processing, 22*(2), 556–575.
https://doi.org/10.1109/TASLP.2013.2294580

## Security Notes

- Attack surface: in-memory float audio arrays only. The lock does not read
user files, URLs, or subprocess output.
- Trust boundary: synthetic fixtures are trusted test input. Production
recognizer input remains untrusted decoded audio from stem separation.
- Mitigations: no file I/O, no network, no shell. Failures stay inside pytest.
- Test points: `test_section_harmony_recovers_verse_c_then_chorus_g` and
`test_section_harmony_keeps_later_c_off_the_opening_window`.
41 changes: 41 additions & 0 deletions docs/plans/2026-08-16-section-harmony-known-progression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Section harmony known-progression accuracy lock

**Goal:** Prove the analysis engine recovers distinct section harmony from a
rehearsal-shaped take, not a single song-wide chord.

**Architecture:** `ChordRecognizer` emits time-stamped segments.
`summarize_section_harmony` overlaps those segments onto section windows. The
new tests synthesize C-major then G-major audio (and the reverse) and score
duration-weighted chord symbol recall against those true windows.

## Security Notes

### Attack surface

- Synthetic numpy audio arrays constructed in-process for pytest.

### Trust boundary

- Untrusted: production audio from user files and YouTube import.
- Trusted: fixture frequencies, section bounds, and recall threshold in the
test module and `docs/doctoring/section-harmony-known-progression.md`.

### Mitigations

- No file reads, URL intake, subprocesses, or IPC.
- Threshold is a regression floor, not a claim that live masters are solved.

### Test points

- Verse C / chorus G main chords and ≥0.70 weighted recall.
- Reverse order keeps G on the opening window.

### Realistic threats

- A mocked recognizer can keep CI green while live section answers stay wrong.
- A song-wide majority chord can hide a chorus change and send the wrong lock-in cue.

### Remaining risk

- Dry triads are easier than mixed stems. Live-master recall stays a later
gold-corpus lane and must not be faked with mocked recognizer output.
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Known-progression accuracy lock for section-level chord recovery.

Rehearsal buyers trust BandScope only when a real take produces the harmony
they will play. Single-chord unit tests are not enough: a verse-then-chorus
song must keep distinct section answers. This module synthesizes a two-section
rehearsal take (C major, then G major) and measures duration-weighted chord
symbol recall against those true labels (Harte et al., 2005; McVicar et al.,
2014).
"""

from __future__ import annotations

import numpy as np

from bandscope_analysis.chords.chord_recognizer import ChordRecognizer, TrackedChord
from bandscope_analysis.chords.section_harmony import summarize_section_harmony

_SAMPLE_RATE = 22050
_SECTION_SECONDS = 4.0
_MINIMUM_WEIGHTED_RECALL = 0.70
_C4_HZ = 261.63
_G4_HZ = 392.00


def _canonical_major_symbol(label: str) -> str:
"""Keep major-triad spellings; do not treat minor as a match.

Args:
label: Raw recognizer or annotation chord string.

Returns:
``C`` or ``G`` for those major labels (including a ``:maj`` suffix),
otherwise the stripped original so ``Cm`` cannot satisfy a C window.
"""
stripped = label.strip()
if stripped.endswith(":maj"):
return stripped[: -len(":maj")]
return stripped


def _major_triad_take(root_hz: float, duration_seconds: float, sample_rate: int) -> np.ndarray:
"""Render a dry major triad as equal-amplitude sines.

Args:
root_hz: Root frequency in hertz.
duration_seconds: Take length in seconds.
sample_rate: Samples per second.

Returns:
Mono float32 audio in ``[-1, 1]``.
"""
sample_count = int(sample_rate * duration_seconds)
time_axis = np.linspace(0.0, duration_seconds, sample_count, endpoint=False)
major_third_hz = root_hz * (2.0 ** (4.0 / 12.0))
perfect_fifth_hz = root_hz * (2.0 ** (7.0 / 12.0))
waveform = (
np.sin(2.0 * np.pi * root_hz * time_axis)
+ np.sin(2.0 * np.pi * major_third_hz * time_axis)
+ np.sin(2.0 * np.pi * perfect_fifth_hz * time_axis)
) / 3.0
return waveform.astype(np.float32)


def _duration_weighted_symbol_recall(
segments: list[TrackedChord],
truth_windows: list[tuple[float, float, str]],
) -> float:
"""Score recovered segments against true section windows.

The denominator is annotated duration so a silent or smeared boundary
cannot inflate the score (McVicar et al., 2014).

Args:
segments: Time-stamped recognizer output.
truth_windows: ``(start, end, canonical_chord)`` annotations.

Returns:
Overlap-weighted recall in ``[0, 1]``, or ``0.0`` when the annotation
duration is not positive.
"""
annotated_duration = sum(end - start for start, end, _chord in truth_windows)
if annotated_duration <= 0.0:
return 0.0

matched_duration = 0.0
for truth_start, truth_end, truth_chord in truth_windows:
for segment in segments:
overlap = min(segment["end_time"], truth_end) - max(segment["start_time"], truth_start)
if overlap <= 0.0:
continue
if _canonical_major_symbol(segment["chord"]) == truth_chord:
matched_duration += overlap
return matched_duration / annotated_duration


def test_canonical_major_symbol_rejects_minor_as_major() -> None:
"""Keep ``Cm`` distinct from ``C`` so a minor estimate cannot pass."""
assert _canonical_major_symbol("C") == "C"
assert _canonical_major_symbol("C:maj") == "C"
assert _canonical_major_symbol("Cm") == "Cm"
assert _canonical_major_symbol("G:maj") == "G"
assert _canonical_major_symbol("Gm") == "Gm"


def test_section_harmony_recovers_verse_c_then_chorus_g() -> None:
"""Recover C then G as section main chords from a two-section take.

The take is a rehearsal-shaped verse/chorus pair: four seconds of C major
followed by four seconds of G major. The recognizer runs on the mixed
audio. Section summaries must keep those answers apart, and
duration-weighted recall against the true windows must stay at or above
70 percent so a boundary smear cannot hide a wrong-song result.
"""
verse = _major_triad_take(_C4_HZ, _SECTION_SECONDS, _SAMPLE_RATE)
chorus = _major_triad_take(_G4_HZ, _SECTION_SECONDS, _SAMPLE_RATE)
take = np.concatenate([verse, chorus])
boundaries = [(0.0, _SECTION_SECONDS), (_SECTION_SECONDS, _SECTION_SECONDS * 2.0)]
truth_windows = [
(0.0, _SECTION_SECONDS, "C"),
(_SECTION_SECONDS, _SECTION_SECONDS * 2.0, "G"),
]

segments = ChordRecognizer().recognize(take, sr=_SAMPLE_RATE)
summaries = summarize_section_harmony(segments, boundaries)

assert len(summaries) == 2
assert _canonical_major_symbol(summaries[0]["main_chord"]) == "C"
assert _canonical_major_symbol(summaries[1]["main_chord"]) == "G"
assert _duration_weighted_symbol_recall(segments, truth_windows) >= _MINIMUM_WEIGHTED_RECALL


def test_section_harmony_keeps_later_c_off_the_opening_window() -> None:
"""Refuse a song-wide C answer when G occupies the first section.

The same two triads in reverse order must not collapse to one global
chord. Players need the opening chorus to stay G even though C arrives
later. This is the temporal complement of the verse-then-chorus lock.
"""
chorus = _major_triad_take(_G4_HZ, _SECTION_SECONDS, _SAMPLE_RATE)
verse = _major_triad_take(_C4_HZ, _SECTION_SECONDS, _SAMPLE_RATE)
take = np.concatenate([chorus, verse])
boundaries = [(0.0, _SECTION_SECONDS), (_SECTION_SECONDS, _SECTION_SECONDS * 2.0)]
truth_windows = [
(0.0, _SECTION_SECONDS, "G"),
(_SECTION_SECONDS, _SECTION_SECONDS * 2.0, "C"),
]

segments = ChordRecognizer().recognize(take, sr=_SAMPLE_RATE)
summaries = summarize_section_harmony(segments, boundaries)

assert len(summaries) == 2
assert _canonical_major_symbol(summaries[0]["main_chord"]) == "G"
assert _canonical_major_symbol(summaries[1]["main_chord"]) == "C"
assert _duration_weighted_symbol_recall(segments, truth_windows) >= _MINIMUM_WEIGHTED_RECALL
Loading