diff --git a/docs/superpowers/specs/2026-08-04-player-music-theory-harness-design.md b/docs/superpowers/specs/2026-08-04-player-music-theory-harness-design.md new file mode 100644 index 0000000..8df9582 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-player-music-theory-harness-design.md @@ -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 추가에도 결정적이면 통과). diff --git a/player/src/lib/chartMapper.js b/player/src/lib/chartMapper.js index f05b2c5..088e0fe 100644 --- a/player/src/lib/chartMapper.js +++ b/player/src/lib/chartMapper.js @@ -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], @@ -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; } @@ -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); @@ -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; } @@ -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); diff --git a/player/src/lib/musicTheory.js b/player/src/lib/musicTheory.js new file mode 100644 index 0000000..46a3713 --- /dev/null +++ b/player/src/lib/musicTheory.js @@ -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, + }; +} diff --git a/player/src/lib/replayAudioEngine.js b/player/src/lib/replayAudioEngine.js index 86243d1..01f88cb 100644 --- a/player/src/lib/replayAudioEngine.js +++ b/player/src/lib/replayAudioEngine.js @@ -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]); @@ -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}`}`, @@ -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'; diff --git a/player/tests/musicTheoryHarness.test.mjs b/player/tests/musicTheoryHarness.test.mjs new file mode 100644 index 0000000..e357964 --- /dev/null +++ b/player/tests/musicTheoryHarness.test.mjs @@ -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); + 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})`, + ); + } +});