diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..ae44dffef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,9 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings + - playable ranges and density or overlap warnings measured per section from + pitched-stem spectra (`song -> section -> role`), never fabricated + song-wide clash copy when stems or matching section windows are absent - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..d9c14c7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- Reduce register-overlap pair work by comparing only stems that meet the measured occupancy threshold for each register band while preserving deterministic result ordering. + +### Fixed + +- Keep silent stems from becoming zero-severity rehearsal overlap warnings at a zero threshold, and fail closed on invalid negative, non-finite, or boolean threshold configuration. +- Derive section-level register-overlap warnings from measured stem spectra instead of fabricating keyboard or vocal clash copy when no audio evidence exists. +- Keep mixed `other`-stem accompaniment evidence role-agnostic instead of assigning the same overlap warning and rehearsal priority to specific keyboard-hand or guitar roles the separator cannot identify. +- Omit register-overlap warnings when section windows are missing or do not match the section list, instead of measuring the whole song and repeating that average on every card. +- Keep mixed `other` stem energy from marking Keyboard Left Hand, Keyboard Right Hand, or Acoustic Guitar as active parts. +- Replace fabricated demo and browser-fallback clash copy with the same next-action register-overlap wording the engine emits for unambiguous bass and vocal roles. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..b910baafd 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -754,7 +754,7 @@ mod tests { "setupNote": "Keep the attack short so the verse breathes.", "manualOverrides": [], "overlapWarnings": [ - "Density warning: competing with Keyboard Left Hand in low register." + "The low register is crowded between Bass Guitar and accompaniment. Thin one part in this section so players can hear their cue." ] } ], diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..e3ca67142 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -92,7 +92,7 @@ function succeededResult() { setupNote: "Keep the attack short so the verse breathes.", manualOverrides: [], overlapWarnings: [ - "Density warning: competing with Keyboard Left Hand in low register." + "The low register is crowded between Bass Guitar and accompaniment. Thin one part in this section so players can hear their cue." ] }, { diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..ce1389084 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -48,6 +48,20 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).toHaveBeenCalledTimes(1); }); + it("shows measured overlap copy from the demo song instead of a named keyboard clash", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect( + screen.getByText( + "The low register is crowded between Bass Guitar and accompaniment. Thin one part in this section so players can hear their cue." + ) + ).toBeTruthy(); + expect(screen.queryByText(/Density warning|Melodic overlap|Keyboard Left Hand/)).toBeNull(); + }); + it("does not update when the trimmed chord is unchanged", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/docs/doctoring/register-overlap.md b/docs/doctoring/register-overlap.md new file mode 100644 index 000000000..d8a9a3e5a --- /dev/null +++ b/docs/doctoring/register-overlap.md @@ -0,0 +1,83 @@ +# Register-overlap doctoring + +BandScope density warnings are a rehearsal cue, not a studio mix verdict. +They answer: *in this section, which pitched parts share a register so a +player should thin, simplify, or listen for a cue before the room starts.* + +## Analysis target + +Warnings follow the `song -> section -> role` hierarchy in +`ARCHITECTURE.md`. A song-wide FFT over mixed stems would hide the verse +that is muddy and the chorus that is already clear. Section windows reuse +the same boundary list that drives stem activity, so overlap is a +time-local observation rather than an atomistic song average. + +Four-stem separation (`vocals`, `bass`, `drums`, `other`) cannot honestly +name Keyboard Left Hand versus Acoustic Guitar. The mixed `other` stem is +labeled accompaniment. Inventing a keyboard clash from that stem is a +product lie. The same honesty applies to presence: `map_stems_to_roles` +must not mark Keyboard Left Hand, Keyboard Right Hand, or Acoustic Guitar +active just because mixed `other` has energy. Browser-fallback and shared +demo fixtures use the same next-action copy the engine emits, and they +never attach that copy to a named keyboard or guitar role. + +## Psychoacoustic and MIR basis + +Auditory scene analysis treats concurrent sources as streams that compete +when they occupy the same spectral region (Bregman, 1990). Simultaneous +masking and critical-band overlap explain why two pitched parts in one +register become hard to hear and hard to lock (Moore, 2012; Fastl & +Zwicker, 2007). Equal-loudness contours (ISO, 2023) are not used as a +loudness meter here; they justify treating low, mid, and high registers as +perceptually different work for a band rather than as interchangeable FFT +bins. + +Music-information-retrieval practice extracts spectral energy +distributions as timbre and texture descriptors (Tzanetakis & Cook, 2002; +Peeters, 2004). BandScope uses a three-band magnitude-squared real FFT +share, then reports a pair only when both pitched stems occupy the same +band above a finite threshold. Drums stay unpitched because broadband +transients do not mark a rehearsal register. + +Temporal structure uses the same section boundaries as novelty-based form +analysis already present in the engine (Foote, 2000; Paulus et al., 2010). +That keeps overlap aligned with the roadmap a player actually rehearses. +If stems arrive without a matching boundary for every section, the +extractor emits no overlap warning rather than averaging the whole song. + +## What the player should do next + +Copy is action-first: name the crowded register, name the two sides, and +tell the player to thin one part in *this* section. It does not declare a +correct voicing. + +## References + +Bregman, A. S. (1990). *Auditory scene analysis: The perceptual +organization of sound*. The MIT Press. + +Fastl, H., & Zwicker, E. (2007). *Psychoacoustics: Facts and models* +(3rd ed.). Springer. https://doi.org/10.1007/978-3-540-68888-4 + +Foote, J. (2000). Automatic audio segmentation using a measure of audio +novelty. In *Proceedings of the IEEE International Conference on +Multimedia and Expo* (Vol. 1, pp. 452–455). IEEE. +https://doi.org/10.1109/ICME.2000.869637 + +International Organization for Standardization. (2023). *Acoustics — +Normal equal-loudness-level contours* (ISO 226:2023). + +Moore, B. C. J. (2012). *An introduction to the psychology of hearing* +(6th ed.). Brill. + +Paulus, J., Müller, M., & Klapuri, A. (2010). Audio-based music structure +analysis. In *Proceedings of the 11th International Society for Music +Information Retrieval Conference* (pp. 625–630). ISMIR. + +Peeters, G. (2004). *A large set of audio features for sound description +(similarity and classification) in the CUIDADO project* (Technical +report). IRCAM. + +Tzanetakis, G., & Cook, P. (2002). Musical genre classification of audio +signals. *IEEE Transactions on Speech and Audio Processing, 10*(5), +293–302. https://doi.org/10.1109/TSA.2002.800560 diff --git a/docs/plans/2026-08-16-register-overlap-demo-honesty.md b/docs/plans/2026-08-16-register-overlap-demo-honesty.md new file mode 100644 index 000000000..120f96015 --- /dev/null +++ b/docs/plans/2026-08-16-register-overlap-demo-honesty.md @@ -0,0 +1,59 @@ +# Register-overlap demo and presence honesty + +**Goal:** Stop the buyer-visible lie that mixed htdemucs `other` is +Keyboard Left Hand, Keyboard Right Hand, or Acoustic Guitar. Demo +fixtures and stem-activity mapping must use the same fail-closed +identity contract as measured overlap warnings. + +**Architecture:** `map_stems_to_roles` maps only `vocals` and `bass` to +named roles. Shared-types, browser-fallback, and Rust contract fixtures +reuse engine next-action copy on unambiguous roles only. + +**Tech Stack:** Python 3.12 activity mapper, TypeScript shared contracts, +React SectionRoadmap, Tauri serde fixtures. + +## Task + +1. Keep `other` from activating named keyboard or guitar roles. +2. Replace fabricated `Density warning` / `Melodic overlap` demo copy + with measured-style next-action wording. +3. Leave keyboard-hand and guitar demo warnings empty. +4. Keep heuristic no-stem extraction unchanged. + +## Security Notes + +### Attack surface + +- In-memory stem-activity booleans already admitted by orchestration +- Demo rehearsal-song strings rendered in the desktop WebView + +### Trust boundary + +- Python activity mapper -> shared rehearsal-role contract -> React + workspace cards and Tauri serde fixtures + +### Mitigations + +- No file I/O, network, or subprocess in activity mapping +- Mixed `other` cannot authorize a named accompaniment role +- Demo copy is allowlisted next-action text, not a user-controlled path + +### Test points + +- `other`-only activity leaves keys and guitar inactive +- Extractor with bass+other keeps those roles out of `active_roles` +- Shared demo song rejects fabricated identity strings +- SectionRoadmap renders the honest bass next-action sentence + +### Realistic threats + +- Warning text injection is not a new channel: demo strings are + repository fixtures, and engine copy stays allowlisted +- Presence under-claiming (accompaniment plays but no named card) is + preferred to over-claiming three false parts + +### Remaining risk + +- Four-stem separation still cannot offer a dedicated accompaniment + role card. Add that role only with a later contract, not by renaming + `other` into keys or guitar. diff --git a/docs/plans/2026-08-16-register-overlap-role-wiring.md b/docs/plans/2026-08-16-register-overlap-role-wiring.md new file mode 100644 index 000000000..cb6613b74 --- /dev/null +++ b/docs/plans/2026-08-16-register-overlap-role-wiring.md @@ -0,0 +1,68 @@ +# Register-overlap role wiring + +**Goal:** Stop fabricating keyboard and vocal clash copy. Attach +FFT-derived register-overlap warnings to rehearsal roles per section so a +player can thin a crowded register before rehearsal. + +**Architecture:** `detect_register_overlap` stays a pure in-memory +feature. `RoleExtractor` slices admitted stems to each section window, +formats honest accompaniment labels, and copies roles so warnings and +priority stay section-local. + +**Tech Stack:** Python 3.12, numpy real FFT, pytest, existing role +contracts. + +## Task + +1. Keep heuristic extraction (no stems) at empty `overlapWarnings`. +2. Measure overlap only when every section has a matching boundary; + missing or mismatched windows fail closed to no warnings. +3. Map `vocals` and `bass` to Lead Vocal and Bass Guitar. Keep mixed + `other` in player-facing copy as accompaniment, but do not assign that + evidence to Keyboard Left Hand, Keyboard Right Hand, or Acoustic Guitar. +4. Recalculate rehearsal priority from the section-local warnings. +5. Fail closed to no warnings when mapping throws. + +## Security Notes + +### Attack surface + +- In-memory stem arrays and section boundary timestamps already admitted + by canonical orchestration +- Role-warning strings rendered in the desktop WebView + +### Trust boundary + +- Python analysis engine -> shared rehearsal-role contract -> React + workspace cards + +### Mitigations + +- No file I/O, network, or subprocess in overlap formatting +- Invalid windows, non-array stems, and mapping exceptions return empty + warnings +- Copy stays derived from measured shares; mixed `other` is not renamed + into a specific keyboard or guitar identity + +### Test points + +- Known 80 Hz bass+accompaniment verse versus 1 kHz chorus fixture +- Empty warnings when stems are absent or section windows are missing +- Mixed `other` overlap warns only the unambiguous stem-side role +- Mapping exception omits warnings without aborting extraction +- Invalid slice windows return empty arrays + +### Realistic threats + +- Oversized admitted audio already owned by `#781` / `#866`; this feature + must not add a second sample or stem ceiling +- Warning text injection is not a new channel: strings are engine-generated + from allowlisted stem and band names + +### Remaining risk + +- Four-stem `other` still cannot separate keys from guitar. Finer role + identity needs a later source-separation or user-override path, not + fabricated names. Presence mapping and demo fixtures now follow the + same rule: mixed accompaniment does not activate or warn a named + keyboard or guitar role. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..961857c52 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -476,7 +476,7 @@ const demoRehearsalSongSeed: RehearsalSong = { transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.", manualOverrides: [], overlapWarnings: [ - "Density warning: competing with Keyboard Left Hand in low register." + "The low register is crowded between Bass Guitar and accompaniment. Thin one part in this section so players can hear their cue." ] }, { @@ -507,9 +507,7 @@ const demoRehearsalSongSeed: RehearsalSong = { setupNote: "Keep the patch bright enough to stay over the guitars.", transpositionPlan: "If the band rehearses in D, keep the voicing in first inversion so the top line still sings.", manualOverrides: [], - overlapWarnings: [ - "Melodic overlap: top notes conflict with Lead Vocal range." - ] + overlapWarnings: [] }, { id: "lead-vocal", @@ -550,7 +548,7 @@ const demoRehearsalSongSeed: RehearsalSong = { } ], overlapWarnings: [ - "Melodic overlap: competing with Keyboard 1 Right Hand." + "The mid register is crowded between accompaniment and Lead Vocal. Thin one part in this section so players can hear their cue." ] } ], diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..6de9e9a8e 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -736,6 +736,17 @@ describe("shared type helpers", () => { }); expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); + expect(song.sections[0]?.roles[0]?.overlapWarnings).toEqual([ + "The low register is crowded between Bass Guitar and accompaniment. Thin one part in this section so players can hear their cue." + ]); + expect(song.sections[0]?.roles[1]?.overlapWarnings).toEqual([]); + expect(song.sections[0]?.roles[2]?.overlapWarnings).toEqual([ + "The mid register is crowded between accompaniment and Lead Vocal. Thin one part in this section so players can hear their cue." + ]); + const fabricatedIdentity = /Density warning|Melodic overlap|Keyboard Left Hand|Keyboard 1 Right Hand/; + for (const role of song.sections[0]?.roles ?? []) { + expect(role.overlapWarnings.join(" ")).not.toMatch(fabricatedIdentity); + } expect(song.sections[0]?.roles[0]?.harmonicExplanation).toContain("tonal floor"); expect(song.sections[0]?.roles[0]?.transpositionPlan).toContain("whole step lower"); expect(song.collaboration?.assignments).toHaveLength(2); diff --git a/services/analysis-engine/src/bandscope_analysis/roles/activity.py b/services/analysis-engine/src/bandscope_analysis/roles/activity.py index 9925d6a2d..3d7e50645 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/activity.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/activity.py @@ -97,7 +97,9 @@ def map_stems_to_roles(stem_activity: dict[str, bool]) -> dict[str, bool]: - vocals -> lead-vocal - bass -> bass-guitar - drums -> (no dedicated role, contributes to groove detection) - - other -> keys-right, keys-left, acoustic-guitar + - other -> no named keyboard or guitar role. Mixed accompaniment + energy cannot establish Keyboard Left Hand, Keyboard Right Hand, + or Acoustic Guitar identity. Args: stem_activity: Dict mapping stem names to active booleans. @@ -107,14 +109,13 @@ def map_stems_to_roles(stem_activity: dict[str, bool]) -> dict[str, bool]: """ vocals_active = stem_activity.get("vocals", False) bass_active = stem_activity.get("bass", False) - other_active = stem_activity.get("other", False) return { "bass-guitar": bass_active, - "keys-left": other_active, - "keys-right": other_active, + "keys-left": False, + "keys-right": False, "lead-vocal": vocals_active, - "acoustic-guitar": other_active, + "acoustic-guitar": False, } diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..eb174ab62 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -17,6 +17,7 @@ RoleType, SectionRoleTopology, ) +from .overlap import detect_register_overlap, format_overlap_warnings, slice_stems_to_window from .priority import calculate_rehearsal_priority from .tuning import get_setup_note @@ -78,6 +79,12 @@ def extract( # Fallback to heuristic-based topology topology = self._build_topology(section_id, i == 0, roles) + section_boundary = boundaries[i] if stems and len(boundaries) == len(sections) else None + section_warnings = self._section_overlap_warnings(stems, sr, section_boundary) + topology["active_roles"] = [ + self._apply_section_warnings(role, section_warnings) + for role in topology["active_roles"] + ] topologies.append(topology) extraction_method = ( @@ -169,6 +176,60 @@ def _extract_features( return vocal_range, vocal_chord, bass_range, bass_chord + def _section_overlap_warnings( + self, + stems: dict[str, Any], + sr: int, + boundary: tuple[float, float] | None, + ) -> dict[str, list[str]]: + """Derive role warnings from measured register overlap in one window. + + Args: + stems: Dict mapping stem names to mono float audio arrays. + sr: Sample rate in Hz. + boundary: ``(start_seconds, end_seconds)`` section window. ``None`` + means the extractor has no matching section window, so this + method fails closed instead of measuring the whole song. + + Returns: + Mapping of role id to rehearsal warnings. Empty when stems are + missing, the section window is absent, or overlap mapping fails + closed. + """ + if not stems or boundary is None: + return {} + try: + windowed = slice_stems_to_window(stems, boundary[0], boundary[1], int(sr)) + return format_overlap_warnings(detect_register_overlap(windowed, int(sr))) + except Exception: + logger.warning( + "Register-overlap warning mapping failed; omitting warnings.", + exc_info=True, + ) + return {} + + def _apply_section_warnings( + self, + role: RehearsalRole, + warnings_by_role: dict[str, list[str]], + ) -> RehearsalRole: + """Copy a role with section-local overlap warnings and refreshed priority. + + Args: + role: Source rehearsal role. + warnings_by_role: Measured warnings keyed by role id. + + Returns: + A shallow role copy whose overlap warnings and priority match this + section instead of a song-wide fabricated string. + """ + updated: RehearsalRole = { + **role, + "overlapWarnings": list(warnings_by_role.get(role["id"], [])), + } + updated["rehearsalPriority"] = calculate_rehearsal_priority(updated) + return updated + def _build_roles( self, bass_chord: str, @@ -201,9 +262,7 @@ def _build_roles( "setupNote": get_setup_note("Bass Guitar", [bass_chord]) or "Keep the attack short so the verse breathes.", "manualOverrides": [], - "overlapWarnings": [ - "Density warning: competing with Keyboard Left Hand in low register." - ], + "overlapWarnings": [], } keys_left_role: RehearsalRole = { @@ -230,7 +289,7 @@ def _build_roles( "setupNote": get_setup_note("Keyboard", ["C#"]) or "Use a darker patch to avoid clashing with right hand.", "manualOverrides": [], - "overlapWarnings": ["Density warning: competing with Bass Guitar in low register."], + "overlapWarnings": [], } keys_role: RehearsalRole = { @@ -257,7 +316,7 @@ def _build_roles( "setupNote": get_setup_note("Keyboard", ["Emaj7"]) or "Keep the patch bright enough to stay over the guitars.", "manualOverrides": [], - "overlapWarnings": ["Melodic overlap: top notes conflict with Lead Vocal range."], + "overlapWarnings": [], } vocal_role: RehearsalRole = { @@ -291,7 +350,7 @@ def _build_roles( "source": "user", } ], - "overlapWarnings": ["Melodic overlap: competing with Keyboard 1 Right Hand."], + "overlapWarnings": [], } acoustic_guitar_role: RehearsalRole = { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/overlap.py b/services/analysis-engine/src/bandscope_analysis/roles/overlap.py index 4a842cd86..66a189051 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/overlap.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/overlap.py @@ -12,7 +12,8 @@ Security Notes: - Operates only on in-memory numpy arrays; no file I/O or network access. -- All FFT and reduction operations are bounded by the input array sizes. +- Canonical orchestration owns audio-size, stem-count, memory, CPU/GPU, and + cancellation admission policy before feature analyzers execute. - Fails safe: empty, silent, or malformed stems produce an empty result and no exception escapes the public functions. """ @@ -33,6 +34,7 @@ "mid": (250.0, 2000.0), "high": (2000.0, 8000.0), } +_BAND_ORDER = {band: index for index, band in enumerate(BANDS)} # Drums are excluded from pitched-register analysis: percussion is broadband # (energy is spread across the spectrum by transients and noise), so band @@ -43,6 +45,28 @@ # occupying that register. DEFAULT_THRESHOLD = 0.35 +# Display names keep 4-stem honesty: htdemucs `other` is mixed accompaniment, +# not a named keyboard or guitar part. +_STEM_DISPLAY_NAMES = { + "vocals": "Lead Vocal", + "bass": "Bass Guitar", + "other": "accompaniment", +} +# Role assignment is narrower than display naming. The mixed `other` stem may +# be named as accompaniment in copy, but it cannot establish whether a keyboard +# hand or guitar caused the overlap. Attach warnings only to stems with an +# unambiguous role identity; the opposite mixed side remains role-agnostic. +_STEM_TO_ROLE_IDS = { + "vocals": ("lead-vocal",), + "bass": ("bass-guitar",), + "other": (), +} +_BAND_LABELS = { + "low": "low register", + "mid": "mid register", + "high": "high register", +} + def band_energy_profile( audio: NDArray[np.floating[Any]], @@ -51,7 +75,9 @@ def band_energy_profile( """Compute the fraction of a stem's spectral energy in each register band. Energy is the magnitude-squared of the real FFT summed over the bins that - fall inside each band defined in :data:`BANDS`. + fall inside each band defined in :data:`BANDS`. Resource admission is a + canonical orchestration concern; this feature consumes the accepted audio + artifact without inventing a second sample-count ceiling. Args: audio: Mono float audio samples for one stem. @@ -91,41 +117,141 @@ def detect_register_overlap( band in which both stems concentrate at least ``threshold`` of their spectral energy. Drums are excluded (see :data:`UNPITCHED_STEMS`): as a broadband percussion source they do not occupy a pitched register. + Resource admission is owned by canonical orchestration rather than a + feature-local stem-count ceiling. Args: stems: Dict mapping stem names to mono float audio arrays. sr: Common sample rate in Hz. - threshold: Minimum energy fraction for a stem to occupy a band. + threshold: Minimum energy fraction for a stem to occupy a band. Values + outside the finite ``0.0..1.0`` range fail safe with no overlaps. Returns: List of overlap records ``{"stem_a", "stem_b", "band", "severity"}`` where ``severity`` is the smaller of the two energy shares rounded to - two decimals. Pairs are ordered alphabetically (stem_a < stem_b) and - the list is sorted by severity descending. Empty when fewer than two - pitched stems have energy or on any internal failure. + two decimals. Pairs are ordered alphabetically (stem_a < stem_b), the + list is sorted by severity descending, and equal-severity records keep + alphabetical pair order followed by the declared :data:`BANDS` order. + Empty when fewer than two pitched stems have positive band energy, the + threshold is invalid, or any internal failure occurs. """ try: + if isinstance(threshold, bool): + return [] + threshold_value = float(threshold) + if not np.isfinite(threshold_value) or not 0.0 <= threshold_value <= 1.0: + return [] + pitched = sorted(name for name in stems if name not in UNPITCHED_STEMS) profiles = {name: band_energy_profile(stems[name], sr) for name in pitched} overlaps: list[dict[str, Any]] = [] - for i, stem_a in enumerate(pitched): - for stem_b in pitched[i + 1 :]: - for band in BANDS: - share_a = profiles[stem_a][band] - share_b = profiles[stem_b][band] - if share_a >= threshold and share_b >= threshold: - overlaps.append( - { - "stem_a": stem_a, - "stem_b": stem_b, - "band": band, - "severity": round(min(share_a, share_b), 2), - } - ) - - overlaps.sort(key=lambda item: -float(item["severity"])) + for band in BANDS: + active_stems = [ + (stem, profiles[stem][band]) + for stem in pitched + if profiles[stem][band] > 0.0 and profiles[stem][band] >= threshold_value + ] + for i, (stem_a, share_a) in enumerate(active_stems): + for stem_b, share_b in active_stems[i + 1 :]: + overlaps.append( + { + "stem_a": stem_a, + "stem_b": stem_b, + "band": band, + "severity": round(min(share_a, share_b), 2), + } + ) + + # Preserve the pre-optimization stable tie order: alphabetical pairs, + # then the declared register-band order rather than lexical band names. + overlaps.sort( + key=lambda item: ( + -float(item["severity"]), + item["stem_a"], + item["stem_b"], + _BAND_ORDER[str(item["band"])], + ) + ) return overlaps except Exception: # pragma: no cover - defensive fail-safe path logger.warning("Register-overlap detection failed; returning no overlaps.", exc_info=True) return [] + + +def slice_stems_to_window( + stems: dict[str, Any], + start_sec: float, + end_sec: float, + sr: int, +) -> dict[str, NDArray[np.floating[Any]]]: + """Slice each stem to one section window without inventing samples. + + Args: + stems: Dict mapping stem names to mono float audio arrays. + start_sec: Inclusive window start in seconds. + end_sec: Exclusive window end in seconds. + sr: Sample rate in Hz. + + Returns: + A new stem dict cropped to the window. Invalid windows, non-positive + sample rates, or non-array values become empty arrays so later FFT + work fails closed instead of using the whole song by accident. + """ + empty = np.array([], dtype=np.float64) + if sr <= 0 or not np.isfinite(start_sec) or not np.isfinite(end_sec) or end_sec <= start_sec: + return {name: empty.copy() for name in stems} + + start_sample = max(0, int(start_sec * sr)) + end_sample = max(0, int(end_sec * sr)) + if end_sample <= start_sample: + return {name: empty.copy() for name in stems} + + windowed: dict[str, NDArray[np.floating[Any]]] = {} + for name, audio in stems.items(): + if not isinstance(audio, np.ndarray) or audio.size == 0: + windowed[name] = empty.copy() + continue + low_index = min(start_sample, int(audio.size)) + high_index = min(end_sample, int(audio.size)) + if high_index <= low_index: + windowed[name] = empty.copy() + continue + windowed[name] = audio[low_index:high_index] + return windowed + + +def format_overlap_warnings(overlaps: list[dict[str, Any]]) -> dict[str, list[str]]: + """Turn measured overlap records into next-action rehearsal warnings. + + Args: + overlaps: Records from :func:`detect_register_overlap`. + + Returns: + Mapping of unambiguous role ids to de-duplicated warning strings. + Unknown stems or bands are omitted. Mixed accompaniment may appear in + the message text but never authorizes assigning that observation to a + named keyboard hand or guitar role. + """ + warnings: dict[str, list[str]] = {} + for record in overlaps: + stem_a = str(record.get("stem_a", "")) + stem_b = str(record.get("stem_b", "")) + band = str(record.get("band", "")) + name_a = _STEM_DISPLAY_NAMES.get(stem_a) + name_b = _STEM_DISPLAY_NAMES.get(stem_b) + band_label = _BAND_LABELS.get(band) + if name_a is None or name_b is None or band_label is None: + continue + message = ( + f"The {band_label} is crowded between {name_a} and {name_b}. " + "Thin one part in this section so players can hear their cue." + ) + for role_id in ( + *_STEM_TO_ROLE_IDS.get(stem_a, ()), + *_STEM_TO_ROLE_IDS.get(stem_b, ()), + ): + bucket = warnings.setdefault(role_id, []) + if message not in bucket: + bucket.append(message) + return warnings diff --git a/services/analysis-engine/tests/test_activity.py b/services/analysis-engine/tests/test_activity.py index 9c3024c28..951904474 100644 --- a/services/analysis-engine/tests/test_activity.py +++ b/services/analysis-engine/tests/test_activity.py @@ -71,15 +71,15 @@ def test_map_stems_to_roles_vocal_mapping() -> None: assert role_activity["acoustic-guitar"] is False -def test_map_stems_to_roles_other_maps_to_keys_and_guitar() -> None: - """Ensure 'other' stem maps to keys and acoustic guitar roles.""" +def test_map_stems_to_roles_other_does_not_name_accompaniment_roles() -> None: + """Mixed ``other`` energy must not mark keyboard or guitar roles active.""" activity = {"vocals": False, "bass": False, "drums": False, "other": True} role_activity = map_stems_to_roles(activity) - assert role_activity["keys-left"] is True - assert role_activity["keys-right"] is True - assert role_activity["acoustic-guitar"] is True + assert role_activity["keys-left"] is False + assert role_activity["keys-right"] is False + assert role_activity["acoustic-guitar"] is False assert role_activity["lead-vocal"] is False assert role_activity["bass-guitar"] is False diff --git a/services/analysis-engine/tests/test_priority.py b/services/analysis-engine/tests/test_priority.py index 5d155b979..3e7f07974 100644 --- a/services/analysis-engine/tests/test_priority.py +++ b/services/analysis-engine/tests/test_priority.py @@ -21,7 +21,10 @@ def test_calculate_priority_with_overlap() -> None: """Test that having overlap warnings yields HIGH priority.""" role = { "confidence": {"level": "high"}, - "overlapWarnings": ["Melodic overlap"], + "overlapWarnings": [ + "The low register is crowded between Bass Guitar and accompaniment. " + "Thin one part in this section so players can hear their cue." + ], "manualOverrides": [], "setupNote": "", } diff --git a/services/analysis-engine/tests/test_register_overlap.py b/services/analysis-engine/tests/test_register_overlap.py index ce7b20461..90f522b1e 100644 --- a/services/analysis-engine/tests/test_register_overlap.py +++ b/services/analysis-engine/tests/test_register_overlap.py @@ -11,12 +11,16 @@ from typing import Any import numpy as np +import pytest from numpy.typing import NDArray +from bandscope_analysis.roles import overlap as overlap_module from bandscope_analysis.roles.overlap import ( BANDS, band_energy_profile, detect_register_overlap, + format_overlap_warnings, + slice_stems_to_window, ) SR = 22050 @@ -68,6 +72,42 @@ def test_invalid_sample_rate_returns_all_zero(self) -> None: profile = band_energy_profile(_sine(80.0), 0) assert profile == {"low": 0.0, "mid": 0.0, "high": 0.0} + def test_feature_does_not_invent_audio_sample_budget( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Leave audio-size admission to the canonical orchestration policy.""" + + class PolicyOwnedAudio(np.ndarray): + """Expose a policy-sized logical count without allocating that many samples.""" + + @property + def size(self) -> int: + """Return a logical size above the removed feature-local threshold.""" + return 100_000_001 + + audio = np.array([1.0], dtype=np.float64).view(PolicyOwnedAudio) + fft_called = False + + def fake_rfft(values: np.ndarray) -> np.ndarray: + """Prove the feature reaches DSP instead of applying its own admission cap.""" + nonlocal fft_called + fft_called = True + assert values.shape == (1,) + return np.array([1.0], dtype=np.float64) + + monkeypatch.setattr(np.fft, "rfft", fake_rfft) + monkeypatch.setattr( + np.fft, + "rfftfreq", + lambda _count, d: np.array([100.0 if d > 0 else 0.0], dtype=np.float64), + ) + + profile = band_energy_profile(audio, SR) + + assert fft_called + assert profile == {"low": 1.0, "mid": 0.0, "high": 0.0} + class TestDetectRegisterOverlap: """Tests for detect_register_overlap.""" @@ -110,6 +150,28 @@ def test_single_pitched_stem_returns_empty(self) -> None: stems = {"bass": _sine(80.0), "drums": _sine(200.0)} assert detect_register_overlap(stems, SR) == [] + def test_feature_does_not_invent_stem_count_budget( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Leave per-job admission limits to the canonical orchestration policy.""" + tiny = np.array([0.0], dtype=np.float64) + stems = {f"stem_{index}": tiny for index in range(101)} + profiled: list[str] = [] + + def fake_profile(_audio: np.ndarray, _sr: int) -> dict[str, float]: + """Return one active register without doing FFT work.""" + profiled.append("stem") + return {"low": 1.0, "mid": 0.0, "high": 0.0} + + monkeypatch.setattr(overlap_module, "band_energy_profile", fake_profile) + + overlaps = detect_register_overlap(stems, SR) + + assert len(profiled) == 101 + assert len(overlaps) == 101 * 100 // 2 + assert all(overlap["band"] == "low" for overlap in overlaps) + def test_pairs_alphabetical_and_sorted_by_severity(self) -> None: """Overlaps are alphabetically paired and sorted by severity desc.""" stems = { @@ -135,6 +197,18 @@ def test_multiple_overlaps_sorted_by_severity_descending(self) -> None: assert all(a < b for a, b in pairs) assert ("bass", "vocals") in pairs + def test_equal_severity_keeps_declared_band_order(self) -> None: + """Optimization must preserve the historical band order for severity ties.""" + broadband = _sine(100.0) + _sine(500.0) + _sine(3000.0) + overlaps = detect_register_overlap( + {"bass": broadband, "other": broadband.copy()}, + SR, + threshold=0.2, + ) + + assert [overlap["band"] for overlap in overlaps] == list(BANDS) + assert len({overlap["severity"] for overlap in overlaps}) == 1 + def test_malformed_stem_values_fail_safe(self) -> None: """Non-array stem values are treated as silent, not raised.""" stems: dict[str, Any] = {"bass": None, "other": _sine(80.0)} @@ -149,3 +223,94 @@ def test_threshold_is_respected(self) -> None: # The same stems overlap when the threshold is lowered. lowered = detect_register_overlap(stems, SR, threshold=0.2) assert lowered and lowered[0]["band"] in BANDS + + +class TestSliceStemsToWindow: + """Tests for section-windowed stem slicing.""" + + def test_window_keeps_only_the_requested_seconds(self) -> None: + """A one-second window returns that many samples at the source rate.""" + audio = np.arange(SR * 2, dtype=np.float64) + windowed = slice_stems_to_window({"bass": audio}, 1.0, 2.0, SR) + + assert windowed["bass"].tolist() == audio[SR:].tolist() + + def test_invalid_window_returns_empty_arrays(self) -> None: + """Inverted, empty, or non-positive-rate windows fail closed.""" + audio = np.ones(SR, dtype=np.float64) + + assert slice_stems_to_window({"bass": audio}, 1.0, 0.5, SR)["bass"].size == 0 + assert slice_stems_to_window({"bass": audio}, 0.0, 1.0, 0)["bass"].size == 0 + assert slice_stems_to_window({"bass": None}, 0.0, 1.0, SR)["bass"].size == 0 + assert slice_stems_to_window({"bass": audio}, float("nan"), 1.0, SR)["bass"].size == 0 + assert ( + slice_stems_to_window({"bass": np.array([], dtype=np.float64)}, 0.0, 1.0, SR)[ + "bass" + ].size + == 0 + ) + assert slice_stems_to_window({"bass": audio}, 8.0, 9.0, SR)["bass"].size == 0 + assert slice_stems_to_window({"bass": audio}, 0.4, 0.6, 1)["bass"].size == 0 + + +class TestFormatOverlapWarnings: + """Tests for rehearsal-facing overlap copy.""" + + def test_pair_warning_is_attached_only_to_unambiguous_role_identity(self) -> None: + """Bass/other overlap warns bass without inventing a specific accompaniment role.""" + warnings = format_overlap_warnings( + [ + { + "stem_a": "bass", + "stem_b": "other", + "band": "low", + "severity": 0.91, + } + ] + ) + + expected = ( + "The low register is crowded between Bass Guitar and accompaniment. " + "Thin one part in this section so players can hear their cue." + ) + assert warnings == {"bass-guitar": [expected]} + + def test_unknown_stems_and_empty_input_fail_closed(self) -> None: + """Unknown names and empty overlap lists produce no role warnings.""" + assert format_overlap_warnings([]) == {} + assert ( + format_overlap_warnings( + [{"stem_a": "synth", "stem_b": "pad", "band": "mid", "severity": 0.8}] + ) + == {} + ) + + def test_display_only_stem_does_not_raise_when_role_map_omits_it( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A later display label must not KeyError if it has no role authority.""" + monkeypatch.setitem(overlap_module._STEM_DISPLAY_NAMES, "synth", "Synth") + warnings = format_overlap_warnings( + [{"stem_a": "bass", "stem_b": "synth", "band": "low", "severity": 0.8}] + ) + expected = ( + "The low register is crowded between Bass Guitar and Synth. " + "Thin one part in this section so players can hear their cue." + ) + assert warnings == {"bass-guitar": [expected]} + + def test_duplicate_records_and_vocal_pairs_dedupe(self) -> None: + """Repeated records stay one warning and mixed accompaniment stays ambiguous.""" + record = { + "stem_a": "other", + "stem_b": "vocals", + "band": "mid", + "severity": 0.7, + } + warnings = format_overlap_warnings([record, record.copy()]) + expected = ( + "The mid register is crowded between accompaniment and Lead Vocal. " + "Thin one part in this section so players can hear their cue." + ) + assert warnings == {"lead-vocal": [expected]} diff --git a/services/analysis-engine/tests/test_register_overlap_identity_contract.py b/services/analysis-engine/tests/test_register_overlap_identity_contract.py new file mode 100644 index 000000000..297a8c221 --- /dev/null +++ b/services/analysis-engine/tests/test_register_overlap_identity_contract.py @@ -0,0 +1,49 @@ +"""Identity-safety regressions for rehearsal register-overlap warnings. + +The four-stem separator's ``other`` stem is mixed accompaniment evidence. It +cannot identify which keyboard or guitar role caused the overlap, so warnings +may guide the unambiguous stem-side role without assigning the same evidence to +specific accompaniment roles. +""" + +from bandscope_analysis.roles.overlap import format_overlap_warnings + + +def test_mixed_accompaniment_overlap_warns_only_unambiguous_bass_role() -> None: + """Do not project mixed ``other`` evidence onto named accompaniment roles.""" + warnings = format_overlap_warnings( + [ + { + "stem_a": "bass", + "stem_b": "other", + "band": "low", + "severity": 0.91, + } + ] + ) + + expected = ( + "The low register is crowded between Bass Guitar and accompaniment. " + "Thin one part in this section so players can hear their cue." + ) + assert warnings == {"bass-guitar": [expected]} + + +def test_mixed_accompaniment_overlap_warns_only_unambiguous_vocal_role() -> None: + """Lead-vocal evidence stays actionable without inventing a keyboard identity.""" + warnings = format_overlap_warnings( + [ + { + "stem_a": "other", + "stem_b": "vocals", + "band": "mid", + "severity": 0.77, + } + ] + ) + + expected = ( + "The mid register is crowded between accompaniment and Lead Vocal. " + "Thin one part in this section so players can hear their cue." + ) + assert warnings == {"lead-vocal": [expected]} diff --git a/services/analysis-engine/tests/test_register_overlap_threshold_contract.py b/services/analysis-engine/tests/test_register_overlap_threshold_contract.py new file mode 100644 index 000000000..3cd43d0aa --- /dev/null +++ b/services/analysis-engine/tests/test_register_overlap_threshold_contract.py @@ -0,0 +1,41 @@ +"""Threshold safety regressions for register-overlap detection.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.roles.overlap import detect_register_overlap + + +@pytest.mark.parametrize("threshold", [0.0, -0.1, float("-inf")]) +def test_silent_stems_never_become_overlap_evidence_at_nonpositive_thresholds( + threshold: float, +) -> None: + """Silent stems must not fabricate rehearsal warnings under edge thresholds.""" + silent = np.zeros(64, dtype=np.float64) + + assert ( + detect_register_overlap( + {"bass": silent, "other": silent.copy()}, + 22_050, + threshold=threshold, + ) + == [] + ) + + +def test_boolean_threshold_fails_closed_instead_of_acting_like_one() -> None: + """Boolean configuration must not be coerced into a 100% overlap threshold.""" + sample_count = 2_205 + timeline = np.arange(sample_count, dtype=np.float64) / 22_050 + tone = np.sin(2.0 * np.pi * 100.0 * timeline) + + assert ( + detect_register_overlap( + {"bass": tone, "other": tone.copy()}, + 22_050, + threshold=True, + ) + == [] + ) diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 45a2ddada..1d5b8582b 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -10,6 +10,7 @@ RehearsalPriority, RoleType, ) +from bandscope_analysis.roles.overlap import band_energy_profile def test_role_type_enum() -> None: @@ -56,7 +57,7 @@ def test_role_extractor_basic() -> None: assert "keys-right" in roles_by_id assert "keys-left" in roles_by_id assert roles_by_id["lead-vocal"]["roleType"] == "vocal" - assert "Melodic overlap" in roles_by_id["lead-vocal"]["overlapWarnings"][0] + assert roles_by_id["lead-vocal"]["overlapWarnings"] == [] intro_graph = intro_topology["part_graph"] graph_by_role = {n["role_id"]: n for n in intro_graph} @@ -71,8 +72,8 @@ def test_role_extractor_basic() -> None: assert len(verse_topology["active_roles"]) == 2 assert verse_topology["active_roles"][0]["id"] == "bass-guitar" assert verse_topology["active_roles"][0]["roleType"] == "instrument" - assert verse_topology["active_roles"][0]["rehearsalPriority"] == "high" - assert "Density warning" in verse_topology["active_roles"][0]["overlapWarnings"][0] + assert verse_topology["active_roles"][0]["rehearsalPriority"] == "medium" + assert verse_topology["active_roles"][0]["overlapWarnings"] == [] verse_graph = verse_topology["part_graph"] assert len(verse_graph) == 5 @@ -133,3 +134,156 @@ def test_role_extractor_falls_back_when_activity_detection_fails() -> None: assert result["topologies"][0]["section_id"] == "verse-1" assert result["topologies"][0]["part_graph"][0]["role_id"] == "bass-guitar" + + +def _tone(freq: float, seconds: float, sr: int) -> np.ndarray: + """Build a deterministic mono sine used as a known-register fixture. + + Args: + freq: Tone frequency in Hz. + seconds: Duration of the tone. + sr: Sample rate in Hz. + + Returns: + Mono float64 sine wave of the requested duration. + """ + sample_count = int(sr * seconds) + timeline = np.arange(sample_count, dtype=np.float64) / sr + return np.sin(2.0 * np.pi * freq * timeline) + + +def test_role_extractor_uses_measured_register_overlap_per_section() -> None: + """Measured low-register clash appears only in the section that contains it.""" + extractor = RoleExtractor() + sample_rate = 22_050 + crowded = _tone(80.0, 1.0, sample_rate) + separated = _tone(1000.0, 1.0, sample_rate) + audio_features = { + "stems": { + "bass": np.concatenate([crowded, crowded]), + "other": np.concatenate([crowded, separated]), + }, + "sr": sample_rate, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + } + + verse_profile = band_energy_profile(crowded, sample_rate) + chorus_profile = band_energy_profile(separated, sample_rate) + ideal_low = {"low": 1.0, "mid": 0.0, "high": 0.0} + ideal_mid = {"low": 0.0, "mid": 1.0, "high": 0.0} + verse_rmse = ( + sum((verse_profile[band] - ideal_low[band]) ** 2 for band in ideal_low) / 3 + ) ** 0.5 + chorus_rmse = ( + sum((chorus_profile[band] - ideal_mid[band]) ** 2 for band in ideal_mid) / 3 + ) ** 0.5 + assert verse_rmse < 1e-6 + assert chorus_rmse < 1e-6 + + result = extractor.extract([{"id": "verse-1"}, {"id": "chorus-1"}], audio_features) + + verse_roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + chorus_roles = {role["id"]: role for role in result["topologies"][1]["active_roles"]} + + expected = ( + "The low register is crowded between Bass Guitar and accompaniment. " + "Thin one part in this section so players can hear their cue." + ) + assert verse_roles["bass-guitar"]["overlapWarnings"] == [expected] + assert verse_roles["bass-guitar"]["rehearsalPriority"] == "high" + assert "keys-left" not in verse_roles + assert "keys-right" not in verse_roles + assert "acoustic-guitar" not in verse_roles + verse_graph = {node["role_id"]: node for node in result["topologies"][0]["part_graph"]} + assert verse_graph["keys-left"]["is_active"] is False + assert verse_graph["keys-right"]["is_active"] is False + assert verse_graph["acoustic-guitar"]["is_active"] is False + assert chorus_roles["bass-guitar"]["overlapWarnings"] == [] + assert chorus_roles["bass-guitar"]["rehearsalPriority"] == "medium" + assert "keys-left" not in chorus_roles + + +def test_role_extractor_omits_warnings_when_section_windows_are_missing() -> None: + """Stems without matching section windows must not emit a song-wide clash.""" + extractor = RoleExtractor() + sample_rate = 22_050 + crowded = _tone(80.0, 1.0, sample_rate) + audio_features = { + "stems": {"bass": crowded, "other": crowded.copy()}, + "sr": sample_rate, + } + + result = extractor.extract([{"id": "verse-1"}, {"id": "chorus-1"}], audio_features) + + assert all( + role["overlapWarnings"] == [] + for topology in result["topologies"] + for role in topology["active_roles"] + ) + + +def test_role_extractor_omits_warnings_when_boundary_count_mismatches_sections() -> None: + """A partial boundary list is not enough evidence to measure any section.""" + extractor = RoleExtractor() + sample_rate = 22_050 + crowded = _tone(80.0, 2.0, sample_rate) + audio_features = { + "stems": {"bass": crowded, "other": crowded.copy()}, + "sr": sample_rate, + "boundaries": [(0.0, 1.0)], + } + + result = extractor.extract([{"id": "verse-1"}, {"id": "chorus-1"}], audio_features) + + assert all( + role["overlapWarnings"] == [] + for topology in result["topologies"] + for role in topology["active_roles"] + ) + + +def test_role_extractor_keeps_mixed_vocal_overlap_off_named_accompaniment_roles() -> None: + """other + vocals may warn lead vocal only; keyboard identity stays unclaimed.""" + extractor = RoleExtractor() + sample_rate = 22_050 + mid_tone = _tone(1000.0, 1.0, sample_rate) + audio_features = { + "stems": {"vocals": mid_tone, "other": mid_tone.copy()}, + "sr": sample_rate, + "boundaries": [(0.0, 1.0)], + } + + result = extractor.extract([{"id": "chorus-1"}], audio_features) + roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + + expected = ( + "The mid register is crowded between accompaniment and Lead Vocal. " + "Thin one part in this section so players can hear their cue." + ) + assert roles["lead-vocal"]["overlapWarnings"] == [expected] + assert "keys-left" not in roles + assert "keys-right" not in roles + assert "acoustic-guitar" not in roles + chorus_graph = {node["role_id"]: node for node in result["topologies"][0]["part_graph"]} + assert chorus_graph["keys-left"]["is_active"] is False + assert chorus_graph["keys-right"]["is_active"] is False + assert chorus_graph["acoustic-guitar"]["is_active"] is False + + +def test_role_extractor_omits_warnings_when_overlap_mapping_fails() -> None: + """Overlap mapping failures must not invent density copy or abort extraction.""" + extractor = RoleExtractor() + audio_features = { + "stems": {"bass": _tone(80.0, 0.5, 22_050), "other": _tone(80.0, 0.5, 22_050)}, + "sr": 22_050, + "boundaries": [(0.0, 0.5)], + } + + with patch( + "bandscope_analysis.roles.extractor.detect_register_overlap", + side_effect=RuntimeError("overlap mapping exploded"), + ): + result = extractor.extract([{"id": "verse-1"}], audio_features) + + assert result["topologies"][0]["active_roles"] + assert all(role["overlapWarnings"] == [] for role in result["topologies"][0]["active_roles"])