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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Maestro Player 음악 이론 정량 하니스 + 음높이 배선 설계

- 날짜: 2026-08-04
- 상태: 확정 (사용자 "진행" — 검증 방법 제안 ①②를 리스크 낮은 순으로)
- 범위: `player/` 하위만

## 0. 발견된 결함 (이 스펙의 동기)

`replayAudioEngine.createCueFromNote`가 주파수를 **레인 번호 고정 테이블**
([220, 261.63, 329.63, 392, …] ≈ A·C·E·G)에서 만든다. 세션의
`harmony`(tonic/mode)와 `motif.intervals`는 소리에 반영되지 않는 **표시 전용
라벨**이다. 즉 "F dorian" 세션도 항상 같은 Am계 음들만 울린다.

## 1. 정량 하니스 (`src/lib/musicTheory.js` + `tests/musicTheoryHarness.test.mjs`)

이론 테이블과 지표 함수(순수 함수, 픽스처 기반 CI 게이트):

- `MODE_INTERVALS`: harmonyEngine의 7개 선법 → 반음 집합.
- `frequencyToMidi(hz)` / `midiToFrequency(midi)`.
- `scaleConformance(cuePlan, harmony)` → 발음 큐 중 선법 스케일 내 비율 + 위반 목록.
- `beatGridConformance(chart, resolution=0.25)` → 박 그리드 정합 비율.
- `chartMaxNotesPerBeat(chart)` → 밀도 상한 검증용.
- `leapStats(cuePlan)` → 연속 리드 큐의 도약 반음 통계(최대, 옥타브 초과 비율).

하니스 단언 (golden 3픽스처 + transition 픽스처):

- 그리드 정합 = 100%, 밀도 ≤ 2/박.
- **스케일 적합률 ≥ 95%** ← 현재 구조에선 실패(§0 결함의 자동 검출) → §2로 GREEN.
- 도약 옥타브 초과 비율 ≤ 40% (배선 후 실측 캘리브레이션, 회귀 방지 목적).

## 2. 음높이 배선 (결함 수정 = 화음 발성 1단계)

- `chartMapper`: 노트 생성 시 `pitchMidi` 부여 —
`tonicMidi(registerBand: low=36/mid=48/high=60 + tonicIndex) + motif.intervals[noteIndex % n]`
를 선법 스케일에 스냅(최근접 스케일음, 하행 우선). hold 노트도 동일 규칙.
- `replayAudioEngine`: `note.pitchMidi`가 있으면 `midiToFrequency` 사용
(hold는 -12, accent는 +12 옥타브 이동 — 피치 클래스 보존이라 적합률 불변).
없으면 기존 레인 테이블 폴백(하위 호환).
- 결과: 브랜치별 조성·선법·모티프가 실제 소리에 반영 — "fix 브랜치는 dorian,
revert는 phrygian"이 귀로 성립하기 시작한다.

## 3. 비범위

- 화음 동시 발성(코드 컬러 보이싱)·베이스/패드 레이어 — 후속 2단계.
- A/B 블라인드 청취, 실저장소 코퍼스 스모크 — 별도 트랙.

## 4. 게이트

`npm run qa` + `build:extension` + golden fingerprint 결정성 유지
(fingerprint는 재실행 간 비교라 pitch 추가에도 결정적이면 통과).
26 changes: 24 additions & 2 deletions player/src/lib/chartMapper.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { clamp, hashString } from './types.js';
import { snapToScale } from './musicTheory.js';

// registerBand별 tonic 기준 옥타브 (스펙 2026-08-04 §2)
const REGISTER_BASE_MIDI = Object.freeze({ low: 36, mid: 48, high: 60 });

