asingh123 commited on
Commit
b7ed343
·
verified ·
1 Parent(s): c73435a

Upload 4 files

Browse files
Files changed (4) hide show
  1. app/favicon.ico +0 -0
  2. app/globals.css +126 -0
  3. app/layout.tsx +34 -0
  4. app/page.tsx +559 -0
app/favicon.ico ADDED
app/globals.css ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @plugin "tailwindcss-animate";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --color-background: var(--background);
9
+ --color-foreground: var(--foreground);
10
+ --font-sans: var(--font-geist-sans);
11
+ --font-mono: var(--font-geist-mono);
12
+ --color-sidebar-ring: var(--sidebar-ring);
13
+ --color-sidebar-border: var(--sidebar-border);
14
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
15
+ --color-sidebar-accent: var(--sidebar-accent);
16
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
17
+ --color-sidebar-primary: var(--sidebar-primary);
18
+ --color-sidebar-foreground: var(--sidebar-foreground);
19
+ --color-sidebar: var(--sidebar);
20
+ --color-chart-5: var(--chart-5);
21
+ --color-chart-4: var(--chart-4);
22
+ --color-chart-3: var(--chart-3);
23
+ --color-chart-2: var(--chart-2);
24
+ --color-chart-1: var(--chart-1);
25
+ --color-ring: var(--ring);
26
+ --color-input: var(--input);
27
+ --color-border: var(--border);
28
+ --color-destructive-foreground: var(--destructive-foreground);
29
+ --color-destructive: var(--destructive);
30
+ --color-accent-foreground: var(--accent-foreground);
31
+ --color-accent: var(--accent);
32
+ --color-muted-foreground: var(--muted-foreground);
33
+ --color-muted: var(--muted);
34
+ --color-secondary-foreground: var(--secondary-foreground);
35
+ --color-secondary: var(--secondary);
36
+ --color-primary-foreground: var(--primary-foreground);
37
+ --color-primary: var(--primary);
38
+ --color-popover-foreground: var(--popover-foreground);
39
+ --color-popover: var(--popover);
40
+ --color-card-foreground: var(--card-foreground);
41
+ --color-card: var(--card);
42
+ --radius-sm: calc(var(--radius) - 4px);
43
+ --radius-md: calc(var(--radius) - 2px);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) + 4px);
46
+ }
47
+
48
+ :root {
49
+ --background: oklch(1 0 0);
50
+ --foreground: oklch(0.145 0 0);
51
+ --card: oklch(1 0 0);
52
+ --card-foreground: oklch(0.145 0 0);
53
+ --popover: oklch(1 0 0);
54
+ --popover-foreground: oklch(0.145 0 0);
55
+ --primary: oklch(0.205 0 0);
56
+ --primary-foreground: oklch(0.985 0 0);
57
+ --secondary: oklch(0.97 0 0);
58
+ --secondary-foreground: oklch(0.205 0 0);
59
+ --muted: oklch(0.97 0 0);
60
+ --muted-foreground: oklch(0.556 0 0);
61
+ --accent: oklch(0.97 0 0);
62
+ --accent-foreground: oklch(0.205 0 0);
63
+ --destructive: oklch(0.577 0.245 27.325);
64
+ --destructive-foreground: oklch(0.577 0.245 27.325);
65
+ --border: oklch(0.922 0 0);
66
+ --input: oklch(0.922 0 0);
67
+ --ring: oklch(0.708 0 0);
68
+ --chart-1: oklch(0.646 0.222 41.116);
69
+ --chart-2: oklch(0.6 0.118 184.704);
70
+ --chart-3: oklch(0.398 0.07 227.392);
71
+ --chart-4: oklch(0.828 0.189 84.429);
72
+ --chart-5: oklch(0.769 0.188 70.08);
73
+ --radius: 0.625rem;
74
+ --sidebar: oklch(0.985 0 0);
75
+ --sidebar-foreground: oklch(0.145 0 0);
76
+ --sidebar-primary: oklch(0.205 0 0);
77
+ --sidebar-primary-foreground: oklch(0.985 0 0);
78
+ --sidebar-accent: oklch(0.97 0 0);
79
+ --sidebar-accent-foreground: oklch(0.205 0 0);
80
+ --sidebar-border: oklch(0.922 0 0);
81
+ --sidebar-ring: oklch(0.708 0 0);
82
+ }
83
+
84
+ .dark {
85
+ --background: oklch(0.145 0 0);
86
+ --foreground: oklch(0.985 0 0);
87
+ --card: oklch(0.145 0 0);
88
+ --card-foreground: oklch(0.985 0 0);
89
+ --popover: oklch(0.145 0 0);
90
+ --popover-foreground: oklch(0.985 0 0);
91
+ --primary: oklch(0.985 0 0);
92
+ --primary-foreground: oklch(0.205 0 0);
93
+ --secondary: oklch(0.269 0 0);
94
+ --secondary-foreground: oklch(0.985 0 0);
95
+ --muted: oklch(0.269 0 0);
96
+ --muted-foreground: oklch(0.708 0 0);
97
+ --accent: oklch(0.269 0 0);
98
+ --accent-foreground: oklch(0.985 0 0);
99
+ --destructive: oklch(0.396 0.141 25.723);
100
+ --destructive-foreground: oklch(0.637 0.237 25.331);
101
+ --border: oklch(0.269 0 0);
102
+ --input: oklch(0.269 0 0);
103
+ --ring: oklch(0.439 0 0);
104
+ --chart-1: oklch(0.488 0.243 264.376);
105
+ --chart-2: oklch(0.696 0.17 162.48);
106
+ --chart-3: oklch(0.769 0.188 70.08);
107
+ --chart-4: oklch(0.627 0.265 303.9);
108
+ --chart-5: oklch(0.645 0.246 16.439);
109
+ --sidebar: oklch(0.205 0 0);
110
+ --sidebar-foreground: oklch(0.985 0 0);
111
+ --sidebar-primary: oklch(0.488 0.243 264.376);
112
+ --sidebar-primary-foreground: oklch(0.985 0 0);
113
+ --sidebar-accent: oklch(0.269 0 0);
114
+ --sidebar-accent-foreground: oklch(0.985 0 0);
115
+ --sidebar-border: oklch(0.269 0 0);
116
+ --sidebar-ring: oklch(0.439 0 0);
117
+ }
118
+
119
+ @layer base {
120
+ * {
121
+ @apply border-border outline-ring/50;
122
+ }
123
+ body {
124
+ @apply bg-background text-foreground;
125
+ }
126
+ }
app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "Create Next App",
17
+ description: "Generated by create next app",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html lang="en">
27
+ <body
28
+ className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
+ >
30
+ {children}
31
+ </body>
32
+ </html>
33
+ );
34
+ }
app/page.tsx ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect, useRef, useMemo } from "react"
4
+ import * as Tone from "tone"
5
+ import { Slider } from "@/components/ui/slider"
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select"
13
+ import { Button } from "@/components/ui/button"
14
+ import { Sparkles, Music, TreePine, RainbowIcon as Unicorn } from "lucide-react"
15
+
16
+ // -------------------- Scales (No Pentatonics) --------------------
17
+ const SCALES = {
18
+ ionian: [0, 2, 4, 5, 7, 9, 11, 12],
19
+ dorian: [0, 2, 3, 5, 7, 9, 10, 12],
20
+ phrygian: [0, 1, 3, 5, 7, 8, 10, 12],
21
+ lydian: [0, 2, 4, 6, 7, 9, 11, 12],
22
+ mixolydian: [0, 2, 4, 5, 7, 9, 10, 12],
23
+ aeolian: [0, 2, 3, 5, 7, 8, 10, 12], // Natural minor
24
+ locrian: [0, 1, 3, 5, 6, 8, 10, 12],
25
+ harmonicMinor: [0, 2, 3, 5, 7, 8, 11, 12],
26
+ melodicMinor: [0, 2, 3, 5, 7, 9, 11, 12],
27
+ } as const
28
+
29
+ // Chromatic root notes
30
+ const ROOTS = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
31
+
32
+ // 8 distinct colors for on-screen keys
33
+ const KEY_COLORS = [
34
+ "bg-red-400 hover:bg-red-500",
35
+ "bg-orange-400 hover:bg-orange-500",
36
+ "bg-yellow-400 hover:bg-yellow-500",
37
+ "bg-green-400 hover:bg-green-500",
38
+ "bg-teal-400 hover:bg-teal-500",
39
+ "bg-blue-400 hover:bg-blue-500",
40
+ "bg-indigo-400 hover:bg-indigo-500",
41
+ "bg-purple-400 hover:bg-purple-500",
42
+ ]
43
+
44
+ // Keyboard mapping for A..K => indexes 0..7
45
+ const KEY_MAPPING = ["a", "s", "d", "f", "g", "h", "j", "k"]
46
+
47
+ // -------------------- MAIN COMPONENT --------------------
48
+ export default function Fairyland() {
49
+ // ------ States ------
50
+ const [synth, setSynth] = useState<Tone.PolySynth | null>(null)
51
+
52
+ // Scale/Root/Octave
53
+ const [scale, setScale] = useState("ionian")
54
+ const [root, setRoot] = useState("C")
55
+ const [octave, setOctave] = useState(4)
56
+
57
+ // ADSR
58
+ const [attack, setAttack] = useState(0.1)
59
+ const [decay, setDecay] = useState(0.2)
60
+ const [sustain, setSustain] = useState(0.5)
61
+ const [release, setRelease] = useState(0.5)
62
+
63
+ // Effects
64
+ const [delayWet, setDelayWet] = useState(0.2)
65
+ const [reverbWet, setReverbWet] = useState(0.3)
66
+
67
+ // Forest pad toggle
68
+ const [forestOn, setForestOn] = useState(true)
69
+ const [forestPlaying, setForestPlaying] = useState(false)
70
+
71
+ // On-screen key states
72
+ const [activeKeys, setActiveKeys] = useState<Record<number, boolean>>({})
73
+ const activeNotesRef = useRef<Record<string, boolean>>({})
74
+
75
+ // Refs to nodes
76
+ const forestRef = useRef<Tone.Player | null>(null)
77
+ const delayRef = useRef<Tone.FeedbackDelay | null>(null)
78
+ const reverbRef = useRef<Tone.Reverb | null>(null)
79
+
80
+ // -------------- 1) Initialize Synth + Effects + Forest --------------
81
+ useEffect(() => {
82
+ // Create delay
83
+ const delay = new Tone.FeedbackDelay({
84
+ delayTime: 0.25,
85
+ feedback: 0.4,
86
+ wet: delayWet, // initial
87
+ })
88
+ delayRef.current = delay
89
+
90
+ // Create reverb
91
+ const reverb = new Tone.Reverb({
92
+ decay: 2.5,
93
+ wet: reverbWet,
94
+ })
95
+ reverbRef.current = reverb
96
+
97
+ // PolySynth => Delay => Reverb => Destination
98
+ const newSynth = new Tone.PolySynth(Tone.Synth, {
99
+ volume: -10,
100
+ oscillator: { type: "triangle" },
101
+ envelope: { attack, decay, sustain, release },
102
+ })
103
+ newSynth.connect(delay)
104
+ delay.connect(reverb)
105
+ reverb.toDestination()
106
+ setSynth(newSynth)
107
+
108
+ // Load forest loop
109
+ const forest = new Tone.Player({
110
+ // place "riverforest.wav" in /public => use "/riverforest.wav"
111
+ url: "/riverforest.wav",
112
+ loop: true,
113
+ autostart: false,
114
+ volume: -10,
115
+ onload: () => {
116
+ console.log("Forest pad loaded.")
117
+ if (forestOn) {
118
+ forest.start(0)
119
+ setForestPlaying(true)
120
+ }
121
+ },
122
+ }).toDestination()
123
+ forestRef.current = forest
124
+
125
+ // Start audio on first click or keydown
126
+ const handleFirstInteraction = () => {
127
+ if (Tone.context.state !== "running") {
128
+ Tone.start()
129
+ }
130
+ window.removeEventListener("click", handleFirstInteraction)
131
+ window.removeEventListener("keydown", handleFirstInteraction)
132
+ }
133
+ window.addEventListener("click", handleFirstInteraction)
134
+ window.addEventListener("keydown", handleFirstInteraction)
135
+
136
+ // Cleanup
137
+ return () => {
138
+ if (forest) {
139
+ if (forestPlaying) forest.stop()
140
+ forest.dispose()
141
+ }
142
+ newSynth.dispose()
143
+ delay.dispose()
144
+ reverb.dispose()
145
+ window.removeEventListener("click", handleFirstInteraction)
146
+ window.removeEventListener("keydown", handleFirstInteraction)
147
+ // No missing dependencies because we're removing only local references
148
+ }
149
+ // We add relevant states so ESLint doesn't complain about missing deps
150
+ }, [
151
+ forestOn,
152
+ forestPlaying,
153
+ delayWet,
154
+ reverbWet,
155
+ attack,
156
+ decay,
157
+ sustain,
158
+ release
159
+ ])
160
+
161
+ // -------------- 2) Toggling forestOn --------------
162
+ useEffect(() => {
163
+ const forest = forestRef.current
164
+ if (!forest) return
165
+ if (!forest.loaded) return
166
+
167
+ if (forestOn) {
168
+ if (!forestPlaying) {
169
+ forest.start(0)
170
+ setForestPlaying(true)
171
+ }
172
+ } else {
173
+ if (forestPlaying) {
174
+ forest.stop()
175
+ setForestPlaying(false)
176
+ }
177
+ }
178
+ }, [forestOn, forestPlaying])
179
+
180
+ // -------------- 3) Envelope changes (Synth) --------------
181
+ useEffect(() => {
182
+ if (!synth) return
183
+ synth.set({
184
+ envelope: {
185
+ attack: Math.max(0.01, attack),
186
+ decay,
187
+ sustain,
188
+ release,
189
+ },
190
+ })
191
+ }, [synth, attack, decay, sustain, release])
192
+
193
+ // -------------- 4) Delay & Reverb updates --------------
194
+ useEffect(() => {
195
+ if (delayRef.current) {
196
+ delayRef.current.wet.value = delayWet
197
+ }
198
+ }, [delayWet])
199
+
200
+ useEffect(() => {
201
+ if (reverbRef.current) {
202
+ reverbRef.current.wet.value = reverbWet
203
+ }
204
+ }, [reverbWet])
205
+
206
+ // -------------- 5) Keyboard (A..K => scaleNotes) --------------
207
+ // Build scale notes
208
+ const scaleNotes = useMemo(() => {
209
+ const intervals = SCALES[scale as keyof typeof SCALES] || [0, 2, 4, 5, 7, 9, 11, 12]
210
+ return intervals.map(interval => {
211
+ const rootIndex = ROOTS.indexOf(root)
212
+ const totalSemitones = (octave * 12) + rootIndex + interval
213
+ const noteIndex = totalSemitones % 12
214
+ const noteOctave = Math.floor(totalSemitones / 12)
215
+ return `${ROOTS[noteIndex]}${noteOctave}`
216
+ })
217
+ }, [scale, root, octave])
218
+
219
+ // Listen for keydown/up
220
+ useEffect(() => {
221
+ const handleKeyDown = (e: KeyboardEvent) => {
222
+ const k = e.key.toLowerCase()
223
+ if (KEY_MAPPING.includes(k) && !activeNotesRef.current[k]) {
224
+ const idx = KEY_MAPPING.indexOf(k)
225
+ const note = scaleNotes[idx]
226
+ if (synth && note) {
227
+ synth.triggerAttack(note)
228
+ }
229
+ activeNotesRef.current[k] = true
230
+ setActiveKeys(prev => ({ ...prev, [idx]: true }))
231
+ }
232
+ }
233
+
234
+ const handleKeyUp = (e: KeyboardEvent) => {
235
+ const k = e.key.toLowerCase()
236
+ if (KEY_MAPPING.includes(k)) {
237
+ const idx = KEY_MAPPING.indexOf(k)
238
+ const note = scaleNotes[idx]
239
+ if (synth && note) {
240
+ synth.triggerRelease(note)
241
+ }
242
+ activeNotesRef.current[k] = false
243
+ setActiveKeys(prev => ({ ...prev, [idx]: false }))
244
+ }
245
+ }
246
+
247
+ window.addEventListener("keydown", handleKeyDown)
248
+ window.addEventListener("keyup", handleKeyUp)
249
+ return () => {
250
+ window.removeEventListener("keydown", handleKeyDown)
251
+ window.removeEventListener("keyup", handleKeyUp)
252
+ }
253
+ }, [synth, scaleNotes])
254
+
255
+ // -------------- RENDER --------------
256
+ return (
257
+ <div
258
+ className="min-h-screen relative overflow-hidden flex flex-col items-center justify-center p-4"
259
+ style={{
260
+ backgroundImage: 'url("https://tonejs.github.io/audio/berklee/forest_ambience.jpg")',
261
+ backgroundSize: "cover",
262
+ backgroundPosition: "center",
263
+ }}
264
+ >
265
+ <div className="max-w-4xl w-full bg-gradient-to-br from-purple-900/80 via-indigo-800/80 to-blue-900/80 backdrop-blur-xl rounded-xl p-6 shadow-2xl border border-white/30">
266
+ {/* Header */}
267
+ <div className="flex flex-col items-center justify-center mb-6">
268
+ <div className="flex items-center gap-2 justify-center">
269
+ <Sparkles className="text-pink-300 h-8 w-8" />
270
+ <h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-teal-300 to-cyan-200">
271
+ Fairyland Web Synth
272
+ </h1>
273
+ <Sparkles className="text-pink-300 h-8 w-8" />
274
+ </div>
275
+ <div className="flex gap-4 mt-2">
276
+ <TreePine className="text-green-300 h-8 w-8" />
277
+ <Music className="text-blue-300 h-8 w-8" />
278
+ <Unicorn className="text-purple-300 h-8 w-8" />
279
+ </div>
280
+ </div>
281
+
282
+ {/* Controls */}
283
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
284
+ <div className="space-y-4">
285
+ {/* Scale */}
286
+ <div className="flex flex-col gap-2">
287
+ <label className="text-white font-medium">Scale</label>
288
+ <Select value={scale} onValueChange={setScale}>
289
+ <SelectTrigger className="bg-white/10 border-purple-300/30 text-white">
290
+ <SelectValue placeholder="Select scale" />
291
+ </SelectTrigger>
292
+ <SelectContent>
293
+ <SelectItem value="ionian">Ionian (Major)</SelectItem>
294
+ <SelectItem value="dorian">Dorian</SelectItem>
295
+ <SelectItem value="phrygian">Phrygian</SelectItem>
296
+ <SelectItem value="lydian">Lydian</SelectItem>
297
+ <SelectItem value="mixolydian">Mixolydian</SelectItem>
298
+ <SelectItem value="aeolian">Aeolian (Minor)</SelectItem>
299
+ <SelectItem value="locrian">Locrian</SelectItem>
300
+ <SelectItem value="harmonicMinor">Harmonic Minor</SelectItem>
301
+ <SelectItem value="melodicMinor">Melodic Minor</SelectItem>
302
+ </SelectContent>
303
+ </Select>
304
+ </div>
305
+
306
+ {/* Root Note */}
307
+ <div className="flex flex-col gap-2">
308
+ <label className="text-white font-medium">Root Note</label>
309
+ <Select value={root} onValueChange={setRoot}>
310
+ <SelectTrigger className="bg-white/10 border-purple-300/30 text-white">
311
+ <SelectValue placeholder="Select root note" />
312
+ </SelectTrigger>
313
+ <SelectContent>
314
+ {ROOTS.map(r => (
315
+ <SelectItem key={r} value={r}>
316
+ {r}
317
+ </SelectItem>
318
+ ))}
319
+ </SelectContent>
320
+ </Select>
321
+ </div>
322
+
323
+ {/* Octave */}
324
+ <div className="flex items-center gap-4">
325
+ <label className="text-white font-medium">Octave: {octave}</label>
326
+ <div className="flex gap-2">
327
+ <Button
328
+ variant="outline"
329
+ size="sm"
330
+ onClick={() => setOctave(o => Math.max(1, o - 1))}
331
+ disabled={octave <= 1}
332
+ className="bg-white/20 border-teal-300/30 text-white hover:bg-white/30"
333
+ >
334
+ -
335
+ </Button>
336
+ <Button
337
+ variant="outline"
338
+ size="sm"
339
+ onClick={() => setOctave(o => Math.min(6, o + 1))}
340
+ disabled={octave >= 6}
341
+ className="bg-white/20 border-teal-300/30 text-white hover:bg-white/30"
342
+ >
343
+ +
344
+ </Button>
345
+ </div>
346
+ </div>
347
+
348
+ {/* Forest Toggle */}
349
+ <Button
350
+ onClick={() => setForestOn(f => !f)}
351
+ className="bg-white/20 border-teal-300/30 text-white hover:bg-white/30 mt-4"
352
+ >
353
+ {forestOn ? "Mute Forest" : "Unmute Forest"}
354
+ </Button>
355
+ </div>
356
+
357
+ {/* ADSR & Effects */}
358
+ <div className="space-y-4">
359
+ <h3 className="text-white font-medium mb-2">ADSR & Effects</h3>
360
+
361
+ {/* Attack */}
362
+ <div className="space-y-1">
363
+ <div className="flex justify-between">
364
+ <label className="text-white text-sm">Attack: {attack.toFixed(2)}s</label>
365
+ </div>
366
+ <Slider
367
+ value={[attack]}
368
+ min={0.01}
369
+ max={2}
370
+ step={0.01}
371
+ onValueChange={([v]) => setAttack(v)}
372
+ className="py-1"
373
+ />
374
+ </div>
375
+
376
+ {/* Decay */}
377
+ <div className="space-y-1">
378
+ <div className="flex justify-between">
379
+ <label className="text-white text-sm">Decay: {decay.toFixed(2)}s</label>
380
+ </div>
381
+ <Slider
382
+ value={[decay]}
383
+ min={0.01}
384
+ max={2}
385
+ step={0.01}
386
+ onValueChange={([v]) => setDecay(v)}
387
+ className="py-1"
388
+ />
389
+ </div>
390
+
391
+ {/* Sustain */}
392
+ <div className="space-y-1">
393
+ <div className="flex justify-between">
394
+ <label className="text-white text-sm">Sustain: {sustain.toFixed(2)}</label>
395
+ </div>
396
+ <Slider
397
+ value={[sustain]}
398
+ min={0}
399
+ max={1}
400
+ step={0.01}
401
+ onValueChange={([v]) => setSustain(v)}
402
+ className="py-1"
403
+ />
404
+ </div>
405
+
406
+ {/* Release */}
407
+ <div className="space-y-1">
408
+ <div className="flex justify-between">
409
+ <label className="text-white text-sm">Release: {release.toFixed(2)}s</label>
410
+ </div>
411
+ <Slider
412
+ value={[release]}
413
+ min={0.01}
414
+ max={5}
415
+ step={0.01}
416
+ onValueChange={([v]) => setRelease(v)}
417
+ className="py-1"
418
+ />
419
+ </div>
420
+
421
+ {/* Delay */}
422
+ <div className="space-y-1 mt-2">
423
+ <div className="flex justify-between">
424
+ <label className="text-white text-sm">
425
+ Delay: {(delayWet * 100).toFixed(0)}%
426
+ </label>
427
+ </div>
428
+ <Slider
429
+ value={[delayWet]}
430
+ min={0}
431
+ max={1}
432
+ step={0.01}
433
+ onValueChange={([v]) => setDelayWet(v)}
434
+ className="py-1"
435
+ />
436
+ </div>
437
+
438
+ {/* Reverb */}
439
+ <div className="space-y-1">
440
+ <div className="flex justify-between">
441
+ <label className="text-white text-sm">
442
+ Reverb: {(reverbWet * 100).toFixed(0)}%
443
+ </label>
444
+ </div>
445
+ <Slider
446
+ value={[reverbWet]}
447
+ min={0}
448
+ max={1}
449
+ step={0.01}
450
+ onValueChange={([v]) => setReverbWet(v)}
451
+ className="py-1"
452
+ />
453
+ </div>
454
+ </div>
455
+ </div>
456
+
457
+ {/* On-Screen Keys */}
458
+ <OnScreenKeys
459
+ synth={synth}
460
+ scale={scale}
461
+ root={root}
462
+ octave={octave}
463
+ activeKeys={activeKeys}
464
+ setActiveKeys={setActiveKeys}
465
+ />
466
+
467
+ <p className="text-center text-white/80 mt-4">
468
+ Press A, S, D, F, G, H, J, K to play the scale!
469
+ </p>
470
+
471
+ {/* Decorations */}
472
+ <div className="absolute top-10 left-10 opacity-30">
473
+ <Sparkles className="text-pink-300 h-16 w-16" />
474
+ </div>
475
+ <div className="absolute bottom-10 right-10 opacity-30">
476
+ <Unicorn className="text-purple-300 h-16 w-16" />
477
+ </div>
478
+ <div className="absolute top-20 right-20 opacity-30">
479
+ <TreePine className="text-green-300 h-16 w-16" />
480
+ </div>
481
+ </div>
482
+ </div>
483
+ )
484
+ }
485
+
486
+ // -------------------- OnScreenKeys COMPONENT --------------------
487
+ function OnScreenKeys({
488
+ synth,
489
+ scale,
490
+ root,
491
+ octave,
492
+ activeKeys,
493
+ setActiveKeys,
494
+ }: {
495
+ synth: Tone.PolySynth | null
496
+ scale: string
497
+ root: string
498
+ octave: number
499
+ activeKeys: Record<number, boolean>
500
+ setActiveKeys: React.Dispatch<React.SetStateAction<Record<number, boolean>>>
501
+ }) {
502
+ // Recompute scale notes for the 8 on-screen keys
503
+ const scaleNotes = useMemo(() => {
504
+ const intervals = SCALES[scale as keyof typeof SCALES] || [0, 2, 4, 5, 7, 9, 11, 12]
505
+ return intervals.map(interval => {
506
+ const rootIndex = ROOTS.indexOf(root)
507
+ const totalSemitones = octave * 12 + rootIndex + interval
508
+ const noteIndex = totalSemitones % 12
509
+ const noteOctave = Math.floor(totalSemitones / 12)
510
+ return `${ROOTS[noteIndex]}${noteOctave}`
511
+ })
512
+ }, [scale, root, octave])
513
+
514
+ const handleMouseDown = (idx: number) => {
515
+ if (!synth) return
516
+ const note = scaleNotes[idx]
517
+ synth.triggerAttack(note)
518
+ setActiveKeys(prev => ({ ...prev, [idx]: true }))
519
+ }
520
+
521
+ const handleMouseUp = (idx: number) => {
522
+ if (!synth) return
523
+ const note = scaleNotes[idx]
524
+ synth.triggerRelease(note)
525
+ setActiveKeys(prev => ({ ...prev, [idx]: false }))
526
+ }
527
+
528
+ const handleMouseLeave = (idx: number) => {
529
+ if (!synth) return
530
+ if (activeKeys[idx]) {
531
+ const note = scaleNotes[idx]
532
+ synth.triggerRelease(note)
533
+ setActiveKeys(prev => ({ ...prev, [idx]: false }))
534
+ }
535
+ }
536
+
537
+ return (
538
+ <div className="flex justify-center gap-2 mb-2">
539
+ {scaleNotes.map((note, idx) => (
540
+ <button
541
+ key={idx}
542
+ className={`
543
+ ${KEY_COLORS[idx]}
544
+ ${activeKeys[idx] ? "opacity-80 translate-y-1" : "opacity-100"}
545
+ w-full h-32 rounded-lg shadow-lg transition-all duration-75 flex items-end justify-center pb-2
546
+ text-white font-medium border-b-4 border-white/20
547
+ `}
548
+ onMouseDown={() => handleMouseDown(idx)}
549
+ onMouseUp={() => handleMouseUp(idx)}
550
+ onMouseLeave={() => handleMouseLeave(idx)}
551
+ onTouchStart={() => handleMouseDown(idx)}
552
+ onTouchEnd={() => handleMouseUp(idx)}
553
+ >
554
+ {note}
555
+ </button>
556
+ ))}
557
+ </div>
558
+ )
559
+ }