├── git ├── FETCH_HEAD ├── public ├── robots.txt ├── manifest.json └── index.html ├── .gitattributes ├── src ├── assets │ ├── 20.png │ ├── 40.png │ ├── 60.png │ ├── 80.png │ ├── 100.png │ ├── cake1.png │ ├── cake2.png │ ├── cake3.png │ ├── bdayaudo.mp3 │ ├── matthew.jpg │ └── birthdaytext.png ├── setupTests.js ├── App.test.js ├── index.js ├── index.css ├── reportWebVitals.js ├── Confetti.js ├── App.css ├── PixelAnimator.js └── App.js ├── .gitignore ├── README.md └── package.json /git: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /FETCH_HEAD: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.mp3 filter=lfs diff=lfs merge=lfs -text 2 | *.jpg filter=lfs diff=lfs merge=lfs -text 3 | *.png filter=lfs diff=lfs merge=lfs -text 4 | -------------------------------------------------------------------------------- /src/assets/20.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f902a4396c205375b5e6d9c020edf7cad7e9f5af2af472471bce83a70f8ff15b 3 | size 5268 4 | -------------------------------------------------------------------------------- /src/assets/40.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:213c78a243051385abc693fb93732cffc20bcadb1e508cbc498feb6974b7b150 3 | size 5297 4 | -------------------------------------------------------------------------------- /src/assets/60.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:34f1352a448e2438c51011f9c0fa9b13a594067b08bc6eddcb4cacbf3d63674f 3 | size 5314 4 | -------------------------------------------------------------------------------- /src/assets/80.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:60dbbba0428dbdc3d0a2309c2a326ac42d1230d6ab198eab7439edeb2a2a4689 3 | size 5335 4 | -------------------------------------------------------------------------------- /src/assets/100.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:25a3480c9f92d2473dea2fafc2415ab727610706cb6d4a944d39c1fce3187113 3 | size 5380 4 | -------------------------------------------------------------------------------- /src/assets/cake1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3293a603c4f335b5d4f036187ff529af1acbf634c00034cd65bff4185e3ea8aa 3 | size 5425 4 | -------------------------------------------------------------------------------- /src/assets/cake2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a13fa5aa92cad0a58b6b7d5456efaaeaeffc2b48d939e4d74d9732274be40512 3 | size 5392 4 | -------------------------------------------------------------------------------- /src/assets/cake3.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:eb62d1b369267427161c235e8e8395814a90ef534038c21d44d65ac62879d4c1 3 | size 5403 4 | -------------------------------------------------------------------------------- /src/assets/bdayaudo.mp3: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b6d10857d658cf231e061a79b9939227fea8cc4353767cc9a611a715c262b11e 3 | size 1464039 4 | -------------------------------------------------------------------------------- /src/assets/matthew.jpg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0123d7cdad4077ef4117c43fd98a6a147af52153cc5f3fa03faca540f92a17c7 3 | size 513485 4 | -------------------------------------------------------------------------------- /src/assets/birthdaytext.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f3a77eff03d7e3c0f75f0bd3617ac041e89b486907419f0cfca6d61562111c74 3 | size 246366 4 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | reportWebVitals(); 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Cute Birthday Cake App I made for my boyfriend : ) 2 | 3 | ## Here are the steps to use this 4 | 5 | #### Cloning 6 | `git clone https://github.com/tinabyte/happybdaymonkey` 7 | 8 | #### Installing dependencies 9 | go to project directory and `npm i` or `npm install` 10 | 11 | #### Running the project 12 | go to project directory and `npm start` 13 | 14 | 15 | 16 | 17 | ## Follow my github to follow along my projects (^_−)−☆ 18 | 19 | 20 | https://github.com/user-attachments/assets/d2a3a3c2-eba5-49c0-9bc6-bb4a0c01784b 21 | 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Birthday Card", 3 | "name": "Birthday Card", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matthew-bday-cake", 3 | "version": "0.1.0", 4 | "homepage": "https://tinabyte.github.io/bdaycakee", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/dom": "^10.4.1", 8 | "@testing-library/jest-dom": "^6.8.0", 9 | "@testing-library/react": "^16.3.0", 10 | "@testing-library/user-event": "^13.5.0", 11 | "react": "^19.1.1", 12 | "react-dom": "^19.1.1", 13 | "react-scripts": "5.0.1", 14 | "web-vitals": "^2.1.4" 15 | }, 16 | "scripts": { 17 | "predeploy": "npm run build", 18 | "deploy": "gh-pages -d build", 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "gh-pages": "^6.3.0" 44 | } 45 | } -------------------------------------------------------------------------------- /src/Confetti.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import "./App.css"; 3 | 4 | export default function Confetti({ pieces = 30, duration = 8000, onDone }) { 5 | useEffect(() => { 6 | if (!onDone) return; 7 | const t = setTimeout(() => onDone(), duration); 8 | return () => clearTimeout(t); 9 | }, [duration, onDone]); 10 | 11 | const colors = ["#ff3b30", "#ff9500", "#ffcc00", "#4cd964", "#5ac8fa", "#007aff", "#5856d6"]; 12 | 13 | return ( 14 |
15 | {Array.from({ length: pieces }).map((_, i) => { 16 | const left = Math.round(Math.random() * 100); 17 | const delay = Math.random() * 2; 18 | const dur = 3 + Math.random() * 3; // 3-6s 19 | const color = colors[i % colors.length]; 20 | const size = 6 + Math.round(Math.random() * 10); 21 | return ( 22 | 35 | ); 36 | })} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | html, 4 | body, 5 | #root { 6 | height: 100%; 7 | margin: 0; 8 | } 9 | 10 | .App { 11 | min-height: 100vh; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | background-color: black; 16 | } 17 | 18 | .cakeLoop { 19 | display: inline-flex; 20 | align-items: center; 21 | justify-content: center; 22 | flex-direction: column; 23 | gap: 12px; 24 | user-select: none; 25 | } 26 | 27 | .cakeLoop img, 28 | .cakeLoop canvas { 29 | image-rendering: pixelated; 30 | image-rendering: crisp-edges; 31 | image-rendering: -moz-crisp-edges; 32 | -ms-interpolation-mode: nearest-neighbor; 33 | display: block; 34 | width: auto; 35 | height: auto; 36 | max-width: 100%; 37 | max-height: 100%; 38 | 39 | } 40 | 41 | .birthdayText { 42 | width: auto; 43 | height: auto; 44 | max-width: 100vw; 45 | max-height: 30vh; 46 | image-rendering: pixelated; 47 | } 48 | 49 | :root { 50 | --cake-overlap: 24px; 51 | --cake-offset-x: 0px; 52 | /* horizontal offset for the cake (positive moves right) */ 53 | --birthday-max-width: 10vw; 54 | /* default max width for birthday text */ 55 | } 56 | 57 | .cake { 58 | position: relative; 59 | transform: translateY(calc(-1 * var(--cake-overlap))) translateX(var(--cake-offset-x)); 60 | z-index: 2; 61 | padding-right: 200px; 62 | } 63 | .cake, 64 | .cake img, 65 | .cake canvas { 66 | cursor: crosshair; 67 | } 68 | 69 | .birthdayText { 70 | z-index: 1; 71 | max-width: 900px; 72 | max-height: 900px; 73 | position: relative; 74 | } 75 | 76 | .confetti-root { 77 | pointer-events: none; 78 | position: fixed; 79 | inset: 0 0 0 0; 80 | overflow: hidden; 81 | z-index: 9999; 82 | } 83 | 84 | .confetti-piece { 85 | position: absolute; 86 | top: -10vh; 87 | border-radius: 2px; 88 | opacity: 0.95; 89 | will-change: transform, top; 90 | animation-name: confetti-fall; 91 | animation-timing-function: linear; 92 | animation-iteration-count: 1; 93 | } 94 | 95 | @keyframes confetti-fall { 96 | 0% { 97 | transform: translateY(-10vh) rotate(0deg); 98 | opacity: 1; 99 | } 100 | 101 | 70% { 102 | opacity: 1; 103 | } 104 | 105 | 100% { 106 | transform: translateY(110vh) rotate(360deg); 107 | opacity: 0.85; 108 | } 109 | } 110 | 111 | .matthew-overlay { 112 | position: fixed; 113 | inset: 0 0 0 0; 114 | display: flex; 115 | align-items: center; 116 | justify-content: center; 117 | background: rgba(0, 0, 0, 0.65); 118 | z-index: 10000; 119 | pointer-events: auto; 120 | } 121 | 122 | .matthew-card { 123 | background: transparent; 124 | border-radius: 12px; 125 | padding: 8px; 126 | display: inline-flex; 127 | align-items: center; 128 | justify-content: center; 129 | transform-origin: center center; 130 | animation: matthew-pop 540ms cubic-bezier(.2, .8, .2, 1) forwards; 131 | } 132 | 133 | .matthew-card img { 134 | display: block; 135 | max-width: 80vw; 136 | max-height: 80vh; 137 | border-radius: 8px; 138 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); 139 | } 140 | 141 | @keyframes matthew-pop { 142 | 0% { 143 | transform: scale(0.6) translateY(8px); 144 | opacity: 0; 145 | } 146 | 147 | 60% { 148 | transform: scale(1.05) translateY(-6px); 149 | opacity: 1; 150 | } 151 | 152 | 100% { 153 | transform: scale(1) translateY(0); 154 | opacity: 1; 155 | } 156 | } -------------------------------------------------------------------------------- /src/PixelAnimator.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; 2 | 3 | 4 | export default function PixelAnimator({ 5 | frames, 6 | fps = 8, 7 | scale = 1, 8 | mode = "canvas", 9 | width, 10 | height, 11 | className, 12 | ...domProps 13 | }) { 14 | const [ready, setReady] = useState(false); 15 | const [naturalSize, setNaturalSize] = useState({ w: 0, h: 0 }); 16 | 17 | const images = useMemo(() => frames.map((src) => { 18 | const img = new Image(); 19 | img.src = src; 20 | img.decoding = "async"; 21 | img.loading = "eager"; 22 | img.crossOrigin = "anonymous"; 23 | return img; 24 | }), [frames]); 25 | 26 | useEffect(() => { 27 | let cancelled = false; 28 | Promise.all( 29 | images.map( 30 | (img) => 31 | new Promise((res, rej) => { 32 | if (img.complete && img.naturalWidth) return res(true); 33 | img.onload = () => res(true); 34 | img.onerror = (e) => rej(e); 35 | }) 36 | ) 37 | ) 38 | .then(() => { 39 | if (cancelled) return; 40 | setNaturalSize({ w: images[0].naturalWidth, h: images[0].naturalHeight }); 41 | setReady(true); 42 | }) 43 | .catch(() => setReady(false)); 44 | return () => { 45 | cancelled = true; 46 | }; 47 | }, [images]); 48 | 49 | if (!ready || images.length === 0) { 50 | return
Loading…
; 51 | } 52 | 53 | return mode === "img" ? ( 54 | 64 | ) : ( 65 | 75 | ); 76 | } 77 | 78 | function ImgAnimator({ images, fps, scale, width, height, className, naturalSize, ...domProps }) { 79 | const [idx, setIdx] = useState(0); 80 | 81 | useEffect(() => { 82 | setIdx(0); 83 | }, [images.length]); 84 | const rafRef = useRef(0); 85 | const accRef = useRef(0); 86 | const lastRef = useRef(performance.now()); 87 | 88 | useEffect(() => { 89 | const frameDur = 1000 / fps; 90 | const loop = (t) => { 91 | const dt = t - lastRef.current; 92 | lastRef.current = t; 93 | accRef.current += dt; 94 | while (accRef.current >= frameDur) { 95 | setIdx((i) => (i + 1) % images.length); 96 | accRef.current -= frameDur; 97 | } 98 | rafRef.current = requestAnimationFrame(loop); 99 | }; 100 | rafRef.current = requestAnimationFrame(loop); 101 | return () => cancelAnimationFrame(rafRef.current); 102 | }, [fps, images.length]); 103 | 104 | const cssW = width ?? Math.round(naturalSize.w * scale); 105 | const cssH = height ?? Math.round(naturalSize.h * scale); 106 | const currentImg = images[idx] ?? images[0]; 107 | 108 | return ( 109 | pixel frame 122 | ); 123 | } 124 | 125 | function CanvasAnimator({ images, fps, scale, width, height, className, naturalSize, ...domProps }) { 126 | const canvasRef = useRef(null); 127 | const idxRef = useRef(0); 128 | const rafRef = useRef(0); 129 | const accRef = useRef(0); 130 | const lastRef = useRef(performance.now()); 131 | 132 | useEffect(() => { 133 | const canvas = canvasRef.current; 134 | if (!canvas) return; 135 | const ctx = canvas.getContext("2d", { alpha: true }); 136 | 137 | const dpr = window.devicePixelRatio || 1; 138 | const targetW = (width ?? naturalSize.w * scale) | 0; 139 | const targetH = (height ?? naturalSize.h * scale) | 0; 140 | 141 | canvas.style.width = targetW + "px"; 142 | canvas.style.height = targetH + "px"; 143 | 144 | canvas.width = Math.round(targetW * dpr); 145 | canvas.height = Math.round(targetH * dpr); 146 | 147 | ctx.imageSmoothingEnabled = false; 148 | 149 | const frameDur = 1000 / fps; 150 | 151 | const draw = () => { 152 | ctx.clearRect(0, 0, canvas.width, canvas.height); 153 | const sx = (canvas.width / images[0].naturalWidth) | 0; 154 | const sy = (canvas.height / images[0].naturalHeight) | 0; 155 | const s = Math.max(1, Math.min(sx, sy)); 156 | const dw = images[0].naturalWidth * s; 157 | const dh = images[0].naturalHeight * s; 158 | const dx = ((canvas.width - dw) / 2) | 0; 159 | const dy = ((canvas.height - dh) / 2) | 0; 160 | 161 | ctx.drawImage(images[idxRef.current], 0, 0, images[0].naturalWidth, images[0].naturalHeight, dx, dy, dw, dh); 162 | }; 163 | 164 | const loop = (t) => { 165 | const dt = t - lastRef.current; 166 | lastRef.current = t; 167 | accRef.current += dt; 168 | while (accRef.current >= frameDur) { 169 | idxRef.current = (idxRef.current + 1) % images.length; 170 | accRef.current -= frameDur; 171 | } 172 | draw(); 173 | rafRef.current = requestAnimationFrame(loop); 174 | }; 175 | 176 | rafRef.current = requestAnimationFrame(loop); 177 | return () => cancelAnimationFrame(rafRef.current); 178 | }, [fps, images, scale, width, height, naturalSize.w, naturalSize.h]); 179 | 180 | return ( 181 | 189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import PixelAnimator from "./PixelAnimator"; 2 | import cake1 from "./assets/cake1.png"; 3 | import cake2 from "./assets/cake2.png"; 4 | import cake3 from "./assets/cake3.png"; 5 | import cake100 from "./assets/100.png"; 6 | import cake80 from "./assets/80.png"; 7 | import cake60 from "./assets/60.png"; 8 | import cake40 from "./assets/40.png"; 9 | import cake20 from "./assets/20.png"; 10 | import birthdayText from "./assets/birthdaytext.png"; 11 | import "./App.css"; 12 | import Confetti from "./Confetti"; 13 | import { useEffect, useRef, useState } from "react"; 14 | import birthdaySong from "./assets/bdayaudo.mp3"; 15 | 16 | 17 | export default function App() { 18 | const audioRef = useRef(null); 19 | const [staticFrame, setStaticFrame] = useState(null); 20 | 21 | const micStreamRef = useRef(null); 22 | const audioCtxRef = useRef(null); 23 | const analyserRef = useRef(null); 24 | const gainNodeRef = useRef(null); 25 | const rafRef = useRef(null); 26 | 27 | 28 | useEffect(() => { 29 | const playAudio = async () => { 30 | try { 31 | await audioRef.current.play(); 32 | } catch (err) { 33 | console.log("Autoplay blocked, waiting for user interaction:", err); 34 | } 35 | }; 36 | playAudio(); 37 | }, []); 38 | 39 | useEffect(() => { 40 | startMicMonitoring(); 41 | return () => { 42 | stopMicMonitoring(); 43 | }; 44 | }, []); 45 | const handleCakeClick = async () => { 46 | const audio = audioRef.current; 47 | audio.play(); 48 | }; 49 | 50 | const pickStaticFrame = (rms) => { 51 | if (rms < 0.02) return null; 52 | if (rms >= 0.30) return cake20; 53 | if (rms >= 0.22) return cake40; 54 | if (rms >= 0.15) return cake60; 55 | if (rms >= 0.08) return cake80; 56 | return cake100; 57 | }; 58 | 59 | const startMicMonitoring = async () => { 60 | if (micStreamRef.current) return; 61 | try { 62 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 63 | micStreamRef.current = stream; 64 | 65 | const AudioContext = window.AudioContext || window.webkitAudioContext; 66 | const audioCtx = new AudioContext(); 67 | audioCtxRef.current = audioCtx; 68 | 69 | const source = audioCtx.createMediaStreamSource(stream); 70 | const gainNode = audioCtx.createGain(); 71 | const sensitivity = 3.0; 72 | gainNode.gain.value = sensitivity; 73 | gainNodeRef.current = gainNode; 74 | 75 | const analyser = audioCtx.createAnalyser(); 76 | analyser.fftSize = 2048; 77 | analyserRef.current = analyser; 78 | source.connect(gainNode); 79 | gainNode.connect(analyser); 80 | 81 | const data = new Float32Array(analyser.fftSize); 82 | 83 | const loop = () => { 84 | analyser.getFloatTimeDomainData(data); 85 | let sum = 0; 86 | for (let i = 0; i < data.length; i++) { 87 | const v = data[i]; 88 | sum += v * v; 89 | } 90 | const rms = Math.sqrt(sum / data.length); 91 | 92 | try { console.debug('mic rms=', rms.toFixed(4)); } catch (e) {} 93 | const chosen = pickStaticFrame(rms); 94 | setStaticFrame((prev) => { 95 | if (prev === chosen) return prev; 96 | return chosen; 97 | }); 98 | rafRef.current = requestAnimationFrame(loop); 99 | }; 100 | 101 | rafRef.current = requestAnimationFrame(loop); 102 | } catch (err) { 103 | console.warn("Microphone access denied or failed:", err); 104 | } 105 | }; 106 | 107 | const [celebrating, setCelebrating] = useState(false); 108 | const [showMatthew, setShowMatthew] = useState(false); 109 | let matthewSrc = null; 110 | try { 111 | matthewSrc = require("./assets/matthew.jpg"); 112 | } catch (e) { 113 | matthewSrc = null; 114 | } 115 | useEffect(() => { 116 | if (staticFrame === cake20) { 117 | stopMicMonitoring(false); 118 | setCelebrating(true); 119 | } 120 | }, [staticFrame]); 121 | 122 | 123 | const stopMicMonitoring = (resetAnimation = true) => { 124 | if (rafRef.current) { 125 | cancelAnimationFrame(rafRef.current); 126 | rafRef.current = null; 127 | } 128 | if (analyserRef.current) { 129 | try { 130 | analyserRef.current.disconnect(); 131 | if (gainNodeRef.current) { 132 | try { gainNodeRef.current.disconnect(); } catch (e) {} 133 | gainNodeRef.current = null; 134 | } 135 | } catch (e) {} 136 | analyserRef.current = null; 137 | } 138 | if (audioCtxRef.current) { 139 | try { 140 | audioCtxRef.current.close(); 141 | } catch (e) {} 142 | audioCtxRef.current = null; 143 | } 144 | if (micStreamRef.current) { 145 | micStreamRef.current.getTracks().forEach((t) => t.stop()); 146 | micStreamRef.current = null; 147 | } 148 | if (resetAnimation) { 149 | setStaticFrame(null); 150 | } 151 | }; 152 | 153 | return ( 154 |
155 |
221 | ); 222 | } 223 | --------------------------------------------------------------------------------