├── 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 |
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 |
156 |

157 |
158 | {staticFrame ? (
159 |
{
169 | if (e.key === "Enter" || e.key === " ") handleCakeClick();
170 | }}
171 | />
172 | ) : (
173 | {
183 | if (e.key === "Enter" || e.key === " ") handleCakeClick();
184 | }}
185 | />
186 | )}
187 |
188 | {celebrating && (
189 |
{
193 | setCelebrating(false);
194 | setTimeout(() => setShowMatthew(true), 250);
195 | }}
196 | />
197 | )}
198 |
199 | {showMatthew && (
200 | setShowMatthew(false)}
203 | onKeyDown={(e) => {
204 | if (e.key === "Escape") setShowMatthew(false);
205 | }}
206 | role="dialog"
207 | tabIndex={-1}
208 | >
209 |
210 | {matthewSrc ? (
211 |

212 | ) : (
213 |
214 | Matthew
215 |
216 | )}
217 |
218 |
219 | )}
220 |
221 | );
222 | }
223 |
--------------------------------------------------------------------------------