사운드 피드백 타이밍

Sound feedback timing

동작과 소리 사이의 지연이 커지면, 그 소리가 내 동작 때문인지 뇌가 헷갈리기 시작합니다.

다른 이름: Perceived latencyCause-and-effect timing
···
html
<div class="wrap">
  <button class="tile good" id="good" type="button">
    <span class="tag">즉시 · 0ms</span>
    <span class="dot" id="gdot"></span>
  </button>
  <button class="tile bad" id="bad" type="button">
    <span class="tag">지연 · 500ms</span>
    <span class="dot" id="bdot"></span>
  </button>
</div>
css
.wrap{position:relative;width:94%;height:82%;display:flex;gap:6%}
.tile{position:relative;flex:1;border:1px solid var(--line);border-radius:12px;background:var(--surface);display:grid;place-items:center;cursor:pointer;padding:0}
.tag{position:absolute;top:8px;left:10px;font:700 10px/1 monospace;color:var(--muted)}
.dot{width:22%;aspect-ratio:1;border-radius:50%;background:var(--line)}
.dot.go{background:var(--accent-3)}
.tile.bad .dot.go{background:var(--accent-2)}
js
const good = document.getElementById('good');
const bad = document.getElementById('bad');
const gdot = document.getElementById('gdot');
const bdot = document.getElementById('bdot');
function beep(ctx, t, freq) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.value = freq;
  gain.gain.setValueAtTime(0, t);
  gain.gain.linearRampToValueAtTime(0.13, t + 0.01);
  gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.13);
  osc.connect(gain).connect(ctx.destination);
  osc.start(t);
  osc.stop(t + 0.14);
}
function trigger(el, dot, delayMs, playSound) {
  el.style.transform = 'scale(.97)';
  setTimeout(() => { el.style.transform = ''; }, 90);
  setTimeout(() => {
    dot.classList.add('go');
    setTimeout(() => dot.classList.remove('go'), 160);
    if (playSound) {
      const ctx = new (window.AudioContext || window.webkitAudioContext)();
      beep(ctx, ctx.currentTime, delayMs === 0 ? 660 : 330);
      setTimeout(() => ctx.close(), 300);
    }
  }, delayMs);
}
let cycle = 0;
setInterval(() => {
  cycle++;
  if (cycle % 2) trigger(good, gdot, 0, false); else trigger(bad, bdot, 500, false);
}, 2000);
good.addEventListener('click', () => trigger(good, gdot, 0, true));
bad.addEventListener('click', () => trigger(bad, bdot, 500, true));

사람은 원인과 결과가 대략 100ms 이내에 일어나면 "내가 한 일 때문"이라고 자연스럽게 느낍니다(감각 운동 동시성). 이 창을 넘기면 소리가 늦게 들려도 "일단 들리긴 했다"고 인지는 하지만, 그 소리를 내 탭과 하나로 묶어 느끼지는 못합니다 — 오히려 "화면이 느리다"는 부정적 인상만 남깁니다.

조작음은 지연이 곧 체감 성능이므로 네트워크 요청이 끝난 뒤가 아니라 조작이 인식된 즉시(로컬에서) 재생해야 합니다. 서버 확인이 필요한 동작(결제, 제출)은 성공·실패가 갈릴 수 있으므로 낙관적 UI처럼 먼저 짧은 확인음을 주고, 실패하면 별도의 정정 신호(다른 소리 + 시각 알림)로 되돌립니다. 저사양 환경에서 프레임이 밀리면 소리보다 시각적 변화가 먼저 어긋나므로, 시각 동기화부터 점검합니다.

버튼 탭 피드백, 타건음, 게임의 히트 판정음처럼 "지금 당장"이 생명인 모든 조작음에 적용됩니다.

언제 쓰나

조작음은 서버 응답을 기다리지 말고 로컬에서 즉시 재생하세요. 100ms를 넘기면 원인-결과로 묶여 들리지 않습니다.