STILLCODING

STILL CODING / NOTES

정확한 박자는 기계처럼 들린다 — 스윙과 사람 같은 어긋남, 그리고 '랜덤이 랜덤이면 안 되는' 이유

JH Kim

박자에 정확히 맞춰 정해진 세기로 연주하는 반주는 드럼 머신처럼 들립니다. 사람이 치는 기타는 뒷박이 조금 늦거나, 같은 코드라도 세기가 다르고, 훑는 속도도 매번 다릅니다. Guitar Auto-Strum은 이 차이를 두 가지 손잡이로 나눠 다룹니다.

web-app/js/engine/feel.js는 두 설정을 구현합니다. 휴머나이즈의 변화량은 스트로크 번호와 시드로 계산하므로, 같은 스트로크를 다시 예약해도 같은 값이 나옵니다.

스윙: 뒷박을 어디에 놓나

박 하나를 8분음표 둘로 나눌 때, 앞의 것은 박의 시작이고 뒤의 것(뒷박, “and”)은 원래 박의 정확히 절반에 놓입니다. 스윙은 이 뒷박의 위치를 박의 절반(0.5)에서 뒤로 옮깁니다. 코드는 스윙을 “뒷박이 박의 몇 분의 몇에 오는가”라는 비율로 정의합니다.

export const STRAIGHT_SWING = 0.5;        // 곧게
export const TRIPLET_SWING = 2 / 3;       // 셔플(3연음 느낌)

export const SWING_PRESETS = Object.freeze([
  { id: 'straight', label: 'Straight', ratio: 0.5 },
  { id: 'light',    label: 'Light',    ratio: 0.54 },
  { id: 'medium',   label: 'Medium',   ratio: 0.58 },
  { id: 'hard',     label: 'Hard',     ratio: 0.62 },
  { id: 'triplet',  label: 'Shuffle',  ratio: 2 / 3 },
]);

늦추는 양은 (비율 − 0.5) × 한 박의 길이입니다. BPM 120이면 한 박이 0.5초이므로, Medium(0.58)은 뒷박이 0.04초, Shuffle(2/3)은 약 0.083초 늦어집니다.

export function swingShiftSeconds(tick, bpm, opts = {}) {
  const ratio = opts.ratio ?? STRAIGHT_SWING;
  const ticksPerBeat = opts.ticksPerBeat || TICKS_PER_BEAT;
  if (ratio === STRAIGHT_SWING) return 0;

  const halfBeat = ticksPerBeat / 2;
  // 정확히 8분음표 뒷박만 움직인다
  if (ticksPerBeat % 2 !== 0 || tick % ticksPerBeat !== halfBeat) return 0;

  return (ratio - STRAIGHT_SWING) * beatSeconds(bpm);
}

움직이는 것은 8분음표 뒷박 하나뿐입니다. 박의 시작과 그 사이의 16분음표는 적힌 자리에 그대로 둡니다. 주석의 설명은 “그것들까지 끌고 가는 것은 다른 느낌(feel)이고, 이 앱의 리듬은 8분음표 기반”이라는 것입니다. 겹박자(compound meter)는 이미 3연음 자리(0, 3, 5틱)에 음을 적어 두기 때문에 이 규칙에 걸리지 않고, 그대로 두는 것이 옳습니다. 이미 스윙하고 있기 때문입니다.

휴머나이즈: 세 가지를 흔든다

휴머나이즈는 세 가지 양을 각각 조절합니다.

프리셋타이밍세기스트럼 속도
Off0ms00
Subtle±6ms±6%±12%
Loose±14ms±13%±25%

현재 HTML에서 Tempo 옆의 Swing 선택 상자가 스윙을, Feel 선택 상자가 휴머나이즈를 조절합니다. 기본값은 straight(곧게)와 off(흔들림 없음)입니다. 켜지 않으면 이 기능은 아무 일도 하지 않고, 코드는 이를 확인하면 계산 경로 자체를 건너뜁니다(isNeutral).

난수를 쓰지 않은 이유

“사람처럼 흔든다”면 Math.random()을 쓰는 것이 가장 먼저 떠오릅니다. 이 코드는 그렇게 하지 않았습니다. 파일 첫머리의 주석이 그 이유를 설명합니다.

반주 엔진은 코드가 도중에 바뀌면 (rechord()) 이미 예약했지만 아직 들리지 않은 스트로크를 되감아 다시 예약한다. 난수 생성기가 계속 돌고 있으면 같은 스트로크가 두 번째에는 다른 어긋남을 받는다. 그러면 그 음이 앞 음보다 먼저 나거나 이미 확정된 음 뒤로 밀릴 수 있다. 사용자가 코드 전환을 가장 주의 깊게 듣는 순간에 겹치거나 비틀거리는 소리가 난다.

이 엔진은 음을 미리 조금 앞서 예약하고(예약 방식은 이전 글에서 다뤘습니다), 코드가 바뀌면 예약을 취소하고 다시 계산합니다. 같은 음이 다른 결과를 내면 다시 계산하는 순간 소리가 어긋납니다.

그래서 어긋남을 난수가 아니라 함수로 만들었습니다. 스트로크의 절대 번호와 시드(seed)만으로 값이 정해지는 해시 함수입니다.

// 정수 해시. 번호가 인접해도 결과는 서로 무관해서, 어긋남이 규칙처럼 들리지 않는다.
function hash32(a, b) {
  let h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1);
  h ^= h >>> 15;
  h = Math.imul(h, 0x85ebca6b);
  h ^= h >>> 13;
  h = Math.imul(h, 0xc2b2ae35);
  h ^= h >>> 16;
  return h >>> 0;
}

// 스트로크마다 [-1, 1] 사이의 안정된 값. salt로 종류마다 다른 값을 뽑는다.
export function offsetFor(index, seed, salt) {
  return (hash32(index, seed ^ Math.imul(salt, 0x9e3779b9)) / 0xffffffff) * 2 - 1;
}

주석은 이 방식의 덤도 짚습니다. 테스트가 결정적이 된다는 것입니다. 엔진에 가짜 난수를 넣어 주는 복잡한 장치 없이도 같은 결과를 매번 기대할 수 있습니다.

이벤트 순서가 뒤집히지 않게

타이밍을 흔들면 새로운 위험이 생깁니다. 늦은 음 뒤에 이른 음이 오면 순서가 뒤집힙니다. 스케줄러는 받은 순서대로 음을 예약하기 때문에 뒤집힌 순서는 곧 오작동입니다. 그래서 한 번에 처리하는 이벤트 묶음에서 앞 이벤트의 시각보다 앞서지 않게 눌러 둡니다.

let previous = -Infinity;
return events.map((event) => {
  const onset = Math.max(previous, feelOnset({ /* ... */ }));
  previous = onset;
  return { ...event, onset };
});

또 feelOnset은 결과가 0 미만이 되지 않게 합니다. 첫 박은 카운트인이 끝나는 자리이므로, 그보다 앞서는 음은 카운트의 일부로 들리기 때문입니다.

한 가지 더 있습니다. 스윙은 적힌 박을 기준으로 재는데, 곡 안에서 한 구간은 3연음 리듬이고 다음 구간은 2분할 리듬일 수 있습니다. 미리 예약하는 한 번의 창 안에 두 격자가 함께 들어올 수 있으므로, 격자 정보(ticksPerBeat)는 이벤트마다 가져옵니다. 한 값으로 뭉치면 경계의 한쪽 음이 잘못 흔들립니다.

현재 프리셋이 다루는 범위