const PATTERN_OFFSETS = Object.freeze({
steady: [0, 0.75, 1.5, 2.25],
Expand All @@ -17,7 +21,7 @@ export function createChartFromMusicPlan(musicPlan, options = {}) {
for (const session of musicPlan) {
for (const intent of session.intents) {
const phraseLength = estimatePhraseLength(intent);
const patternNotes = composeIntentNotes(intent, beatCursor, laneCount, maxNotesPerBeat);
const patternNotes = composeIntentNotes(intent, beatCursor, laneCount, maxNotesPerBeat, session);
notes.push(...patternNotes);
beatCursor += phraseLength;
}
Expand All @@ -39,7 +43,7 @@ export function resolveLaneIndex(intent, laneCount = 4) {
return baseLane;
}

function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat) {
function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat, session = null) {
const lane = resolveLaneIndex(intent, laneCount);
const offsets = PATTERN_OFFSETS[intent.rhythmPattern] || PATTERN_OFFSETS.steady;
const noteBudget = getNoteBudget(intent, maxNotesPerBeat);
Expand All @@ -54,6 +58,7 @@ function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat) {
durationBeats: 2 + Math.round(intent.accentLevel * 2),
noteType: intent.accentLevel >= 0.7 ? 'accent' : 'hold',
eventRef: intent.eventRef,
pitchMidi: computePitchMidi(intent, session, 0),
});
return notes;
}
Expand All @@ -67,12 +72,29 @@ function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat) {
durationBeats: intent.rhythmPattern === 'fill' ? 0.5 : 1,
noteType: pickNoteType(intent, noteIndex),
eventRef: intent.eventRef,
pitchMidi: computePitchMidi(intent, session, noteIndex),
});
}

return notes;
}

// 세션 harmony(조성·선법)와 motif 음정을 실제 음높이로 배선한다 (스펙 §2).
// 세션 정보가 없으면 null — 오디오 엔진이 레거시 레인 주파수로 폴백한다.
function computePitchMidi(intent, session, noteIndex) {
const harmony = session?.harmony;
const motif = session?.motif;
if (!harmony || !motif) {
return null;
}

const baseMidi = (REGISTER_BASE_MIDI[intent.registerBand] ?? REGISTER_BASE_MIDI.mid)
+ (harmony.tonicIndex || 0);
const intervals = motif.intervals?.length ? motif.intervals : [0];
const rawOffset = intervals[(noteIndex + (motif.variation || 0)) % intervals.length];
return baseMidi + snapToScale(rawOffset, harmony.mode);
}

