-
Notifications
You must be signed in to change notification settings - Fork 0
feat(player): 음악 이론 정량 하니스 + 조성·선법 음높이 배선 #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
50 changes: 50 additions & 0 deletions
50
docs/superpowers/specs/2026-08-04-player-music-theory-harness-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 추가에도 결정적이면 통과). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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})`, | ||
| ); | ||
| } | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a fixture spans multiple branches or session gaps,
buildMusicPlanreturns multiple sessions whilerenderFixturecombines all their notes into one cue plan. Passingplan[0].harmonytherefore 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 👍 / 👎.