איך בונים כלי נגינה מאוויר ואור: מיתרים של Canvas, סינתזה ב-WebAudio, וחלקיקים שנולדים מכל צליל. זהו הפירוק המלא — קונספט, מתמטיקה, קוד.
נבל הרוחות הוא שירה חזותית, לא דמו טכני: מסך שלם הוא גוף התהודה, והמבקר הוא הרוח. כשהסמן חוצה מיתר — המיתר רוטט, צליל נולד, וזרם של גחליליות אור עולה מנקודת הפריטה. גם כשאיש לא נוגע, משב רוח אוטומטי חולף מדי כמה שניות ופורט ארפג׳ו עדין, כך שהדף לעולם אינו מת.
שלוש החלטות עיצוב מרכזיות:
#060a18#0d1b33#f0c674#6fe7ff#8a7bff#dfe9ffהרקע אינו שחור שטוח: שלוש שכבות של radial-gradient ועננות CSS מטושטשת (filter: blur(70px)) שנעה לאט יוצרות עומק אמיתי, ומעל הכול שכבת גרעון (grain) מ-SVG מוטמע בשקיפות 5% שמונעת מהגרדיאנטים להיראות פלסטיים.
הטיפוגרפיה: Frank Ruhl Libre 900 לכותרת — סריף עברי בעל נוכחות טקסית, עם גרדיאנט זהב חתוך לטקסט — ו-Heebo 300 לכל השאר, קליל כמו אוויר.
כל מיתר הוא קו־בזייה מדוגם ב-22 נקודות. ההיסט האופקי של כל נקודה מורכב משלושה רכיבים: גל עמידה של הרטט אחרי פריטה, נדנוד רוח איטי, ומעטפת צורה שמוודאת שהקצוות מעוגנים.
// גל עמידה: sin(πq) הוא אופן־התנודה הראשון — אפס בקצוות, שיא באמצע const shape = Math.sin(q * Math.PI); // רוח: שני סינוסים בתדרים זרים זה לזה = תנועה אורגנית שאינה חוזרת על עצמה const sway = (Math.sin(t * .42 + s.phase * 3) + Math.sin(t * .23 + s.t * 5) * .6) * swayAmp; // רטט הפריטה: אמפליטודה דועכת אקספוננציאלית, מאופננת באופן שני const vib = s.amp * Math.sin(t * s.vibSpeed * 2 + s.phase); const x = s.x + sway * shape + vib * shape * Math.sin(q * Math.PI * 2 + t * 3);
הדעיכה מתבצעת פר־פריים באופן בלתי־תלוי בקצב הרינדור: s.amp *= Math.exp(-dt * 2.0). הזוהר מושג בלי shadowBlur היקר — פשוט משרטטים את אותו path שלוש פעמים: הילה רחבה ושקופה, שכבת ביניים, וליבה דקה ובוהקת. מיתר שנפרט זה עתה "בוער" — ה-alpha והבהירות שלו מוגברים לפי heat = amp / 12.
לא בודקים מרחק מהמיתר אלא חצייה: אם המכפלה (prevX - sx) * (currX - sx) שלילית, הסמן עבר מצד לצד. עוצמת הפריטה נגזרת ממהירות התנועה:
if ((px - s.x) * (x - s.x) < 0) { const speed = Math.min(1, Math.hypot(x - px, y - py) / 46); pluck(s, y, speed); // עוצמה → אמפליטודה, ווליום, כמות חלקיקים }
אין ולו דגימת אודיו אחת — הכול פרוצדורלי. כל פריטה בונה קול (voice) חד־פעמי:
osc1 (triangle, freq) ──┐
├─► lowpass ──► env gain ──► panner ──┬─► master ─► compressor ─► out
osc2 (sine, freq×2) ─►g─┘ 4900→500Hz exp decay ├─► convolver (hall 2.8s)
└─► delay 290ms + feedback
// impulse response פרוצדורלי — אולם קר ואוורירי בחמש שורות const len = Math.floor(sampleRate * 2.8); const ir = ctx.createBuffer(2, len, sampleRate); for (let ch = 0; ch < 2; ch++) { const d = ir.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6); } convolver.buffer = ir;
המיפוי המוזיקלי: 14 מיתרים על פנטטוני מז׳ורי מ-D3, freq = 146.83 · 2^((oct·12+step)/12). הפאן הסטריאופוני נגזר ממיקום המיתר על המסך — מה שנשמע משמאל באמת נמצא משמאל.
כלל הזהב של אודיו בדפדפן: ה-AudioContext נוצר ומופעל רק אחרי מחוות משתמש אמיתית (לחיצה/מגע/מקש). עד אז הכלי חי ויזואלית בלבד, וכפתור "צליל" פועם בעדינות כהזמנה. פוליפוניה מוגבלת ל-22 קולות במקביל וכולם עוברים דרך compressor — אי אפשר להחריש אוזניים.
כל פריטה פולטת 8–24 חלקיקים (לפי העוצמה) מנקודת המגע. כל חלקיק הוא ספרייט של גרדיאנט רדיאלי שצויר פעם אחת מראש לכל גוון מיתר, ומצויר עם globalCompositeOperation = 'lighter' — חיבור אדיטיבי שגורם לחלקיקים חופפים לבעור כאור אמיתי.
// פיזיקת גחלילית: ציפה כלפי מעלה + סחרור סינוסי + רוח גלובלית p.swirl += dt * 2; p.vx += Math.sin(p.swirl) * 14 * dt + windDir * 26 * dt; p.vy += -14 * dt; // אור עולה, לא נופל ctx.globalAlpha = Math.pow(p.life, 1.6); // דעיכה לא-ליניארית
התקציב קשיח: עד 620 חלקיקים בדסקטופ, 320 במובייל, 220 תחת prefers-reduced-motion. כשהמאגר מלא — החלקיק הוותיק ביותר מוחלף. בנוסף מרחפים ברקע ~34 גרגרי אבק איטיים, כדי שהאוויר לעולם לא יהיה ריק.
מכונת מצבים קטנה: כל 7–16 שניות נולד משב שחוצה את המסך בכיוון אקראי במהירות של רוחב־מסך לכל ~3 שניות. כשהוא חוצה מיתר — פריטה עדינה (עוצמה 0.12–0.22). התוצאה: ארפג׳ו עולה או יורד, תמיד הרמוני בזכות הפנטטוני. בזמן משב, גם האבק מאיץ באותו כיוון והנדנוד של המיתרים גובר — המערכת כולה נושמת יחד.
if (gust.active) { const prev = gust.x; gust.x += gust.dir * gust.speed * dt; for (const s of strings) if ((prev - s.x) * (gust.x - s.x) <= 0) // המשב חצה את המיתר pluck(s, randomY, .12 + Math.random() * .1); }
| החלטה | איך |
|---|---|
| אווירת רקע זולה | עננות ו-vignette הם CSS על שכבות נפרדות — ה-GPU מרנדר, ה-Canvas נשאר פנוי למיתרים ולחלקיקים בלבד |
| זוהר בלי blur | שלושה strokes בעוביים שונים במקום shadowBlur (יקר פי ~10) |
| רזולוציה | devicePixelRatio מוגבל ל-2; קנבס אחד בלבד |
| ספרייטים | גרדיאנט רדיאלי מחושב פעם אחת לכל גוון, אחר כך רק drawImage |
| reduced-motion | ללא משבים אוטומטיים, פחות חלקיקים, ללא אנימציות רקע |
| מקלדת | המקשים 1–9 ו-0 פורטים על עשרת המיתרים הראשונים |
כמעט הכול (רקע, גרעון, ספרייטים, אימפולס הריוורב, סרטי הזוהר) נוצר בקוד בזמן ריצה. נכס חיצוני יחיד: תמונת AI אחת של הנבל האאולי (סעיף 12), ו-GSAP ל-scroll reveals.
הגרסה הראשונה הייתה מסך אחד נעול (overflow:hidden). בהרחבה נבנה סביבו אתר שלם, בלי לוותר על הריק:
backdrop-filter) וקו תחתון אחרי 30px גלילה. עוגנים: איך מנגנים / על הכלי / הסינתזה / הד.position:fixed לסקשן בגובה 100svh; קואורדינטות המצביע מתורגמות דרך getBoundingClientRect(), ו-touch-action:pan-y מאפשר גלילה במובייל תוך כדי פריטה אופקית.position:fixed — כל האתר צף באותו לילה, והסקשנים שקופים מעליו.[data-reveal] עולה 36px ומתבהר פעם אחת ב-top 88%. ה-hero דוהה ונוסק מעט ביציאה (scrub). בלי GSAP או עם reduced-motion — הכול פשוט נראה.הביקורת על הגרסה הראשונה: "מיתרים צפים בריק". התשובה — לגלות את הרוח עצמה, בשלוש שכבות:
שלושה עקומים חוצי־מסך, כל אחד סכום שני סינוסים בתדרים זרים. כל סרט משורטט שלוש פעמים ב-lighter — פס רחב 110px באלפא 0.027, ביניים 38px, וליבה 7px — עם גרדיאנט אופקי ציאן↔סגול↔זהב שנבנה פעם אחת ב-resize:
const y = H * r.base + Math.sin(x * r.k1 + t * r.s1 * (gust.active ? 2.2 : 1) + r.ph) * r.a1 * boost + Math.sin(x * r.k2 - t * r.s2 + r.ph * 2) * r.a2; // בזמן משב: boost = 1.7 — הרקיע עצמו מאיץ
גרגרי האבק הפכו מנקודות לפסים קצרים שאורכם נגזר מהמהירות — בזמן משב הם נמתחים לכיוון הרוח, כמו חשיפה ארוכה בצילום.
כל פריטה משחררת אליפסה מתרחבת (ellipse ביחס 1:0.62) שרדיוסה מתכנס אקספוננציאלית אל מקסימום התלוי בעוצמה, ודוהה תוך ~0.7s. עד 14 טבעות חיות בו־זמנית.
כל עוד המבקר לא נגע, אחת ל-11–17 שניות חוצה כתם אור קטן את המיתרים בגובה משתנה ופורט ויזואלית בלבד (ללא צליל) — הדגמה שקטה של המחווה. אחרי האינטראקציה הראשונה היא נעלמת לתמיד. תחת reduced-motion היא לא קיימת.
כפתור "הד" הוא מכונת מצבים בת שלושה מצבים: idle → rec → play. בזמן הקלטה כל פריטת משתמש (לא רוח, לא הד) נשמרת כאירוע {i, y, power, dt} — אינדקס מיתר, גובה, עוצמה וזמן יחסי. עצירה (או 12 שניות) חותמת את הלולאה:
const loopT = (t - echo.playStart) % echo.dur; if (loopT < echo.prevLoopT) echo.next = 0; // הלולאה נכרכה — מתחילים מחדש while (echo.next < events.length && events[echo.next].dt <= loopT) pluck(strings[events[echo.next++].i], ev.y, ev.power, false, true);
הדגל החמישי (fromEcho) מונע מההד להקליט את עצמו — אחרת כל לולאה הייתה מכפילה את עצמה עד אינסוף. משך הלולאה: האירוע האחרון + 1.3 שניות נשימה, מינימום 2 שניות. אם המסך הוקטן ומספר המיתרים ירד, האינדקס נחתך ב-Math.min — ההד מתעוות בחן במקום לקרוס.
סקשן "על הכלי" מספר את סיפור הנבל האאולי — הכלי האמיתי שהרוח פורטת עליו. כאן תמונה מביאה מה שקנבס לא: משקל היסטורי. נוצרה עם FLUX dev (fal.ai), 1344×768, ~175KB, נטענת ב-loading="lazy". הפרומפט המלא:
"Ancient aeolian wind harp standing alone on a dark basalt cliff
at night, tall elegant arched wooden frame, taut strings glowing
faint warm gold, ribbons of aurora light in cyan and soft violet
flowing horizontally through deep indigo sky, scattered stars,
thin silver mist at the base, cinematic wide shot, ethereal
painterly digital art, rim-lit in gold, palette of deep navy
#060a18 cyan #6fe7ff violet #8a7bff gold #f0c674,
no people, no text"
שימו לב לעיגון הפלטה בקודי hex בתוך הפרומפט — כך התמונה נוחתת בעולם הצבע של האתר במקום להילחם בו, וסרטי הזוהר שבשמיים שלה מהדהדים את סרטי הקנבס שבבמה.