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
38 changes: 38 additions & 0 deletions docs/superpowers/specs/2026-08-04-player-chord-voicing-design.md
Original file line number Diff line number Diff line change
@@ -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. 비범위

보이스리딩(전위 선택), 벨로시티 커브, 악기 음색(신스 패치)은 후속.
28 changes: 25 additions & 3 deletions player/src/lib/chartMapper.js
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 26 additions & 6 deletions player/src/lib/musicTheory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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 Keep the conformance ratio at the cue level

When accent cues contain several conforming chord voices, incrementing total for every frequency dilutes an invalid lead note and weakens the existing ≥95% gate in musicTheoryHarness.test.mjs. For example, ten accent cues with four chord tones each and one off-scale lead report 49/50 = 98% instead of the cue-level 9/10 = 90%, allowing a regression affecting 10% of cues to pass; mark a cue nonconformant when any of its sounding frequencies is outside the scale, or gate cue and voice ratios separately.

Useful? React with 👍 / 👎.

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,
};
}
Expand Down
14 changes: 14 additions & 0 deletions player/src/lib/replayAudioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`}`,
Expand All @@ -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',
Expand Down
34 changes: 34 additions & 0 deletions player/tests/musicTheoryHarness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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은 단선율');
}
}
});
34 changes: 34 additions & 0 deletions player/tests/replayAudioEngine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 베이스
});
Loading