Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/backend/JSON_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Current server behavior that affects schema expectations:
| `timeSignature` | `string \| null` | Time signature estimate (currently defaults to `"4/4"` when rhythm exists). | string | Treat as prior; verify manually on odd-metre material. |
| `timeSignatureSource` | `string \| null` | Provenance marker for the raw `timeSignature` value. Current analyzer emits `"assumed_four_four"` when rhythm data exists. | categorical | Forwarded through HTTP `phase1`; use it to distinguish measured vs assumed meter. |
| `timeSignatureConfidence` | `float \| null` | Confidence attached to the raw `timeSignature` value. Current analyzer emits `0.0` for the assumed 4/4 fallback. | 0-1 | Forwarded through HTTP `phase1`; use low values to avoid overstating meter certainty. |
| `timeSignatureCandidates` | `array` | **Full mode only.** Per-candidate meter evidence from the onset-accent autocorrelation, strongest first: `{timeSignature, dominance, positionMeans[]}` for each scoreable bar length (3/4, 4/4, 5/4, 6/8, 7/8). `dominance` = mean accent at bar position 1 over the mean of the other positions; `positionMeans` = the per-position onset-count means the dominance was computed from. Empty `[]` on every fallback branch. | list | Additive evidence surface (accuracy program PR-B1): meter improvements and `fundamentalsQuality.domains.meter.evidence` read it instead of recomputing; downstream must never treat a candidate as an override of `timeSignature`. |
| `timeSignatureCandidates` | `array` | **Full mode only.** Per-candidate meter evidence from the onset-accent autocorrelation, strongest first: `{timeSignature, dominance, loudnessDominance, positionMeans[]}` for each scoreable bar length (3/4, 4/4, 5/4, 6/8, 7/8). `dominance` = mean onset count at bar position 1 over the mean of the other positions; `loudnessDominance` (PR-G4) = the per-beat low-band loudness folded at this bar length, maximized over phase and clamped to 10 (1.0 = neutral; harmonic folds — e.g. a 3-periodic accent scored at bar 6 — are neutralized); `positionMeans` = the per-position onset-count means. The shipped `timeSignature` decision runs on `dominance x loudnessDominance` with a 15% override margin when loudness contributed (20% count-only otherwise). Empty `[]` on every fallback branch. | list | Evidence surface (accuracy programs PR-B1/PR-G4): meter improvements and `fundamentalsQuality.domains.meter.evidence` read it instead of recomputing; downstream must never treat a candidate as an override of `timeSignature`. |
| `durationSeconds` | `float \| null` | Track duration from sample count. | seconds | Useful for arrangement section planning and timeline mapping. |
| `sampleRate` | `int \| null` | Effective analysis sample rate. | Hz | Ensures downstream feature interpretation uses correct temporal/frequency scaling. |
| `keyProfile` | `string \| null` | Key profile used by `KeyExtractor` (e.g. `"edma"`). | categorical | Indicates which pitch template corpus was used for key detection. |
Expand Down
16 changes: 11 additions & 5 deletions apps/backend/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,7 +1591,15 @@ def main():
# (accuracy PR-G3). Never overrides bpm — see analyze_bpm_octave_evidence.
result.update(analyze_bpm_octave_evidence(mono, sample_rate, result.get("bpm")))
result.update(analyze_key(mono))
result.update(analyze_time_signature(rhythm_data, mono=mono, sample_rate=sample_rate))
# Shared beat-domain loudness data, extracted once and reused by meter
# detection (PR-G4 loudness-accent stream), rhythm detail, groove, and
# sidechain below. Hoisted above analyze_time_signature deliberately.
beat_data = _extract_beat_loudness_data(mono, sample_rate, rhythm_data)
result.update(
analyze_time_signature(
rhythm_data, mono=mono, sample_rate=sample_rate, beat_data=beat_data
)
)
result.update(analyze_duration_and_sr(mono, sample_rate))

# LUFS + LRA (needs stereo at its native sample rate — load_stereo does
Expand Down Expand Up @@ -1647,11 +1655,9 @@ def main():
)
result.update(analyze_plr(result.get("lufsIntegrated"), result.get("truePeak")))

# Shared beat-domain loudness data used by rhythm detail + groove + sidechain.
beat_data = _extract_beat_loudness_data(mono, sample_rate, rhythm_data)

