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
43 changes: 43 additions & 0 deletions docs/superpowers/specs/2026-08-05-player-synth-polish-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Maestro Player 신스 다듬기 설계 (음색 3단계)

- 날짜: 2026-08-05
- 상태: 확정 (화음 발성 2단계의 예고된 후속 — 벨로시티·엔벨로프·필터)
- 범위: `player/src/lib/` + 테스트

## 1. 벨로시티 커브 (chartMapper)

모든 노트에 `velocity` 부여: `clamp(0.7 + accentLevel×0.3 + energy×0.1, 0.6, 1.1)`
(소수 2자리). 강조 이벤트일수록 실제로 크게 울린다 — 지금까지는 noteType
3단 고정 게인뿐이었다.

## 2. 엔벨로프 (replayAudioEngine)

cue에 `attackSeconds`/`releaseSeconds`를 noteType별로 부여하고 엔진이 사용:

| type | attack | release | 의도 |
| --- | --- | --- | --- |
| tap | 0.008 | 0.06 | 짧고 깔끔한 타격 |
| accent | 0.005 | 0.12 | 즉각 타격 + 여운 |
| hold | 0.03 | 0.2 | 패드성 페이드 |

게인은 `type 기본 게인 × velocity`. release는 duration 이후 꼬리로 감쇠
(기존: 고정 attack 0.01, 종료 시 급감).

## 3. 로우패스 필터 (replayAudioEngine)

voice별 BiquadFilter(lowpass) 삽입 — 멜로디 cue.filterCutoffHz: hold(sawtooth)
1400 / accent(triangle) 2600 / tap(sine) 3200, 화음 패드는 1200 고정.
사각·톱니 하모닉의 날카로움을 정리한다. `createBiquadFilter` 미지원
환경(구형 mock)은 필터 없이 폴백.

## 4. 테스트

- 하니스: 전 픽스처 노트 velocity ∈ [0.6, 1.1], accent 평균 velocity >
tap 평균 (강조가 실제로 더 크게).
- 엔진: cue 필드(velocity 반영 게인, type별 envelope/cutoff), 드라이버가
필터 노드를 voice 수만큼 생성·연결. 구필드 없는 cue 폴백 동작.
- fingerprint 결정성·스케일 적합률 등 기존 게이트 무회귀.

## 5. 비범위

보이스리딩(전위), 리버브/딜레이 등 공간계, 악기 다층 패치.
10 changes: 10 additions & 0 deletions player/src/lib/chartMapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat, sessio
eventRef: intent.eventRef,
pitchMidi: computePitchMidi(intent, session, 0),
chordMidis: computeChordMidis(session, holdType),
velocity: computeVelocity(intent),
});
return notes;
}
Expand All @@ -77,12 +78,21 @@ function composeIntentNotes(intent, baseBeat, laneCount, maxNotesPerBeat, sessio
eventRef: intent.eventRef,
pitchMidi: computePitchMidi(intent, session, noteIndex),
chordMidis: computeChordMidis(session, noteType),
velocity: computeVelocity(intent),
});
}

return notes;
}

// 벨로시티 커브 (스펙 2026-08-05 §1): 강조·에너지가 실제 음량으로 반영된다.
function computeVelocity(intent) {
const accentLevel = clamp(intent.accentLevel || 0, 0, 1);
const energy = clamp(intent.energy || 0, 0, 1);
const velocity = clamp(0.7 + accentLevel * 0.3 + energy * 0.1, 0.6, 1.1);
return Math.round(velocity * 100) / 100;
}

// accent/hold에만 코드 컬러 보이싱 + 베이스 토닉을 부여한다 (tap은 단선율 — 과밀 방지).
function computeChordMidis(session, noteType) {
if (noteType !== 'accent' && noteType !== 'hold') {
Expand Down
34 changes: 29 additions & 5 deletions player/src/lib/replayAudioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ 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]);
// 신스 다듬기 (스펙 2026-08-05 §2-3): type별 엔벨로프와 로우패스 컷오프
const VOICE_SHAPE = Object.freeze({
tap: { attackSeconds: 0.008, releaseSeconds: 0.06, filterCutoffHz: 3200 },
accent: { attackSeconds: 0.005, releaseSeconds: 0.12, filterCutoffHz: 2600 },
hold: { attackSeconds: 0.03, releaseSeconds: 0.2, filterCutoffHz: 1400 },
});
const CHORD_PAD_CUTOFF_HZ = 1200;

