designpaca/apps/site/public/work/overtone-sound/index.html
Yun Chan 123d9fc5a8 fix(showcase): 미정의 CSS 변수·한글 조판·접근성 결함 수리 및 무결성 게이트 두 겹 신설
stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 없다"를 잡지
못한다. 미정의 변수는 선언 전체를 무효로 만드는데 콘솔 에러가 없어 조용히 남는다.
검사기를 심자마자 v0.9.1 의 실제 결함이 나왔다.

게이트 신설
- tools/integrity.mjs — L0 무결성. CSS 커스텀 프로퍼티 참조, 상대경로,
  한글 조판 필수 규칙(keep-all·한글 폰트 지정), 슬롭 지문, 스크롤 리스너를 검사.
  verify.mjs 체인에 편입해 실패 시 배포를 막는다.
  폴백이 있는 var(--x, 기본값) 은 선택적 주입이므로 미정의로 세지 않는다.
- tools/showcase-audit.mjs — 320/390/768/1440 실렌더 감사. 가로 스크롤·오버플로·
  컨트롤 줄바꿈·대비 AA·왼쪽 인셋·h1 개수·히어로 줄 수·깨진 이미지·콘솔 에러.
  반투명 배경을 합성해 대비를 재고, WebGL 페이지를 위해 swiftshader 로 렌더한다.
- html-validate 대상을 두 페이지에서 public/work 전체로 넓혔다.

수리한 결함
- 미정의 변수 64건: --dur-fast·--radius-xs 가 정의된 적이 없어 cask-archive,
  cheongon-care, materia-interior 의 transition 선언이 통째로 무효였다.
  새 토큰을 만들지 않고 기존 --dur-quick·--radius 로 매핑했다 — 토큰을 늘리면
  radius 어휘가 4종이 되어 하드 게이트 3에 걸린다.
- 한글 조판: lumina-optics·pulsegate·synapse-bci 가 word-break: keep-all 없이
  IBM Plex Sans 만 지정해 한글이 시스템 기본으로 떨어지고 있었다.
  같은 계열의 IBM Plex Sans KR 을 스택에 넣고 keep-all 을 추가했다.
- 슬롭 지문 19건: text-transform: uppercase 로 만든 영문 대문자 eyebrow 를
  한글 문장으로 바꾸고, 대문자용으로 잡아둔 넓은 자간과 모노 서체도 되돌렸다.
- 하드 게이트 9: materia-interior/hannam-duplex.html 의 100vh → 100dvh.
  이 파일은 어디서도 참조되지 않으면서 인라인 hex 까지 갖고 있어 토큰 기반으로 다시 썼다.
- 접근성 11건: label 하나가 output 과 input 둘에 걸리던 것 5건(font-foundry,
  lumina-optics), dl > div > div > dt 4건(muwol-coffee), div 에 붙은 aria-label
  2건(overtone-sound, pulsegate).

측정기 자체의 오류 둘도 고쳤다 — 눈에 보이지 않는 .sr-only 를 대비 실패로 잡던 것과,
overflow-x: auto 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면
통과도 실패도 의미가 없다.
2026-08-24 12:50:57 +09:00

