From ccd671840bf2f88b2f5b15ce7e34f679ad047854 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Tue, 4 Aug 2026 19:32:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(player):=20=ED=99=94=EC=9D=8C=20?= =?UTF-8?q?=EB=B0=9C=EC=84=B1=202=EB=8B=A8=EA=B3=84=20=EC=8A=A4=ED=8E=99?= =?UTF-8?q?=20(=EC=BD=94=EB=93=9C=20=EC=BB=AC=EB=9F=AC=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EC=8B=B1=20+=20=EB=B2=A0=EC=9D=B4=EC=8A=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-04-player-chord-voicing-design.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-player-chord-voicing-design.md diff --git a/docs/superpowers/specs/2026-08-04-player-chord-voicing-design.md b/docs/superpowers/specs/2026-08-04-player-chord-voicing-design.md new file mode 100644 index 0000000..c9ed235 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-player-chord-voicing-design.md @@ -0,0 +1,38 @@ +# Maestro Player 화음 발성 2단계 설계 (코드 컬러 보이싱 + 베이스) + +- 날짜: 2026-08-04 +- 상태: 확정 (음높이 배선 1단계의 예고된 후속) +- 범위: `player/` 하위만 + +## 0. 목표 + +1단계에서 멜로디 음높이가 조성·선법에 배선됐다. 2단계는 지금까지 라벨로만 +존재하던 `harmony.chordColor`(triad/add9/sus2/sus4/maj7/flat7)를 실제 +동시 발성으로 만들고 베이스 토닉을 깔아 "화성이 울리는" 소리를 만든다. + +## 1. 규칙 + +- `musicTheory.buildChordOffsets(chordColor, mode)`: 컬러별 기본 구성음 + (triad [0,4,7], add9 +14, sus2 [0,2,7], sus4 [0,5,7], maj7 +11, flat7 +10)을 + **선법 스케일에 스냅** — 단3도 선법(dorian/phrygian/aeolian)에서는 3도가 + 자동으로 단3도(3)로 조정된다. 결과적으로 스케일 적합률 게이트가 유지된다. +- `chartMapper`: **accent·hold 노트에만** `chordMidis` 부여 — + `[베이스 토닉(36+tonicIndex), ...(48+tonicIndex+chordOffsets)]`. + tap 노트는 단선율 유지(과밀 방지). +- `replayAudioEngine`: cue에 `chordFrequencies` 전달, 재생 시 메인 음 외에 + 화음 음마다 sine 오실레이터 추가(게인 ×0.35, 길이 ×1.6 — 패드 느낌). + hold는 기존 -1옥타브 메인 위에 화음이 얹혀 패드+베이스 역할. +- `scaleConformance`는 chordFrequencies까지 검사하도록 확장(게이트 강화). + +## 2. 테스트 + +- buildChordOffsets: dorian triad→[0,3,7], dorian maj7의 11→10 스냅. +- chartMapper: accent 노트에 chordMidis(베이스 최저음 포함) 존재, tap에는 없음, + 전부 스케일 적합. +- replayAudioEngine: chordFrequencies가 있는 cue 재생 시 오실레이터 수 = + 1+화음 수, 주파수 목록에 화음 주파수 포함. +- 기존 하니스(스케일·그리드·밀도·도약) + fingerprint 결정성 무회귀. + +## 3. 비범위 + +보이스리딩(전위 선택), 벨로시티 커브, 악기 음색(신스 패치)은 후속. From b56591d771d9b57d6f43d81ecc835e60fc0662ec Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Tue, 4 Aug 2026 19:34:46 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(player):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=AC=20=EB=B3=B4=EC=9D=B4=EC=8B=B1=20+=20?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=ED=86=A0=EB=8B=89=20=EB=B0=9C?= =?UTF-8?q?=EC=84=B1=20(=ED=99=94=EC=9D=8C=202=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라벨로만 존재하던 harmony.chordColor를 실제 동시 발성으로 배선한다. buildChordOffsets가 컬러 구성음을 선법 스케일에 스냅(단3도 선법에서 3도 자동 조정)하고, chartMapper가 accent/hold 노트에 베이스 토닉+화음 chordMidis를 부여하며(tap은 단선율 유지 — 과밀 방지), 오디오 엔진이 화음 음을 sine 패드(게인 x0.35, 길이 x1.6)로 얹는다. scaleConformance 게이트를 화음 주파수까지 검사하도록 강화. Co-Authored-By: Claude Fable 5 --- player/src/lib/chartMapper.js | 28 ++++++++++++++++--- player/src/lib/musicTheory.js | 32 +++++++++++++++++----- player/src/lib/replayAudioEngine.js | 14 ++++++++++ player/tests/musicTheoryHarness.test.mjs | 34 ++++++++++++++++++++++++ player/tests/replayAudioEngine.test.mjs | 34 ++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 9 deletions(-) diff --git a/player/src/lib/chartMapper.js b/player/src/lib/chartMapper.js index 088e0fe..951e712 100644 --- a/player/src/lib/chartMapper.js +++ b/player/src/lib/chartMapper.js @@ -1,5 +1,5 @@ import { clamp, hashString } from './types.js'; -import { snapToScale } from './musicTheory.js'; +import { buildChordOffsets, snapToScale } from './musicTheory.js'; // registerBand별 tonic 기준 옥타브 (스펙 2026-08-04 §2) const REGISTER_BASE_MIDI = Object.freeze({ low: 36, mid: 48, high: 60 }); @@ -51,34 +51,56 @@ function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat, sessio const notes = []; if (intent.rhythmPattern === 'hold') { + const holdType = intent.accentLevel >= 0.7 ? 'accent' : 'hold'; notes.push({ noteId: `${intent.intentId}:0`, laneIndex: lane, beatOffset: roundBeat(baseBeat), durationBeats: 2 + Math.round(intent.accentLevel * 2), - noteType: intent.accentLevel >= 0.7 ? 'accent' : 'hold', + noteType: holdType, eventRef: intent.eventRef, pitchMidi: computePitchMidi(intent, session, 0), + chordMidis: computeChordMidis(session, holdType), }); return notes; } for (let noteIndex = 0; noteIndex < noteBudget; noteIndex += 1) { const laneOffset = pickLaneOffset(intent, noteSeed, noteIndex, laneCount); + const noteType = pickNoteType(intent, noteIndex); notes.push({ noteId: `${intent.intentId}:${noteIndex}`, laneIndex: clamp(lane + laneOffset, 1, laneCount), beatOffset: roundBeat(baseBeat + offsets[noteIndex]), durationBeats: intent.rhythmPattern === 'fill' ? 0.5 : 1, - noteType: pickNoteType(intent, noteIndex), + noteType, eventRef: intent.eventRef, pitchMidi: computePitchMidi(intent, session, noteIndex), + chordMidis: computeChordMidis(session, noteType), }); } return notes; } +// accent/hold에만 코드 컬러 보이싱 + 베이스 토닉을 부여한다 (tap은 단선율 — 과밀 방지). +function computeChordMidis(session, noteType) { + if (noteType !== 'accent' && noteType !== 'hold') { + return null; + } + + const harmony = session?.harmony; + if (!harmony) { + return null; + } + + const tonicIndex = harmony.tonicIndex || 0; + const bassMidi = REGISTER_BASE_MIDI.low + tonicIndex; + const chordRoot = REGISTER_BASE_MIDI.mid + tonicIndex; + const chordOffsets = buildChordOffsets(harmony.chordColor, harmony.mode); + return [bassMidi, ...chordOffsets.map((offset) => chordRoot + offset)]; +} + // 세션 harmony(조성·선법)와 motif 음정을 실제 음높이로 배선한다 (스펙 §2). // 세션 정보가 없으면 null — 오디오 엔진이 레거시 레인 주파수로 폴백한다. function computePitchMidi(intent, session, noteIndex) { diff --git a/player/src/lib/musicTheory.js b/player/src/lib/musicTheory.js index 46a3713..8e9387b 100644 --- a/player/src/lib/musicTheory.js +++ b/player/src/lib/musicTheory.js @@ -45,23 +45,43 @@ export function snapToScale(semitoneOffset, mode) { return semitoneOffset; } +// 코드 컬러 구성음 (스펙 2026-08-04 화음 §1) — 선법 스냅으로 3도/7도가 자동 조정된다. +const CHORD_COLOR_BASE = Object.freeze({ + triad: [0, 4, 7], + add9: [0, 4, 7, 14], + sus2: [0, 2, 7], + sus4: [0, 5, 7], + maj7: [0, 4, 7, 11], + flat7: [0, 4, 7, 10], +}); + +export function buildChordOffsets(chordColor, mode) { + const base = CHORD_COLOR_BASE[chordColor] || CHORD_COLOR_BASE.triad; + return base.map((offset) => snapToScale(offset, mode)); +} + 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 = []; + let total = 0; for (const cue of cues) { - const pitchClass = ((frequencyToMidi(cue.frequencyHz) - tonicPitchClass) % 12 + 12) % 12; - if (!scale.includes(pitchClass)) { - offenders.push({ cueId: cue.cueId, pitchClass }); + const frequencies = [cue.frequencyHz, ...(cue.chordFrequencies || [])]; + for (const frequencyHz of frequencies) { + total += 1; + const pitchClass = ((frequencyToMidi(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, + total, + conformant: total - offenders.length, + ratio: total ? (total - offenders.length) / total : 1, offenders, }; } diff --git a/player/src/lib/replayAudioEngine.js b/player/src/lib/replayAudioEngine.js index 01f88cb..a2893a8 100644 --- a/player/src/lib/replayAudioEngine.js +++ b/player/src/lib/replayAudioEngine.js @@ -55,6 +55,16 @@ export function createBrowserReplayAudioDriver(globalObject = globalThis, option batch.cues.forEach((cue, index) => { playCueOnContext(context, cue, baseVolume, index); + // 코드 컬러 보이싱: 화음 음은 sine 패드로 낮은 게인·긴 길이로 얹는다 (스펙 화음 §1) + (cue.chordFrequencies || []).forEach((chordFrequencyHz) => { + playCueOnContext(context, { + ...cue, + frequencyHz: chordFrequencyHz, + waveform: 'sine', + gainMultiplier: cue.gainMultiplier * 0.35, + durationSeconds: cue.durationSeconds * 1.6, + }, baseVolume, index); + }); }); return true; @@ -73,6 +83,9 @@ function createCueFromNote(note, stepIndex, options) { const frequencyHz = Number.isFinite(note.pitchMidi) ? resolvePitchedFrequency(note) : resolveLegacyLaneFrequency(note, laneIndex, laneCount); + const chordFrequencies = Array.isArray(note.chordMidis) && note.chordMidis.length + ? note.chordMidis.map((midi) => Math.round(midiToFrequency(midi) * 100) / 100) + : null; return { cueId: `${note.noteId || `cue-${stepIndex}-${laneIndex}`}`, @@ -82,6 +95,7 @@ function createCueFromNote(note, stepIndex, options) { noteType: note.noteType || 'tap', stepIndex, frequencyHz, + chordFrequencies, durationSeconds: note.noteType === 'hold' ? 0.28 : note.noteType === 'accent' ? 0.18 : 0.12, gainMultiplier: note.noteType === 'accent' ? 1.15 : note.noteType === 'hold' ? 0.8 : 0.92, waveform: note.noteType === 'hold' ? 'sawtooth' : note.noteType === 'accent' ? 'triangle' : 'sine', diff --git a/player/tests/musicTheoryHarness.test.mjs b/player/tests/musicTheoryHarness.test.mjs index e357964..337ecb4 100644 --- a/player/tests/musicTheoryHarness.test.mjs +++ b/player/tests/musicTheoryHarness.test.mjs @@ -79,3 +79,37 @@ test('모든 픽스처: 리드 큐 도약의 옥타브 초과 비율 ≤ 40%', ( ); } }); + +test('buildChordOffsets는 선법에 맞게 구성음을 스냅한다', async () => { + const { buildChordOffsets } = await import('../src/lib/musicTheory.js'); + assert.deepEqual(buildChordOffsets('triad', 'dorian'), [0, 3, 7]); // 단3도 자동 조정 + assert.deepEqual(buildChordOffsets('triad', 'ionian'), [0, 4, 7]); + assert.deepEqual(buildChordOffsets('maj7', 'dorian'), [0, 3, 7, 10]); // 11→10 스냅 + assert.deepEqual(buildChordOffsets('add9', 'dorian'), [0, 3, 7, 14]); // 9th(pc2)는 dorian 내 +}); + +test('accent 노트는 chordMidis(베이스 최저음 포함)를 갖고 tap 노트는 단선율을 유지한다', () => { + for (const fixture of collectFixtures()) { + const { plan, chart } = renderFixture(fixture.events); + const harmony = plan[0].harmony; + const accents = chart.notes.filter((note) => note.noteType === 'accent'); + const taps = chart.notes.filter((note) => note.noteType === 'tap'); + assert.ok(accents.length > 0, `${fixture.label}: accent 노트 없음`); + for (const note of accents) { + assert.ok(Array.isArray(note.chordMidis) && note.chordMidis.length >= 3, `${fixture.label} ${note.noteId} chordMidis 없음`); + assert.equal(Math.min(...note.chordMidis), note.chordMidis[0], '베이스가 최저음이어야 함'); + for (const midi of note.chordMidis) { + const pitchClass = ((midi - (36 + harmony.tonicIndex)) % 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], + 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], + }[harmony.mode]; + assert.ok(scale.includes(pitchClass), `${fixture.label} 화음 ${midi} 스케일 밖`); + } + } + for (const note of taps) { + assert.equal(note.chordMidis ?? null, null, 'tap은 단선율'); + } + } +}); diff --git a/player/tests/replayAudioEngine.test.mjs b/player/tests/replayAudioEngine.test.mjs index 8191d7a..712c078 100644 --- a/player/tests/replayAudioEngine.test.mjs +++ b/player/tests/replayAudioEngine.test.mjs @@ -114,3 +114,37 @@ function createAudioHarness() { }, }; } + +test('chordFrequencies가 있는 cue는 화음 오실레이터를 추가로 울린다', async () => { + const harness = createAudioHarness(); + const driver = createBrowserReplayAudioDriver({ AudioContext: harness.AudioContext }); + await driver.prime(); + + const played = driver.playCueBatch({ + batch: { + cues: [ + { + frequencyHz: 440, + durationSeconds: 0.18, + gainMultiplier: 1, + waveform: 'triangle', + chordFrequencies: [110, 220, 277.18], + }, + ], + }, + }); + + assert.equal(played, true); + assert.equal(harness.starts.length, 4); // 메인 1 + 화음 3 + assert.deepEqual(harness.frequencyValues, [440, 110, 220, 277.18]); +}); + +test('createReplayCuePlan은 chordMidis를 chordFrequencies로 변환한다', () => { + const [batch] = createReplayCuePlan([ + { noteId: 'n1', laneIndex: 4, beatOffset: 0, durationBeats: 1, noteType: 'accent', pitchMidi: 60, chordMidis: [36, 48, 51, 55] }, + ], { laneCount: 4 }); + + const cue = batch.cues[0]; + assert.equal(cue.chordFrequencies.length, 4); + assert.equal(Math.round(cue.chordFrequencies[0]), 65); // C2 베이스 +});