From 2f9e23858ca82870ac65d037de3597639e46b337 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 15:52:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(player):=20=EC=8B=A0=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EA=B8=B0=20=EC=8A=A4=ED=8E=99=20(=EB=B2=A8?= =?UTF-8?q?=EB=A1=9C=EC=8B=9C=ED=8B=B0=C2=B7=EC=97=94=EB=B2=A8=EB=A1=9C?= =?UTF-8?q?=ED=94=84=C2=B7=EB=A1=9C=EC=9A=B0=ED=8C=A8=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-05-player-synth-polish-design.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-player-synth-polish-design.md diff --git a/docs/superpowers/specs/2026-08-05-player-synth-polish-design.md b/docs/superpowers/specs/2026-08-05-player-synth-polish-design.md new file mode 100644 index 0000000..43e301c --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-player-synth-polish-design.md @@ -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. 비범위 + +보이스리딩(전위), 리버브/딜레이 등 공간계, 악기 다층 패치. From b9ad8abad0169954a040bf69f5a0d97d34812138 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 15:55:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(player):=20=EC=8B=A0=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EA=B8=B0=20=E2=80=94=20=EB=B2=A8=EB=A1=9C?= =?UTF-8?q?=EC=8B=9C=ED=8B=B0=20=EC=BB=A4=EB=B8=8C=C2=B7=EC=97=94=EB=B2=A8?= =?UTF-8?q?=EB=A1=9C=ED=94=84=C2=B7=EB=A1=9C=EC=9A=B0=ED=8C=A8=EC=8A=A4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 강조/에너지가 실제 음량으로 반영되는 velocity(0.6~1.1)를 노트에 부여하고, noteType별 attack/release 엔벨로프(패드성 hold, 즉각적 accent)와 voice별 로우패스 필터(사각·톱니 하모닉 정리, 화음 패드는 1200Hz로 더 어둡게)를 적용한다. 구필드 없는 cue는 기존 동작 폴백. Co-Authored-By: Claude Fable 5 --- player/src/lib/chartMapper.js | 10 ++++ player/src/lib/replayAudioEngine.js | 34 ++++++++++++-- player/tests/musicTheoryHarness.test.mjs | 26 +++++++++++ player/tests/replayAudioEngine.test.mjs | 59 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/player/src/lib/chartMapper.js b/player/src/lib/chartMapper.js index 951e712..47dea48 100644 --- a/player/src/lib/chartMapper.js +++ b/player/src/lib/chartMapper.js @@ -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; } @@ -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') { diff --git a/player/src/lib/replayAudioEngine.js b/player/src/lib/replayAudioEngine.js index a2893a8..94b4228 100644 --- a/player/src/lib/replayAudioEngine.js +++ b/player/src/lib/replayAudioEngine.js @@ -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); @@ -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); }); }); @@ -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), }; } @@ -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); + + // 로우패스로 사각·톱니 하모닉을 정리한다 (미지원 환경은 폴백) + 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) { diff --git a/player/tests/musicTheoryHarness.test.mjs b/player/tests/musicTheoryHarness.test.mjs index 7dfa01d..7154964 100644 --- a/player/tests/musicTheoryHarness.test.mjs +++ b/player/tests/musicTheoryHarness.test.mjs @@ -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가 더 커야 함'); +}); diff --git a/player/tests/replayAudioEngine.test.mjs b/player/tests/replayAudioEngine.test.mjs index 712c078..1a5c594 100644 --- a/player/tests/replayAudioEngine.test.mjs +++ b/player/tests/replayAudioEngine.test.mjs @@ -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; @@ -91,6 +92,18 @@ function createAudioHarness() { }; } + createBiquadFilter() { + return { + type: 'lowpass', + frequency: { + setValueAtTime(value) { + filterCutoffs.push(value); + }, + }, + connect() {}, + }; + } + createGain() { return { gain: { @@ -106,6 +119,7 @@ function createAudioHarness() { AudioContext: FakeAudioContext, frequencyValues, starts, + filterCutoffs, get resumeCalls() { return resumeCalls; }, @@ -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); // 화음 패드는 더 어둡게 +});