257 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>오버톤 사운드랩 · 공간 음향 연구소</title>
<meta
name="description"
content="침묵과 폭발의 주파수. 공간 음향과 생성 사운드스케이프를 만드는 연구소의 작업 아카이브."
/>
<meta property="og:title" content="오버톤 사운드랩" />
<meta
property="og:description"
content="공간 음향과 생성 사운드스케이프 — 작업 아카이브."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#0E0D0B" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E0D0B'/%3E%3Cpath d='M4 16h4l2-7 3 14 3-10 2 5h10' fill='none' stroke='%23E8A33D' stroke-width='1.8'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">오버톤 사운드랩</a>
<nav class="nav" aria-label="섹션">
<a href="#works">작업</a>
<a href="#manifesto">선언</a>
<a href="#contact">연락</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — anti-grid: 글자가 파형 위로 삐져나온다.
캔버스는 절차적 유사-FFT(정현파 합). 재생 버튼 없이 스스로 흐르되
화면 밖에서는 그리지 않는다.
-->
<section class="hero" aria-labelledby="hero-h">
<canvas id="wave" class="wave" aria-hidden="true"></canvas>
<div class="wrap hero-inner">
<p class="hero-kicker reveal">
<span>공간 음향 · DSP · 서울</span>
<span class="mono">48kHz / 2ch</span>
</p>
<h1 id="hero-h" class="reveal" style="--delay: 80ms">
침묵과 폭발의<br /><em>주파수</em>
</h1>
<p class="hero-statement reveal" style="--delay: 160ms">
오버톤은 소리를 만들지 않고, 소리가 공간에서 어떻게 행동하는지를
연구합니다. 방금 지나간 곡선은 이 연구소를 지나가는 소리의 자취입니다.
</p>
</div>
</section>
<!--
작업 아카이브 — 스태거 2열. 카드가 아니라 '벽에 걸린 목록'.
같은 간격을 주지 않는다: 짝수 작품은 오른쪽으로, 홀수는 왼쪽으로 쏠린다.
-->
<section id="works" class="section works" aria-labelledby="works-h">
<div class="wrap">
<h2 id="works-h" class="works-h reveal">작업</h2>
<ol class="archive">
<li class="work reveal">
<p class="work-year mono">2026</p>
<h3>공명하는 방 — DDP 설치</h3>
<p>
종이 콘 스피커 48개가 천장에서 내려와 방 안의 소리를 0.8초 늦게
되돌려주는 설치. 관람객의 발소리가 악기가 된다.
</p>
<p class="work-meta mono">48ch · 공간지연 · 상설</p>
</li>
<li class="work work-b reveal" style="--delay: 60ms">
<p class="work-year mono">2025</p>
<h3>무(無)의 스펙트럼</h3>
<p>
아무 소리도 없는 방의 바닥 소음(−62dB)을 증폭해 들려주는
퍼포먼스. 침묵에는 평균 진폭이 있다.
</p>
<p class="work-meta mono">live · −62dB floor</p>
</li>
<li class="work reveal" style="--delay: 40ms">
<p class="work-year mono">2024</p>
<h3> 파도의 인버전 — 제주</h3>
<p>
바람과 파도의 실측 데이터를 반대 위상으로 합성해 바다가 '삼키는'
소리를 만드는 해안 설치.
</p>
<p class="work-meta mono">field data · 6h loop</p>
</li>
<li class="work work-b reveal" style="--delay: 100ms">
<p class="work-year mono">2023</p>
<h3>유리 진동자</h3>
<p>
잔 12개에 각기 다른 공진 주파수를 실어 연주하는 설비. 연주자는
물의 높이를 조율한다.
</p>
<p class="work-meta mono">glass · 220–940Hz</p>
</li>
</ol>
</div>
</section>
<!--
선언 — anti-grid의 정점: 문장 하나가 페이지 폭을 다 쓴다.
압축 스케일의 반대편. 이 페이지에서 가장 큰 텍스트 블록.
-->
<section id="manifesto" class="section manifesto" aria-labelledby="manifesto-h">
<div class="wrap">
<h2 id="manifesto-h" class="sr-only">선언</h2>
<p class="manifesto-line reveal">
좋은 음향은 들리지 않는다.<br />
방이 좋아지는 것뿐이다.
</p>
<p class="manifesto-note reveal" style="--delay: 100ms">
우리는 음파를 시각화로 번역하지 않습니다. 시각화는 소리의 크기만
흉내 낼 뿐, 소리의 '방의 크기'를 옮기지 못합니다. 대신 우리는 방을
조율합니다 — 재생 목록이 아니라 잔향시간을 설계합니다.
</p>
</div>
</section>
<!-- 협업 마퀴 — 페이지에 하나뿐인 흐름 -->
<div class="marquee" role="group" aria-label="협업 단체">
<div class="marquee-track">
<span>국립현대미술관</span><span>DDP</span><span>서울시립교향악단</span><span>아르코예술극장</span><span>제주국제음악제</span><span>국립현대미술관</span><span>DDP</span><span>서울시립교향악단</span><span>아르코예술극장</span><span>제주국제음악제</span>
</div>
</div>
<!--
사진 — 설치 현장 한 컷. 다크룸의 앰버 빔.
-->
<section class="section photo" aria-label="설치 현장">
<figure class="wrap photo-fig reveal">
<img
src="./assets/installation.webp"
alt="어두운 전시실에 종이콘 스피커들이 늘어서 있고 앰버색 빛줄기가 한 줄 지나간다"
width="1440"
height="810"
loading="lazy"
/>
<figcaption class="mono">공명하는 방 · 설치 뷰 · 2026</figcaption>
</figure>
</section>
<!-- 연락 -->
<section id="contact" class="section contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">연락</h2>
<p>
설치·공연·연구 협업 문의는 소리의 조건(공간 크기, 재생 시간, 청중 수)과
함께 보내주세요.
</p>
<a class="contact-mail" href="mailto:lab@overtone.kr">lab@overtone.kr</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>오버톤 사운드랩 · 서울 마포</p>
<p class="mono foot-meta">© 2019–2026 · OVERTONE SOUND LAB</p>
</div>
</footer>
<script>
/*
절차적 유사-FFT — 정현파 5개를 더하고 위상을 흐르게 한다.
오디오 파일도 디코딩도 없다(연구조사 R3의 교훈: 전체 파일 디코드는
수 초 메인스레드를 막는다). 화면 밖·reduced-motion 에서는 정지 프레임.
*/
const canvas = document.getElementById("wave");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = canvas.getContext("2d");
let w, h, dpr, running = false;
function fit() {
dpr = Math.min(devicePixelRatio || 1, 1.5);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(t) {
ctx.clearRect(0, 0, w, h);
// 세 겹의 파형 — 시간차를 두고 겹친다
for (let layer = 0; layer < 3; layer++) {
ctx.beginPath();
const amp = (h * 0.16) * (1 - layer * 0.28);
const yMid = h * (0.52 + layer * 0.12);
const speed = 0.00042 * (1 + layer * 0.7);
for (let x = 0; x <= w; x += 3) {
const p = x / w;
const env = Math.exp(-Math.pow((p - 0.5) * 2.4, 2)); // 중앙 강조
const y =
yMid +
Math.sin(p * 21 + t * speed + layer * 2.1) * amp * env * Math.sin(p * Math.PI) +
Math.sin(p * 53 - t * speed * 1.7) * amp * 0.25 * env;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = layer === 0 ? "rgba(232,163,61,0.85)" : `rgba(232,163,61,${0.32 - layer * 0.1})`;
ctx.lineWidth = layer === 0 ? 1.4 : 1;
ctx.stroke();
}
}
function loop(t) {
if (!running) return;
draw(t);
requestAnimationFrame(loop);
}
fit();
addEventListener("resize", fit, { passive: true });
draw(0);
if (!reduced) {
const io = new IntersectionObserver((entries) => {
const on = entries[0].isIntersecting;
if (on && !running) {
running = true;
requestAnimationFrame(loop);
} else if (!on) {
running = false;
}
});
io.observe(canvas);
}
const io2 = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io2.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
</script>
</body>
</html>