05うなり振幅エンベロープ
周波数がわずかに異なる2つの Math.sin 波を足して描くと、うなり(beat)が生じます。requestAnimationFrame ループの中で合成波形の上に strokeStyle を変えて包絡線(envelope)を別に描き、振幅の周期を見せます。
使いどころオーディオEQの教育コンテンツ、信号処理の講義資料
Math.sinrequestAnimationFramestrokeStyle

<main class="stage">
<div class="be" id="be">
<p class="be__hint">
<svg class="be__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M24 128c16-64 40-64 56 0s40 64 56 0s40-64 56 0s40 64 56 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round"/></g></svg>
<span>わずかに周波数が違う2つの波が出会い、大きくなったり小さくなったりします</span>
</p>
<div class="be__field" id="field" role="application" aria-label="うなり波形の実験盤">
<canvas class="be__cv" id="cv"></canvas>
<b class="be__badge" aria-hidden="true"><span class="be__badgeNum">9·10</span><span class="be__badgeUnit">Hz</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 맥놀이 진폭 엔벨로프 — 주파수가 살짝 다른 두 사인파를 더하면 생기는 맥놀이(beat)를 그린다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.be {
--wave: #{$subject-blue};
--env: #{$subject-pink};
--rail: #{rgba($subject-ink, .14)};
--ink-fg: #{$subject-ink};
--badge-bg: #{rgba($subject-ink, .78)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.be__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: var(--ink-fg);
}
.be__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-pink; }
.be__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.be__cv { display: block; width: 100%; height: 100%; }
.be__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.be__field { height: 124px; }
.be__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.be.is-still .be__badge { opacity: .92 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.be {
--wave: #2f6df6;
--env: #ff9ebb;
--rail: rgba(23, 20, 26, 0.14);
--ink-fg: #17141a;
--badge-bg: rgba(23, 20, 26, 0.78);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.be__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: var(--ink-fg);
}
.be__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff9ebb;
}
.be__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.be__cv {
display: block;
width: 100%;
height: 100%;
}
.be__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.be__field {
height: 124px;
}
.be__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.be.is-still .be__badge {
opacity: 0.92 !important;
}
}// 맥놀이 진폭 엔벨로프 — 주파수가 살짝 다른(9Hz·10Hz) 두 Math.sin 파를 더하면 맥놀이가 생긴다.
// requestAnimationFrame 루프에서 합성 파형을 그리고, strokeStyle 을 바꿔 가며 포락선(envelope)
// 곡선을 따로 그려 진폭이 주기적으로 커졌다 작아지는 것을 보여준다.
var root = document.getElementById('be');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var WAVE = css.getPropertyValue('--wave').trim();
var ENV = css.getPropertyValue('--env').trim();
var RAIL = css.getPropertyValue('--rail').trim();
var F1 = 9, F2 = 10; // 두 파동의 주파수(Hz) — 차이 1Hz 가 맥놀이 주기를 만든다
var WINDOW = 1.2; // 화면에 보이는 시간창(초)
var SCROLL = 0.6; // 파형이 흘러가는 속도(초/초)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(sec) {
var mid = H * 0.5;
var scale = H * 0.19;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = RAIL;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, mid + 0.5); ctx.lineTo(W, mid + 0.5);
ctx.stroke();
var t0 = sec * SCROLL;
// 합성 파형 — 두 사인의 합
ctx.strokeStyle = WAVE;
ctx.lineWidth = 2;
ctx.beginPath();
for (var x = 0; x <= W; x += 2) {
var t = t0 + (x / W) * WINDOW;
var v = Math.sin(2 * Math.PI * F1 * t) + Math.sin(2 * Math.PI * F2 * t);
var y = mid - v * scale;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 포락선 — |cos(2π·Δf/2·t)|·2 가 순간 최대 진폭이다. 위·아래로 대칭해 감싸는 곡선을 그린다
ctx.strokeStyle = ENV;
ctx.lineWidth = 1.6;
ctx.setLineDash([5, 4]);
ctx.beginPath();
for (var xi = 0; xi <= W; xi += 2) {
var ti = t0 + (xi / W) * WINDOW;
var env = Math.abs(Math.cos(Math.PI * (F1 - F2) * ti)) * 2;
var ye = mid - env * scale;
if (xi === 0) ctx.moveTo(xi, ye); else ctx.lineTo(xi, ye);
}
ctx.stroke();
ctx.beginPath();
for (var xj = 0; xj <= W; xj += 2) {
var tj = t0 + (xj / W) * WINDOW;
var envb = Math.abs(Math.cos(Math.PI * (F1 - F2) * tj)) * 2;
var yeb = mid + envb * scale;
if (xj === 0) ctx.moveTo(xj, yeb); else ctx.lineTo(xj, yeb);
}
ctx.stroke();
ctx.setLineDash([]);
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.15); });
if (reduce) {
// reduce — 루프 없이 한 순간의 파형·엔벨로프 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.15);
} else {
raf = requestAnimationFrame(frame);
}// BeatEnvelope — vanilla 판(run/303/demo/05-wave-beat-envelope/index.html) 의 React 포트.
// 맥놀이 진폭 엔벨로프 — 주파수가 살짝 다른(9Hz·10Hz) 두 Math.sin 파를 더하면 맥놀이가 생긴다.
// requestAnimationFrame 루프에서 합성 파형을 그리고, strokeStyle 을 바꿔 가며 포락선(envelope)
// 곡선을 따로 그려 진폭이 주기적으로 커졌다 작아지는 것을 보여준다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BeatEnvelope.module.scss";
export type BeatEnvelopeProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "주파수가 살짝 다른 두 파동이 만나 커졌다 작아졌다 합니다";
const FIELD_LABEL = "맥놀이 파형 실험판";
const BADGE_TEXT = "9·10";
const BADGE_UNIT = "Hz";
const F1 = 9, F2 = 10; // 두 파동의 주파수(Hz) — 차이 1Hz 가 맥놀이 주기를 만든다
const WINDOW = 1.2; // 화면에 보이는 시간창(초)
const SCROLL = 0.6; // 파형이 흘러가는 속도(초/초)
export default function BeatEnvelope(props: BeatEnvelopeProps) {
const duration = props.duration ?? 2;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const accentColor = props.accentColor ?? "#ff9ebb";
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": duration + "s", "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const wave = css.getPropertyValue("--wave").trim();
const env = css.getPropertyValue("--env").trim();
const rail = css.getPropertyValue("--rail").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
function fit() {
const r = field!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(sec: number) {
const mid = H * 0.5;
const scale = H * 0.19;
ctx!.clearRect(0, 0, W, H);
ctx!.strokeStyle = rail;
ctx!.lineWidth = 1;
ctx!.beginPath();
ctx!.moveTo(0, mid + 0.5); ctx!.lineTo(W, mid + 0.5);
ctx!.stroke();
const t0 = sec * SCROLL;
// 합성 파형 — 두 사인의 합
ctx!.strokeStyle = wave;
ctx!.lineWidth = 2;
ctx!.beginPath();
for (let x = 0; x <= W; x += 2) {
const t = t0 + (x / W) * WINDOW;
const v = Math.sin(2 * Math.PI * F1 * t) + Math.sin(2 * Math.PI * F2 * t);
const y = mid - v * scale;
if (x === 0) ctx!.moveTo(x, y); else ctx!.lineTo(x, y);
}
ctx!.stroke();
// 포락선 — |cos(2π·Δf/2·t)|·2 가 순간 최대 진폭이다. 위·아래로 대칭해 감싸는 곡선을 그린다
ctx!.strokeStyle = env;
ctx!.lineWidth = 1.6;
ctx!.setLineDash([5, 4]);
ctx!.beginPath();
for (let xi = 0; xi <= W; xi += 2) {
const ti = t0 + (xi / W) * WINDOW;
const envV = Math.abs(Math.cos(Math.PI * (F1 - F2) * ti)) * 2;
const ye = mid - envV * scale;
if (xi === 0) ctx!.moveTo(xi, ye); else ctx!.lineTo(xi, ye);
}
ctx!.stroke();
ctx!.beginPath();
for (let xj = 0; xj <= W; xj += 2) {
const tj = t0 + (xj / W) * WINDOW;
const envb = Math.abs(Math.cos(Math.PI * (F1 - F2) * tj)) * 2;
const yeb = mid + envb * scale;
if (xj === 0) ctx!.moveTo(xj, yeb); else ctx!.lineTo(xj, yeb);
}
ctx!.stroke();
ctx!.setLineDash([]);
}
function frame(now: number) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) draw(0.15);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 한 순간의 파형·엔벨로프 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
draw(0.15);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
root.classList.remove("is-still");
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={styles.be} style={vars}>
<p className={styles["be__hint"]}>
<svg className={styles["be__ico"]} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M24 128c16-64 40-64 56 0s40 64 56 0s40-64 56 0s40 64 56 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round"/></g></svg>
<span>{HINT}</span>
</p>
<div ref={fieldRef} className={styles["be__field"]} role="application" aria-label={FIELD_LABEL}>
<canvas ref={cvRef} className={styles["be__cv"]}></canvas>
<b className={styles["be__badge"]} aria-hidden="true"><span className={styles["be__badgeNum"]}>{BADGE_TEXT}</span><span className={styles["be__badgeUnit"]}>{BADGE_UNIT}</span></b>
</div>
</div>
</main>
);
}05 うなり振幅エンベロープ — 計測 累積面積 42.926% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · リサージュ曲線 作り方9選 — 波の干渉で描くキャンバス
このデモが属する記事
リサージュ曲線 作り方9選 — 波の干渉で描くキャンバスリサージュ曲線とは、周波数の異なる二つのサイン波をX軸とY軸にそれぞれ与えたときにペンが描く閉曲線で、オシロスコープの画面で位相差を目で確かめるために使われる図形です。ここではその原理をMath.sinとキャンバスだけで九つの場面に広げ、す この記事のデモをすべて見る