export function createReplayCuePlan(notes = [], options = {}) {
const sortedNotes = [...notes].sort((left, right) => left.beatOffset - right.beatOffset);
Expand Down Expand Up @@ -63,6 +70,7 @@ export function createBrowserReplayAudioDriver(globalObject = globalThis, option
waveform: 'sine',
gainMultiplier: cue.gainMultiplier * 0.35,
durationSeconds: cue.durationSeconds * 1.6,
filterCutoffHz: CHORD_PAD_CUTOFF_HZ,
}, baseVolume, index);
});
});
Expand Down Expand Up @@ -97,8 +105,12 @@ function createCueFromNote(note, stepIndex, options) {
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,
gainMultiplier: Math.round(
(note.noteType === 'accent' ? 1.15 : note.noteType === 'hold' ? 0.8 : 0.92)
* (Number.isFinite(note.velocity) ? note.velocity : 1) * 100,
) / 100,
waveform: note.noteType === 'hold' ? 'sawtooth' : note.noteType === 'accent' ? 'triangle' : 'sine',
...(VOICE_SHAPE[note.noteType] || VOICE_SHAPE.tap),
};
}

Expand Down Expand Up @@ -154,18 +166,30 @@ function playCueOnContext(context, cue, baseVolume, indexOffset) {
const oscillator = context.createOscillator();
const gainNode = context.createGain();
const startTime = context.currentTime + (indexOffset * 0.008);
const attack = cue.attackSeconds || 0.01;
const release = cue.releaseSeconds || 0.02;
const endTime = startTime + cue.durationSeconds;

oscillator.type = cue.waveform;
oscillator.frequency.setValueAtTime(cue.frequencyHz, startTime);
gainNode.gain.setValueAtTime(0.0001, startTime);
gainNode.gain.exponentialRampToValueAtTime(baseVolume * cue.gainMultiplier, startTime + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.0001, endTime);
gainNode.gain.exponentialRampToValueAtTime(baseVolume * cue.gainMultiplier, startTime + attack);
gainNode.gain.exponentialRampToValueAtTime(0.0001, endTime + release);

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 Hold gain until cue duration before releasing

With Web Audio automation, this second exponentialRampToValueAtTime starts from the previous scheduled event, which is the attack peak, so every cue begins fading immediately after attack and reaches silence only at endTime + release. In normal playback this means the new release tail is not actually after the note duration; hold/accent cues lose their body over the whole note instead of sustaining until endTime. Schedule the peak value at endTime before ramping down to make the release happen after the cue duration.

Useful? React with 👍 / 👎.


// 로우패스로 사각·톱니 하모닉을 정리한다 (미지원 환경은 폴백)
let head = oscillator;
if (cue.filterCutoffHz && typeof context.createBiquadFilter === 'function') {
const filter = context.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(cue.filterCutoffHz, startTime);
oscillator.connect(filter);
head = filter;
}

oscillator.connect(gainNode);
head.connect(gainNode);
gainNode.connect(context.destination);
oscillator.start(startTime);
oscillator.stop(endTime + 0.02);
oscillator.stop(endTime + release + 0.02);
}

function clamp(value, min, max) {
Expand Down
26 changes: 26 additions & 0 deletions player/tests/musicTheoryHarness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,29 @@ test('accent 노트는 chordMidis(베이스 최저음 포함)를 갖고 tap 노
}
assert.ok(accentTotal > 0, '전체 픽스처에 accent가 하나도 없음');
});