function estimatePhraseLength(intent) {
if (intent.rhythmPattern === 'hold') {
return 3 + Math.round(intent.energy * 2);
Expand Down
107 changes: 107 additions & 0 deletions player/src/lib/musicTheory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// 음악 이론 테이블과 정량 지표 (스펙 2026-08-04 §1).
// 하니스(CI 게이트)와 chartMapper의 음높이 스냅이 공유한다.

export const MODE_INTERVALS = Object.freeze({
ionian: [0, 2, 4, 5, 7, 9, 11],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
aeolian: [0, 2, 3, 5, 7, 8, 10],
'minor-pentatonic': [0, 3, 5, 7, 10],
});

export const NOTE_NAME_TO_PITCH_CLASS = Object.freeze({
C: 0, Db: 1, D: 2, Eb: 3, E: 4, F: 5, Gb: 6, G: 7, Ab: 8, A: 9, Bb: 10, B: 11,
});

export function frequencyToMidi(frequencyHz) {
return Math.round(69 + 12 * Math.log2(frequencyHz / 440));
}

export function midiToFrequency(midi) {
return 440 * 2 ** ((midi - 69) / 12);
}

// tonic 기준 상대 반음을 선법 스케일의 최근접 스케일음으로 스냅한다 (하행 우선).
export function snapToScale(semitoneOffset, mode) {
const scale = MODE_INTERVALS[mode] || MODE_INTERVALS.ionian;
const pitchClass = ((semitoneOffset % 12) + 12) % 12;
if (scale.includes(pitchClass)) {
return semitoneOffset;
}

for (let distance = 1; distance <= 6; distance += 1) {
const down = ((pitchClass - distance) % 12 + 12) % 12;
if (scale.includes(down)) {
return semitoneOffset - distance;
}
const up = (pitchClass + distance) % 12;
if (scale.includes(up)) {
return semitoneOffset + distance;
}
}

return semitoneOffset;
}

export function scaleConformance(cuePlan, harmony) {
const scale = MODE_INTERVALS[harmony?.mode] || MODE_INTERVALS.ionian;
const tonicPitchClass = NOTE_NAME_TO_PITCH_CLASS[harmony?.tonic] ?? 0;
const cues = cuePlan.flatMap((batch) => batch.cues);
const offenders = [];

for (const cue of cues) {
const pitchClass = ((frequencyToMidi(cue.frequencyHz) - tonicPitchClass) % 12 + 12) % 12;
if (!scale.includes(pitchClass)) {
offenders.push({ cueId: cue.cueId, pitchClass });
}
}

return {
total: cues.length,
conformant: cues.length - offenders.length,
ratio: cues.length ? (cues.length - offenders.length) / cues.length : 1,
offenders,
};
}

export function beatGridConformance(chart, resolution = 0.25) {
const notes = chart?.notes || [];
const offenders = notes.filter((note) => {
const steps = note.beatOffset / resolution;
return Math.abs(steps - Math.round(steps)) > 1e-6;
});

return {
total: notes.length,
ratio: notes.length ? (notes.length - offenders.length) / notes.length : 1,
offenders: offenders.map((note) => note.noteId),
};
}

export function chartMaxNotesPerBeat(chart) {
const buckets = new Map();
for (const note of chart?.notes || []) {
const bucket = Math.floor(note.beatOffset);
buckets.set(bucket, (buckets.get(bucket) || 0) + 1);
}
return Math.max(0, ...buckets.values());
}

// 배치별 리드 큐(첫 큐)의 연속 도약 통계.
export function leapStats(cuePlan) {
const leadMidis = cuePlan
.filter((batch) => batch.cues.length > 0)
.map((batch) => frequencyToMidi(batch.cues[0].frequencyHz));
const leaps = [];
for (let index = 1; index < leadMidis.length; index += 1) {
leaps.push(Math.abs(leadMidis[index] - leadMidis[index - 1]));
}

return {
count: leaps.length,
maxLeapSemitones: leaps.length ? Math.max(...leaps) : 0,
overOctaveRatio: leaps.length ? leaps.filter((leap) => leap > 12).length / leaps.length : 0,
};
}
29 changes: 22 additions & 7 deletions player/src/lib/replayAudioEngine.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { midiToFrequency } from './musicTheory.js';

const STEP_BEATS = 0.5;
const BASE_FREQUENCIES = Object.freeze([220, 261.63, 329.63, 392, 523.25, 659.25]);

Expand Down Expand Up @@ -68,13 +70,9 @@ export function createBrowserReplayAudioDriver(globalObject = globalThis, option
function createCueFromNote(note, stepIndex, options) {
const laneIndex = note.laneIndex || 1;
const laneCount = Math.max(1, options.laneCount || 4);
const baseFrequency = BASE_FREQUENCIES[(laneIndex - 1) % BASE_FREQUENCIES.length];
const octaveShift = laneIndex > laneCount / 2 ? 2 : 1;
const frequencyHz = note.noteType === 'accent'
? baseFrequency * octaveShift
: note.noteType === 'hold'
? baseFrequency / 2
: baseFrequency;
const frequencyHz = Number.isFinite(note.pitchMidi)
? resolvePitchedFrequency(note)
: resolveLegacyLaneFrequency(note, laneIndex, laneCount);

return {
cueId: `${note.noteId || `cue-${stepIndex}-${laneIndex}`}`,
Expand All @@ -90,6 +88,23 @@ function createCueFromNote(note, stepIndex, options) {
};
}

// harmony/motif가 배선된 노트: 옥타브 이동만 허용해 피치 클래스(선법 적합)를 보존한다.
function resolvePitchedFrequency(note) {
const octaveShift = note.noteType === 'hold' ? -12 : note.noteType === 'accent' ? 12 : 0;
return Math.round(midiToFrequency(note.pitchMidi + octaveShift) * 100) / 100;
}

// pitchMidi가 없는 노트(레거시/외부 차트) 폴백: 기존 레인 고정 주파수 유지.
function resolveLegacyLaneFrequency(note, laneIndex, laneCount) {
const baseFrequency = BASE_FREQUENCIES[(laneIndex - 1) % BASE_FREQUENCIES.length];
const octaveShift = laneIndex > laneCount / 2 ? 2 : 1;
return note.noteType === 'accent'
? baseFrequency * octaveShift
: note.noteType === 'hold'
? baseFrequency / 2
: baseFrequency;
}

function summarizeCueBatch(cues) {
if (!cues.length) {
return 'BGM armed';
Expand Down
81 changes: 81 additions & 0 deletions player/tests/musicTheoryHarness.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 음악 이론 정량 하니스 (스펙 2026-08-04 §1): 픽스처마다 스케일 적합·그리드·밀도·도약을 게이트한다.
import test from 'node:test';
import assert from 'node:assert/strict';

import { createChartFromMusicPlan } from '../src/lib/chartMapper.js';
import { buildMusicPlan } from '../src/lib/musicIntentMapper.js';
import { createReplayCuePlan } from '../src/lib/replayAudioEngine.js';
import {
beatGridConformance,
chartMaxNotesPerBeat,
leapStats,
scaleConformance,
snapToScale,
} from '../src/lib/musicTheory.js';
import {
buildGoldenListeningPackEntries,
buildTransitionValidationFixture,
} from '../src/lib/goldenListeningPack.js';

function renderFixture(events) {
const plan = buildMusicPlan(events, { laneCount: 4 });
const chart = createChartFromMusicPlan(plan, { laneCount: 4, maxNotesPerBeat: 2 });
const cuePlan = createReplayCuePlan(chart.notes, { laneCount: 4 });
return { plan, chart, cuePlan };
}

function collectFixtures() {
const fixtures = buildGoldenListeningPackEntries().map((entry) => ({
label: entry.label,
events: entry.events,
}));
fixtures.push({ label: 'transition-validation', events: buildTransitionValidationFixture() });
return fixtures;
}

test('snapToScale은 항상 선법 스케일 내 피치 클래스를 돌려준다', () => {
for (const mode of ['ionian', 'dorian', 'phrygian', 'minor-pentatonic']) {
for (let offset = -14; offset <= 14; offset += 1) {
const snapped = snapToScale(offset, mode);
const pitchClass = ((snapped % 12) + 12) % 12;
const scale = {
ionian: [0, 2, 4, 5, 7, 9, 11],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
'minor-pentatonic': [0, 3, 5, 7, 10],
}[mode];
assert.ok(scale.includes(pitchClass), `${mode} offset ${offset} → ${snapped}`);
}
}
});

test('모든 픽스처: 박 그리드 정합 100% + 밀도 상한 2/박', () => {
for (const fixture of collectFixtures()) {
const { chart } = renderFixture(fixture.events);
const grid = beatGridConformance(chart, 0.25);
assert.equal(grid.ratio, 1, `${fixture.label} 그리드 위반: ${grid.offenders.join(',')}`);
assert.ok(chartMaxNotesPerBeat(chart) <= 2, `${fixture.label} 밀도 초과`);
}
});

test('모든 픽스처: 발음 큐의 선법 스케일 적합률 ≥ 95%', () => {
for (const fixture of collectFixtures()) {
const { plan, cuePlan } = renderFixture(fixture.events);
const conformance = scaleConformance(cuePlan, plan[0].harmony);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate each session against its own harmony

When a fixture spans multiple branches or session gaps, buildMusicPlan returns multiple sessions while renderFixture combines all their notes into one cue plan. Passing plan[0].harmony therefore judges every later session against the first session’s tonic and mode, so correctly pitched later cues can make this gate fail—and later-session pitch defects can pass when they happen to fit the first scale. Partition the cues by session/event references and run conformance against each session’s own harmony.

Useful? React with 👍 / 👎.

assert.ok(
conformance.ratio >= 0.95,
`${fixture.label}: ${plan[0].harmony.key} 적합률 ${(conformance.ratio * 100).toFixed(1)}% (${conformance.conformant}/${conformance.total})`,
);
}
});

test('모든 픽스처: 리드 큐 도약의 옥타브 초과 비율 ≤ 40%', () => {
for (const fixture of collectFixtures()) {
const { cuePlan } = renderFixture(fixture.events);
const stats = leapStats(cuePlan);
assert.ok(
stats.overOctaveRatio <= 0.4,
`${fixture.label}: 옥타브 초과 도약 ${(stats.overOctaveRatio * 100).toFixed(1)}% (max ${stats.maxLeapSemitones})`,
);
}
});
Loading