# Rhythm detail — real meter-aware downbeats derived from the per-beat
# kick-accent pattern (beat_data) and the detected time signature.
# kick-accent pattern (beat_data, extracted above) and the detected
# time signature.
result.update(
analyze_rhythm_detail(
mono,
Expand Down
140 changes: 129 additions & 11 deletions apps/backend/analyze_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1287,12 +1287,93 @@ def analyze_duration_and_sr(mono: np.ndarray, sample_rate: int = 44100) -> dict:
_TIME_SIG_BAR_CANDIDATES = (3, 4, 5, 6, 7)
_TIME_SIG_LABELS = {3: "3/4", 4: "4/4", 5: "5/4", 6: "6/8", 7: "7/8"}
_TIME_SIG_MARGIN_THRESHOLD = 0.20 # winner must beat 4/4 by 20% to override
# With the loudness-accent stream contributing (PR-G4), the combined evidence
# is richer than onset counts alone, so the override margin relaxes slightly.
# Calibrated on the synthetic corpus: 6/8's measured combined margin is ~17%,
# and every true-4/4 clip wins outright (no margin test at all).
_TIME_SIG_ACCENT_MARGIN_THRESHOLD = 0.15
_TIME_SIG_LOUDNESS_MIN_BARS = 4 # folds with fewer bars are noise, stay neutral
_TIME_SIG_LOUDNESS_DOMINANCE_CAP = 10.0 # near-silent off-beat positions explode the ratio


def _loudness_meter_dominance(low_band: np.ndarray | None, bar_length: int) -> float:
"""Downbeat dominance of the per-beat low-band loudness folded at bar_length.

The onset-count accent stream is loudness-blind: a kick on every beat
yields identical counts at every bar position no matter which beat is
accented (the measured failure on the odd-meter corpus clips). Folding
the kick-band loudness recovers the accent. Maximized over bar phase
(unlike the count stream, which trusts tick 1) because the loudness
fold is about periodicity, not phase — the downbeat phase is resolved
separately by ``_compute_downbeat_phase``.

Returns 1.0 (neutral — no evidence either way) when the signal is
missing, too short (< _TIME_SIG_LOUDNESS_MIN_BARS bars), or degenerate.
Clamped to _TIME_SIG_LOUDNESS_DOMINANCE_CAP: a near-silent off-beat
position (broken-kick patterns) makes the ratio arbitrarily large
without carrying more meter information than "clearly dominant".
"""
if low_band is None:
return 1.0
arr = np.asarray(low_band, dtype=np.float64)
arr = np.where(np.isfinite(arr), arr, 0.0)
n_bars = arr.size // bar_length
if bar_length < 2 or n_bars < _TIME_SIG_LOUDNESS_MIN_BARS:
return 1.0
folded = arr[: n_bars * bar_length].reshape(n_bars, bar_length).mean(axis=0)
best = 1.0
for position in range(bar_length):
others_mean = float(np.mean(np.delete(folded, position)))
if others_mean <= 0.0:
# Off-positions are dead silent: maximal dominance if this
# position carries anything at all (synthetic-clean case).
if float(folded[position]) > 0.0:
best = _TIME_SIG_LOUDNESS_DOMINANCE_CAP
continue
best = max(best, float(folded[position]) / others_mean)
return float(min(best, _TIME_SIG_LOUDNESS_DOMINANCE_CAP))


_TIME_SIG_HARMONIC_ECHO_PROMINENCE = 0.15


def _is_harmonic_of_shorter_bar(
low_band: np.ndarray | None, bar_length: int, divisor: int
) -> bool:
"""True when the accent profile at bar_length is really divisor-periodic.

A 3/4 accent pattern folded at 6 repeats its accent at the divisor
offset — the fold scores high but describes the shorter bar twice, not
a 6-beat bar. A genuine 6/8 fold accents one position only. The test is
the echo's PROMINENCE — how far the position at (peak + divisor) rises
above the remaining positions, relative to the peak's own rise — not
its raw ratio to the peak: baseline noise puts echo/peak near 0.7 for
BOTH cases, while prominence measures 0.26 (true 3/4) vs 0.00 (true
6/8) on the corpus clips.
"""
if low_band is None or divisor <= 1 or bar_length % divisor != 0:
return False
arr = np.asarray(low_band, dtype=np.float64)
arr = np.where(np.isfinite(arr), arr, 0.0)
n_bars = arr.size // bar_length
if n_bars < _TIME_SIG_LOUDNESS_MIN_BARS:
return False
folded = arr[: n_bars * bar_length].reshape(n_bars, bar_length).mean(axis=0)
peak_position = int(np.argmax(folded))
echo_position = (peak_position + divisor) % bar_length
baseline = float(np.mean(np.delete(folded, [peak_position, echo_position])))
peak_rise = float(folded[peak_position]) - baseline
if peak_rise <= 0.0:
return False
echo_rise = float(folded[echo_position]) - baseline
return echo_rise / peak_rise >= _TIME_SIG_HARMONIC_ECHO_PROMINENCE


def analyze_time_signature(
rhythm_data: dict | None,
mono: np.ndarray | None = None,
sample_rate: int = 44100,
beat_data: dict | None = None,
) -> dict:
"""Phase 1.C #0 — onset-accent autocorrelation for meter detection.

Expand All @@ -1305,8 +1386,18 @@ def analyze_time_signature(
3. For each candidate bar length B in (3, 4, 5, 6, 7), reshape the
per-beat onset counts into bars of B and compute "downbeat
dominance" = mean accent at position 1 / mean accent at positions 2..B.
4. The candidate with the highest dominance wins, but only overrides 4/4
when it beats 4/4's dominance by ``_TIME_SIG_MARGIN_THRESHOLD`` (20%).
4. (PR-G4) When ``beat_data`` is provided, fold its per-beat low-band
loudness at each B for a second, loudness-aware accent stream
(``_loudness_meter_dominance``) — onset counts alone are blind to a
kick that plays every beat but accents the downbeat, the measured
odd-meter failure. Harmonics collapse (a 3-periodic accent folded at
6 scores high but is not a 6-beat bar), and the decision runs on the
product of both streams.
5. The candidate with the highest combined score wins, but only
overrides 4/4 by ``_TIME_SIG_ACCENT_MARGIN_THRESHOLD`` (15%) when the
loudness stream contributed, or the original
``_TIME_SIG_MARGIN_THRESHOLD`` (20%) count-only margin otherwise —
legacy callers without beat_data (including --fast) are byte-identical.

Falls back cleanly to the previous "assumed 4/4" behavior when:
- rhythm_data is missing
Expand All @@ -1315,10 +1406,11 @@ def analyze_time_signature(
- onset detection fails

Also emits ``timeSignatureCandidates`` — the per-candidate accent evidence
(dominance + per-bar-position onset means) that was previously computed
and discarded. Additive, full-mode-only at the top level; downstream meter
work (and fundamentalsQuality's meter evidence) reads it instead of
recomputing. Empty list on every fallback branch.
(dominance + per-bar-position onset means, plus ``loudnessDominance``)
that was previously computed and discarded. Additive, full-mode-only at
the top level; downstream meter work (and fundamentalsQuality's meter
evidence) reads it instead of recomputing. Empty list on every fallback
branch.
"""

def _result(
Expand Down Expand Up @@ -1401,19 +1493,40 @@ def _result(
# We couldn't even score 4/4 — fall back to the assumption.
return _result("4/4", "assumed_four_four", 0.0)

# PR-G4: fold the per-beat low-band loudness (when the caller has it)
# into a second accent stream. Harmonic folds collapse to neutral —
# their evidence belongs to the shorter bar they repeat.
low_band = (
np.asarray(beat_data.get("lowBand", []), dtype=np.float64)
if isinstance(beat_data, dict)
else None
)
loudness_dominance: dict[int, float] = {}
for B in scores:
dominance = _loudness_meter_dominance(low_band, B)
if dominance > 1.0 and any(
d in scores and d < B and B % d == 0 and _is_harmonic_of_shorter_bar(low_band, B, d)
for d in scores
):
dominance = 1.0
loudness_dominance[B] = dominance
loudness_contributed = any(v != 1.0 for v in loudness_dominance.values())
combined = {B: scores[B] * loudness_dominance[B] for B in scores}

# Surface the previously discarded evidence, strongest first.
candidates = [
{
"timeSignature": _TIME_SIG_LABELS.get(B, f"{B}/4"),
"dominance": round(float(scores[B]), 3),
"loudnessDominance": round(float(loudness_dominance[B]), 3),
"positionMeans": per_position_means[B],
}
for B in sorted(scores, key=scores.get, reverse=True)
for B in sorted(combined, key=combined.get, reverse=True)
]

baseline = scores[4]
best_B = max(scores, key=scores.get)
best_score = scores[best_B]
baseline = combined[4]
best_B = max(combined, key=combined.get)
best_score = combined[best_B]
winner_label = _TIME_SIG_LABELS.get(best_B, "4/4")

if best_B == 4:
Expand All @@ -1422,8 +1535,13 @@ def _result(
return _result("4/4", "onset_autocorrelation", round(float(confidence), 2), candidates)

# Non-4/4 candidate won. Require a margin over 4/4 to override.
margin_threshold = (
_TIME_SIG_ACCENT_MARGIN_THRESHOLD
if loudness_contributed
else _TIME_SIG_MARGIN_THRESHOLD
)
margin = (best_score - baseline) / max(baseline, 0.1)
if margin < _TIME_SIG_MARGIN_THRESHOLD:
if margin < margin_threshold:
confidence = max(0.0, min(1.0, (baseline - 1.0) / 2.0))
return _result("4/4", "onset_autocorrelation_low_margin", round(float(confidence), 2), candidates)

Expand Down
15 changes: 6 additions & 9 deletions apps/backend/scripts/build_synthetic_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,15 +538,12 @@ def _render_spec(spec: dict[str, Any]) -> RenderedClip:
# upgrades land. Only checks that actually fail at baseline belong here; the
# rest of each clip's checks gate normally.
_KNOWN_GAPS_BY_ID: dict[str, list[str]] = {
# analyze_time_signature's 20%-margin conservatism reads every odd meter
# as 4/4, and the bar-1 phase depends on the meter, so downbeats follow.
"grid_3_4_90": ["meter:timeSignature", "downbeats:f1"],
"grid_6_8_110": ["meter:timeSignature", "downbeats:f1"],
# 7/8 additionally confuses the tempo estimate (142.6 vs 140). The
# octave-evidence check also stays informational here: the shipped bpm
# is smeared (a meter artifact, PR-G4's problem), not octave-wrong, so
# no simple ratio of 142.6 can land on 140.
"grid_7_8_140": ["meter:timeSignature", "downbeats:f1", "tempo:bpm", "tempoOctave:preferredBpm"],
# PR-G4 (loudness-accent meter stream) closed the odd-meter meter and
# downbeat gaps on 3/4, 6/8, and 7/8. What remains on 7/8 is the tempo
# smear (142.6 vs 140 — a beat-tracker artifact on odd meters, not a
# meter read), which also keeps the octave-evidence check informational:
# no simple ratio of 142.6 lands on 140.
"grid_7_8_140": ["tempo:bpm", "tempoOctave:preferredBpm"],
# RhythmExtractor halves 174 BPM to 86.9 (octave preference); the beat
# grid and downbeats still score >= 0.87 against truth.
"grid_4_4_174": ["tempo:bpm"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,7 @@
"beatF1": 0.9,
"downbeatF1": 0.75,
"octaveTolerance": 2.0
},
"knownGaps": [
"meter:timeSignature",
"downbeats:f1"
]
}
},
{
"id": "grid_6_8_110",
Expand Down Expand Up @@ -474,11 +470,7 @@
"beatF1": 0.9,
"downbeatF1": 0.75,
"octaveTolerance": 2.0
},
"knownGaps": [
"meter:timeSignature",
"downbeats:f1"
]
}
},
{
"id": "grid_4_4_128",
Expand Down Expand Up @@ -626,8 +618,6 @@
"octaveTolerance": 2.0
},
"knownGaps": [
"meter:timeSignature",
"downbeats:f1",
"tempo:bpm",
"tempoOctave:preferredBpm"
]
Expand Down
61 changes: 61 additions & 0 deletions apps/backend/tests/test_time_signature_accent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import unittest

import numpy as np

from analyze_core import (
_is_harmonic_of_shorter_bar,
_loudness_meter_dominance,
analyze_time_signature,
)


def _accented(bar: list[float], bars: int) -> np.ndarray:
return np.asarray(bar * bars, dtype=np.float64)


class LoudnessMeterDominanceTests(unittest.TestCase):
def test_accented_downbeat_dominates_at_true_bar_length(self) -> None:
low = _accented([1.0, 0.8, 0.8], 8) # 3/4, kick every beat, louder downbeat
self.assertAlmostEqual(_loudness_meter_dominance(low, 3), 1.25, places=3)
# Folded at 4 the accent smears across positions.
self.assertLess(_loudness_meter_dominance(low, 4), 1.15)

def test_flat_signal_is_neutral(self) -> None:
low = np.full(32, 0.9)
for bar_length in (3, 4, 5, 6, 7):
self.assertAlmostEqual(_loudness_meter_dominance(low, bar_length), 1.0, places=3)

def test_too_few_bars_is_neutral(self) -> None:
low = _accented([1.0, 0.8, 0.8, 0.8, 0.8, 0.8], 3) # 3 bars < min 4
self.assertEqual(_loudness_meter_dominance(low, 6), 1.0)

def test_missing_signal_is_neutral(self) -> None:
self.assertEqual(_loudness_meter_dominance(None, 4), 1.0)

def test_near_silent_offbeats_are_capped(self) -> None:
low = _accented([1.0, 0.0, 0.0, 0.0], 8) # broken-kick: one hit per bar
self.assertEqual(_loudness_meter_dominance(low, 4), 10.0)


class HarmonicCollapseTests(unittest.TestCase):
def test_three_periodic_accent_collapses_the_six_fold(self) -> None:
low = _accented([1.0, 0.8, 0.8], 8) # true 3/4: fold at 6 shows two peaks
self.assertTrue(_is_harmonic_of_shorter_bar(low, 6, 3))

def test_genuine_six_bar_accent_does_not_collapse(self) -> None:
low = _accented([1.0, 0.8, 0.8, 0.8, 0.8, 0.8], 8) # true 6/8: one peak
self.assertFalse(_is_harmonic_of_shorter_bar(low, 6, 3))


class AnalyzeTimeSignatureBeatDataTests(unittest.TestCase):
def test_without_beat_data_behavior_is_count_only(self) -> None:
# Legacy/--fast callers pass no beat_data: the loudness stream must
# be absent from the decision (all-neutral) and the 20% count-only
# margin applies. rhythm_data=None hits the earliest fallback.
result = analyze_time_signature(None)
self.assertIsNone(result["timeSignature"])
self.assertEqual(result["timeSignatureCandidates"], [])


if __name__ == "__main__":
unittest.main()
4 changes: 3 additions & 1 deletion apps/ui/src/services/backendPhase1Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,9 @@ function parseOptionalTimeSignatureCandidates(value: unknown): TimeSignatureCand
const positionMeans = Array.isArray(entry.positionMeans)
? entry.positionMeans.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
: [];
candidates.push({ timeSignature, dominance, positionMeans });
// Optional (PR-G4): absent on pre-G4 snapshots — forward as null.
const loudnessDominance = toNumber(entry.loudnessDominance);
candidates.push({ timeSignature, dominance, positionMeans, loudnessDominance });
}
return candidates;
}
Expand Down
5 changes: 5 additions & 0 deletions apps/ui/src/types/measurement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,11 @@ export interface TimeSignatureCandidate {
timeSignature: string;
dominance: number;
positionMeans: number[];
/**
* Low-band loudness-accent dominance at this bar length (PR-G4), folded
* over bar phase. 1.0 = neutral/no evidence; null on pre-G4 snapshots.
*/
loudnessDominance?: number | null;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions apps/ui/tests/fixtures/phase1FullPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ export const phase1EnvelopeFixture = {
timeSignatureSource: 'assumed_four_four',
timeSignatureConfidence: 0,
timeSignatureCandidates: [
{ timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5] },
{ timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7, 1.6] },
{ timeSignature: '4/4', dominance: 1.42, positionMeans: [2.1, 1.4, 1.6, 1.5], loudnessDominance: 1.31 },
{ timeSignature: '3/4', dominance: 1.11, positionMeans: [1.9, 1.7, 1.6], loudnessDominance: 1.05 },
],
durationSeconds: 184.2,
sampleRate: 44100,
Expand Down
Loading
Loading