test('모든 픽스처: 노트 velocity는 [0.6, 1.1] 범위이고 accent가 tap보다 크게 울린다', () => {
let accentSum = 0;
let accentCount = 0;
let tapSum = 0;
let tapCount = 0;
for (const fixture of collectFixtures()) {
const { chart } = renderFixture(fixture.events);
for (const note of chart.notes) {
assert.ok(
Number.isFinite(note.velocity) && note.velocity >= 0.6 && note.velocity <= 1.1,
`${fixture.label} ${note.noteId} velocity ${note.velocity}`,
);
if (note.noteType === 'accent') {
accentSum += note.velocity;
accentCount += 1;
}
if (note.noteType === 'tap') {
tapSum += note.velocity;
tapCount += 1;
}
}
}
assert.ok(accentCount > 0 && tapCount > 0);
assert.ok(accentSum / accentCount > tapSum / tapCount, '강조 노트의 평균 velocity가 더 커야 함');
});
59 changes: 59 additions & 0 deletions player/tests/replayAudioEngine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ test('createBrowserReplayAudioDriver degrades safely without audio support', asy
function createAudioHarness() {
const frequencyValues = [];
const starts = [];
const filterCutoffs = [];
let resumeCalls = 0;
let suspendCalls = 0;

Expand Down Expand Up @@ -91,6 +92,18 @@ function createAudioHarness() {
};
}

createBiquadFilter() {
return {
type: 'lowpass',
frequency: {
setValueAtTime(value) {
filterCutoffs.push(value);
},
},
connect() {},
};
}

createGain() {
return {
gain: {
Expand All @@ -106,6 +119,7 @@ function createAudioHarness() {
AudioContext: FakeAudioContext,
frequencyValues,
starts,
filterCutoffs,
get resumeCalls() {
return resumeCalls;
},
Expand Down Expand Up @@ -148,3 +162,48 @@ test('createReplayCuePlan은 chordMidis를 chordFrequencies로 변환한다', ()
assert.equal(cue.chordFrequencies.length, 4);
assert.equal(Math.round(cue.chordFrequencies[0]), 65); // C2 베이스
});

test('cue는 velocity 반영 게인과 type별 엔벨로프·컷오프를 갖는다', () => {
const [batch] = createReplayCuePlan([
{ noteId: 'v1', laneIndex: 1, beatOffset: 0, durationBeats: 1, noteType: 'accent', pitchMidi: 60, velocity: 1.1 },
{ noteId: 'v2', laneIndex: 2, beatOffset: 0, durationBeats: 1, noteType: 'tap', pitchMidi: 62, velocity: 0.7 },
{ noteId: 'v3', laneIndex: 3, beatOffset: 0, durationBeats: 1, noteType: 'hold', pitchMidi: 48, velocity: 0.8 },
], { laneCount: 4 });
const [accent, tap, hold] = batch.cues;

assert.equal(accent.gainMultiplier, Math.round(1.15 * 1.1 * 100) / 100);
assert.equal(tap.gainMultiplier, Math.round(0.92 * 0.7 * 100) / 100);
assert.deepEqual([accent.attackSeconds, accent.releaseSeconds], [0.005, 0.12]);
assert.deepEqual([tap.attackSeconds, tap.releaseSeconds], [0.008, 0.06]);
assert.deepEqual([hold.attackSeconds, hold.releaseSeconds], [0.03, 0.2]);
assert.equal(hold.filterCutoffHz, 1400);
assert.equal(accent.filterCutoffHz, 2600);
assert.equal(tap.filterCutoffHz, 3200);
});

test('드라이버는 voice마다 로우패스 필터를 연결한다 (미지원 mock은 폴백)', async () => {
const harness = createAudioHarness();
const driver = createBrowserReplayAudioDriver({ AudioContext: harness.AudioContext });
await driver.prime();

driver.playCueBatch({
batch: {
cues: [{
frequencyHz: 440,
durationSeconds: 0.18,
gainMultiplier: 1,
waveform: 'triangle',
filterCutoffHz: 2600,
attackSeconds: 0.005,
releaseSeconds: 0.12,
chordFrequencies: [110, 220],
}],
},
});

// 메인 1 + 화음 2 = 3 voice, 각각 필터 1개
assert.equal(harness.starts.length, 3);
assert.equal(harness.filterCutoffs.length, 3);
assert.equal(harness.filterCutoffs[0], 2600);
assert.ok(harness.filterCutoffs[1] <= 1400); // 화음 패드는 더 어둡게
});
Loading