import{useState,useRef,useEffect,useCallback}from "react"; // ═══════════════════════════════════════════════════════════════ // AUDIO ENGINE // ═══════════════════════════════════════════════════════════════ const initAudioCtx = (() => {let ctx = null,analyser = null; return () => {if (!ctx) {ctx = new (window.AudioContext || window.webkitAudioContext)(); analyser = ctx.createAnalyser(); analyser.fftSize = 2048; analyser.connect(ctx.destination);} if (ctx.state === "suspended") ctx.resume(); return {ctx,analyser};};})(); function buildNoise(ctx,type){const len = ctx.sampleRate * 10;const buf = ctx.createBuffer(1,len,ctx.sampleRate);const d = buf.getChannelData(0);if (type === "white"){for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1}else if (type === "pink"){let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0;for (let i = 0; i < len; i++){const w = Math.random()*2-1;b0=.99886*b0+w*.0555179;b1=.99332*b1+w*.0750759;b2=.969*b2+w*.153852;b3=.8665*b3+w*.3104856;b4=.55*b4+w*.5329522;b5=-.7616*b5-w*.016898;d[i]=(b0+b1+b2+b3+b4+b5+b6+w*.5362)*.11;b6=w*.115926}}else if (type === "brown"){let last = 0;for (let i = 0; i < len; i++){d[i]=(last+.02*(Math.random()*2-1))/1.02;last=d[i];d[i]*=3.5}}else if (type === "violet"){for (let i=0;i<len;i++) d[i]=Math.random()*2-1;const t=new Float32Array(len);t[0]=d[0];for (let i=1;i<len;i++) t[i]=d[i]-d[i-1];let mx=0;for(let i=0;i<len;i++) if(Math.abs(t[i])>mx) mx=Math.abs(t[i]);for(let i=0;i<len;i++) d[i]=t[i]/(mx*2)}else if (type === "blue"){// Blue noise: first-order high-shelf filter on white — energy rises at ~3dB/oct // Intermediate between white and violet let prev = 0;for (let i=0;i<len;i++){const w = Math.random()*2-1;d[i] = w - prev * .62;// leaky differentiator prev = w}// Normalize let mx=0;for(let i=0;i<len;i++) if(Math.abs(d[i])>mx) mx=Math.abs(d[i]);if(mx>0) for(let i=0;i<len;i++) d[i]=d[i]/(mx*1.5)}else if (type === "grey"){// Grey noise: white noise shaped to equal-loudness (ISO 226 approximation via IIR) // Perceptually flat — sounds quieter at extremes,balanced in the midrange // Implemented as pink + high-frequency boost to counteract ear sensitivity curve let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0;for (let i=0;i<len;i++){const w = Math.random()*2-1;// Pink base b0=.99886*b0+w*.0555179;b1=.99332*b1+w*.0750759;b2=.969*b2+w*.153852;b3=.8665*b3+w*.3104856;b4=.55*b4+w*.5329522;b5=-.7616*b5-w*.016898;const pink=(b0+b1+b2+b3+b4+b5+b6+w*.5362)*.11;b6=w*.115926;// Blend with a white component to lift highs back toward equal loudness d[i] = pink * .55 + w * .45}// Normalize let mx=0;for(let i=0;i<len;i++) if(Math.abs(d[i])>mx) mx=Math.abs(d[i]);if(mx>0) for(let i=0;i<len;i++) d[i]=d[i]/(mx*1.8)}return buf}function spawnNodes(ctx,analyser,sound,noiseBufs,vol=1){const g = ctx.createGain();g.gain.value = Math.max(0,Math.min(1,vol));g.connect(analyser);try{if (sound.type === "binaural"){const{carrier=200,beat=10}= sound.params;const mrg = ctx.createChannelMerger(2);const oL = ctx.createOscillator(),oR = ctx.createOscillator();const gL = ctx.createGain(),gR = ctx.createGain();oL.frequency.value = carrier;oR.frequency.value = carrier + beat;oL.type = oR.type = "sine";oL.connect(gL);gL.connect(mrg,0,0);oR.connect(gR);gR.connect(mrg,0,1);mrg.connect(g);oL.start();oR.start();return{g,stop:()=>{try{oL.stop();oR.stop()}catch(e){}}}}if (sound.type === "tone"){const osc = ctx.createOscillator();osc.frequency.value = sound.params.frequency||440;osc.type = sound.params.waveform||"sine";osc.connect(g);osc.start();return{g,stop:()=>{try{osc.stop()}catch(e){}}}}if (sound.type === "noise"){const buf = noiseBufs[sound.params.noiseType];if (!buf) return null;const src = ctx.createBufferSource();src.buffer=buf;src.loop=true;src.connect(g);src.start();return{g,stop:()=>{try{src.stop()}catch(e){}}}}if (sound.type === "import" && sound.audioBuffer){const src = ctx.createBufferSource();src.buffer=sound.audioBuffer;src.loop=true;src.connect(g);src.start();return{g,stop:()=>{try{src.stop()}catch(e){}}}}}catch(e){console.error(e)}return null}// ═══════════════════════════════════════════════════════════════ // EXPORT ENGINE // ═══════════════════════════════════════════════════════════════ function encodeWAV(audioBuffer){const numCh = audioBuffer.numberOfChannels;const numSamp = audioBuffer.length;const SR = audioBuffer.sampleRate;const bitsPS = 16;const byteRate = SR * numCh * 2;const blockAlign= numCh * 2;const dataLen = numSamp * numCh * 2;// Header — 44 bytes const header = new ArrayBuffer(44);const v = new DataView(header);const ws = (o,s)=>{for(let i=0;i<s.length;i++) v.setUint8(o+i,s.charCodeAt(i))}const u16 = (o,n)=>v.setUint16(o,n,true);const u32 = (o,n)=>v.setUint32(o,n,true);ws(0,"RIFF");u32(4,36+dataLen);ws(8,"WAVE");ws(12,"fmt ");u32(16,16);u16(20,1);u16(22,numCh);u32(24,SR);u32(28,byteRate);u16(32,blockAlign);u16(34,bitsPS);ws(36,"data");u32(40,dataLen);// Chunked PCM — write in 1-second slices so GC can collect as we go const channels = [];for(let c=0;c<numCh;c++) channels.push(audioBuffer.getChannelData(c));const CHUNK = SR;// 1 second of samples per chunk const blobs = [header];for(let offset=0; offset<numSamp; offset+=CHUNK){const end = Math.min(offset+CHUNK,numSamp);const slice = new Int16Array((end-offset)*numCh);let si = 0;for(let i=offset;i<end;i++){for(let c=0;c<numCh;c++){const s = Math.max(-1,Math.min(1,channels[c][i]));slice[si++] = s<0 ? s*0x8000 : s*0x7FFF}}blobs.push(slice.buffer)}return new Blob(blobs,{type:"audio/wav"})}const WAV_MAX_MINUTES = 15; // above this WAV hits ~450 MB — force MP3 function loadLamejs(){return new Promise((res,rej)=>{if(window.lamejs){res(window.lamejs); return;} const s=document.createElement("script"); s.src="https://cdnjs.cloudflare.com/ajax/libs/lamejs/1.2.1/lame.min.js"; s.onload=()=>res(window.lamejs); s.onerror=()=>rej(new Error("Failed to load lamejs")); document.head.appendChild(s);})}async function encodeMp3(audioBuffer,onProgress){const lame = await loadLamejs();const SR = audioBuffer.sampleRate;const left = audioBuffer.getChannelData(0);const right = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : left;const encoder = new lame.Mp3Encoder(2,SR,128);const chunkSz = 1152;const chunks = [];const total = Math.ceil(left.length / chunkSz);const toPCM = (f32)=>{const i16 = new Int16Array(f32.length);for(let i=0;i<f32.length;i++) i16[i]=Math.max(-32768,Math.min(32767,f32[i]*32767));return i16}for(let i=0;i<left.length;i+=chunkSz){const L = toPCM(left.slice(i,i+chunkSz));const R = toPCM(right.slice(i,i+chunkSz));const d = encoder.encodeBuffer(L,R);if(d.length>0) chunks.push(new Uint8Array(d));if(onProgress) onProgress(Math.round((i/left.length)*90));// Yield to keep UI responsive every 50 chunks if(i % (chunkSz*50) === 0) await new Promise(r=>setTimeout(r,0))}const end = encoder.flush();if(end.length>0) chunks.push(new Uint8Array(end));if(onProgress) onProgress(100);return new Blob(chunks,{type:"audio/mp3"})}async function exportMix(mix,sounds,durationSecs,format,onProgress,fadeIn=false,fadeOut=false){const SR = 22050;const offCtx = new OfflineAudioContext(2,SR * durationSecs,SR);// Master gain node — all tracks route through this for fade automation const masterGain = offCtx.createGain();masterGain.connect(offCtx.destination);if(fadeIn){masterGain.gain.setValueAtTime(0,0);masterGain.gain.linearRampToValueAtTime(1,2)}if(fadeOut){masterGain.gain.setValueAtTime(1,Math.max(0,durationSecs - 2));masterGain.gain.linearRampToValueAtTime(0,durationSecs)}const offNoise ={}["white","pink","brown","violet","blue","grey"].forEach(t=>{offNoise[t]=buildNoise(offCtx,t);});mix.tracks.forEach(track=>{const sound = sounds.find(s=>s.id===track.soundId); if(!sound) return; // Route into masterGain instead of destination directly spawnNodes(offCtx,masterGain,sound,offNoise,track.volume);});if(onProgress) onProgress(5);const rendered = await offCtx.startRendering();if(onProgress) onProgress(format==="wav"?95:10);if(format==="wav"){const blob = encodeWAV(rendered);if(onProgress) onProgress(100);return blob}else{return await encodeMp3(rendered,onProgress)}}// ═══════════════════════════════════════════════════════════════ // EXPORT MODAL // ═══════════════════════════════════════════════════════════════ const ExportModal=({mix,sounds,onClose})=>{const [duration,setDuration]=useState(30);const [format,setFormat]=useState("wav");const [state,setState]=useState("idle");// idle | rendering | done | error const [progress,setProgress]=useState(0);const [errMsg,setErrMsg]=useState("");const [fadeIn,setFadeIn]=useState(false);const [fadeOut,setFadeOut]=useState(false);const wavBlocked = format === "wav" && duration > WAV_MAX_MINUTES;// Size estimates at 22050 Hz const estimatedMB = format==="wav" ? ((22050*2*2*duration*60)/1024/1024).toFixed(0) : ((128*duration*60)/8/1024).toFixed(0);const handleExport=async()=>{setState("rendering");setProgress(0);try{const blob = await exportMix(mix,sounds,duration*60,format,setProgress,fadeIn,fadeOut);const url = URL.createObjectURL(blob);const a = document.createElement("a");a.href = url;a.download = `${mix.name.replace(/[^a-z0-9]/gi,"_")}.${format}`;a.click();setTimeout(()=>URL.revokeObjectURL(url),5000);setState("done")}catch(e){console.error(e);setErrMsg(e.message||"Export failed");setState("error")}}return(<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.75)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,backdropFilter:"blur(4px)"}}> <div style={{background:C.surf,border:`1px solid ${C.border}`,width:"440px",maxWidth:"95vw",boxShadow:`0 0 40px rgba(0,229,192,.1)`}}> {} <div style={{borderBottom:`1px solid ${C.border}`,padding:"14px 20px",display:"flex",justifyContent:"space-between",alignItems:"center"}}> <div> <div style={{fontFamily:MONO,fontSize:"11px",color:C.teal,letterSpacing:"0.15em",textTransform:"uppercase"}}>Export Mix</div> <div style={{fontFamily:MONO,fontSize:"13px",color:C.bright,marginTop:"2px"}}>{mix.name}</div> </div> <Btn sm onClick={onClose} color={C.muted} disabled={state==="rendering"}>✕</Btn> </div> <div style={{padding:"20px"}}> {} <Field label={`Duration: ${duration} min`}> <Inp type="range" min={1} max={120} step={1} value={duration} onChange={e=>setDuration(+e.target.value)} style={{marginBottom:"6px"}}/> <div style={{display:"flex",gap:"6px"}}> {[5,15,30,60].map(v=>(<Btn key={v} sm onClick={()=>setDuration(v)} color={duration===v?C.teal:C.muted}> {v}m </Btn>))} <Inp type="number" min={1} max={120} step={1} value={duration} onChange={e=>setDuration(+e.target.value)} style={{width:"70px",padding:"4px 8px",fontSize:"12px"}}/> </div> </Field> {} <Field label="Format"> <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"8px"}}> {[{id:"wav",label:"WAV",desc:"Lossless · Large file"},{id:"mp3",label:"MP3 128k",desc:"Compressed · Smaller"},].map(f=>(<div key={f.id} onClick={()=>format!=="rendering"&&setFormat(f.id)} style={{border:`1px solid ${format===f.id?C.teal:C.border}`,background:format===f.id?C.teal15:"transparent",padding:"10px 12px",cursor:"pointer",transition:"all 0.15s"}}> <div style={{fontFamily:MONO,fontSize:"12px",color:format===f.id?C.teal:C.text,fontWeight:"bold"}}>{f.label}</div> <div style={{fontFamily:MONO,fontSize:"10px",color:C.muted,marginTop:"2px"}}>{f.desc}</div> </div>))} </div> </Field> {} <Field label="Fade"> <div style={{display:"flex",gap:"16px"}}> {[["fadeIn","Fade In",fadeIn,setFadeIn],["fadeOut","Fade Out",fadeOut,setFadeOut]].map(([id,label,val,set])=>(<label key={id} style={{display:"flex",alignItems:"center",gap:"8px",cursor:"pointer",userSelect:"none"}}> <div onClick={()=>set(v=>!v)} style={{width:"16px",height:"16px",border:`1px solid ${val?C.teal:C.border}`,background:val?C.teal:"transparent",flexShrink:0,display:"flex",alignItems:"center",justifyContent:"center",transition:"all 0.15s",cursor:"pointer",}}> {val&&<span style={{color:"#0a0d12",fontSize:"11px",fontWeight:"bold",lineHeight:1}}>✓</span>} </div> <div> <div style={{fontFamily:MONO,fontSize:"11px",color:val?C.teal:C.text}}>{label}</div> <div style={{fontFamily:MONO,fontSize:"9px",color:C.muted}}>2 sec linear ramp</div> </div> </label>))} </div> </Field> {} <div style={{background:"#08090e",border:`1px solid ${C.border}`,padding:"10px 14px",marginBottom:"16px",display:"flex",gap:"20px",flexWrap:"wrap"}}> {[["Duration",`${duration} min`],["Tracks",`${mix.tracks.length}`],["Est. Size",`~${estimatedMB} MB`],["Format",format.toUpperCase()],].map(([k,v])=>(<div key={k}> <div style={{fontFamily:MONO,fontSize:"9px",color:C.muted,letterSpacing:"0.1em",textTransform:"uppercase"}}>{k}</div> <div style={{fontFamily:MONO,fontSize:"13px",color:C.teal,marginTop:"2px"}}>{v}</div> </div>))} </div> {} {wavBlocked&&(<div style={{background:C.amber15,border:`1px solid ${C.warn}`,padding:"10px 14px",marginBottom:"16px",fontFamily:MONO,fontSize:"11px",color:C.warn,lineHeight:1.6}}> WAV is capped at {WAV_MAX_MINUTES} min (~{((22050*2*2*WAV_MAX_MINUTES*60)/1024/1024).toFixed(0)} MB at 22050 Hz).<br/> Switch to MP3 for durations longer than {WAV_MAX_MINUTES} min — it encodes in chunks and won't crash the browser.
</div>)} {} {state==="rendering"&&(<div style={{marginBottom:"16px"}}> <div style={{display:"flex",justifyContent:"space-between",marginBottom:"6px"}}> <span style={{fontFamily:MONO,fontSize:"10px",color:C.muted,letterSpacing:"0.1em"}}> {format==="mp3"&&progress<10?"OFFLINE RENDER":format==="mp3"?"ENCODING MP3":"RENDERING"} </span> <span style={{fontFamily:MONO,fontSize:"10px",color:C.teal}}>{progress}%</span> </div> <div style={{height:"3px",background:C.border}}> <div style={{height:"100%",background:C.teal,width:`${progress}%`,transition:"width 0.3s",boxShadow:`0 0 8px ${C.teal}`}}/> </div> <div style={{fontFamily:MONO,fontSize:"10px",color:C.dim,marginTop:"6px"}}> {format==="wav"?"WAV renders in seconds. MP3 takes longer for large durations.": "MP3 encoding is CPU-bound — larger durations take proportionally longer."} </div> </div>)} {state==="done"&&(<div style={{background:C.teal15,border:`1px solid ${C.teal}`,padding:"10px 14px",marginBottom:"16px",fontFamily:MONO,fontSize:"12px",color:C.teal}}> ✓ Export complete — check your downloads folder. </div>)} {state==="error"&&(<div style={{background:"rgba(244,63,94,0.1)",border:`1px solid ${C.danger}`,padding:"10px 14px",marginBottom:"16px",fontFamily:MONO,fontSize:"12px",color:C.danger}}> Error: {errMsg} </div>)} {} <div style={{display:"flex",gap:"8px",justifyContent:"flex-end"}}> <Btn onClick={onClose} color={C.muted} disabled={state==="rendering"}>Cancel</Btn> {state==="done" ? <Btn onClick={handleExport} solid color={C.teal}>Export Again</Btn> : <Btn onClick={handleExport} solid color={C.teal} disabled={state==="rendering"||wavBlocked}> {state==="rendering"?"Rendering…":"Export & Download"} </Btn>} </div> </div> </div> </div>)}; // ═══════════════════════════════════════════════════════════════ // INITIAL STATE & CONSTANTS // ═══════════════════════════════════════════════════════════════ const INIT_SOUNDS = [{id:"n-white",name:"White Noise",type:"noise",params:{noiseType:"white"},builtin:true},{id:"n-pink",name:"Pink Noise",type:"noise",params:{noiseType:"pink"},builtin:true},{id:"n-brown",name:"Brown Noise",type:"noise",params:{noiseType:"brown"},builtin:true},{id:"n-violet",name:"Violet Noise",type:"noise",params:{noiseType:"violet"},builtin:true},{id:"n-blue",name:"Blue Noise",type:"noise",params:{noiseType:"blue"},builtin:true},{id:"n-grey",name:"Grey Noise",type:"noise",params:{noiseType:"grey"},builtin:true},]; // ═══════════════════════════════════════════════════════════════ // PRESETS — loaded from /presets.json at runtime // Edit public/presets.json to modify presets without touching app code. // ═══════════════════════════════════════════════════════════════ // Fallback used if fetch fails (network offline,file missing,etc.) const PRESET_FALLBACK = [{name:"Focus 10 — Mind Awake / Body Asleep",carrier:300,beat:7.83,bandLabel:"Theta (7.83 Hz Schumann)",noise:"Brown",noiseVol:"40–45%",source:"Table 1 · F10",group:"Focus Protocols"},{name:"Focus 12 — Expanded Awareness",carrier:350,beat:14,bandLabel:"Alpha-Beta Border (14 Hz low-beta)",noise:"Pink",noiseVol:"40–45%",source:"Table 1 · F12",group:"Focus Protocols"},{name:"Focus 15 — No Time",carrier:400,beat:.5,bandLabel:"Delta (0.5 Hz very slow delta)",noise:"Brown",noiseVol:"45–50%",source:"Table 1 · F15",group:"Focus Protocols"},{name:"Focus 21 — Bridge to Lucidity",carrier:475,beat:.25,bandLabel:"Deep Delta (0.25 Hz)",noise:"Brown",noiseVol:"45–50%",source:"Table 1 · F21",group:"Focus Protocols"},{name:"Focus 27 — Unified / Beat-Free",carrier:550,beat:.1,bandLabel:"Transcendent (beat-free)",noise:"Pink",noiseVol:"35–40%",source:"Table 1 · F27",group:"Focus Protocols"},{name:"ADHD Work Focus — Sustained Attention",carrier:160,beat:14,bandLabel:"Beta (14 Hz low-beta)",noise:"White",noiseVol:"45–50%",source:"Table 2 · ADHD Work Focus",group:"ADHD & Performance"},{name:"ADHD Optimal — Peak Cognition",carrier:420,beat:40,bandLabel:"Gamma (40 Hz)",noise:"Violet",noiseVol:"40–45%",source:"Table 2 · ADHD Optimal",group:"ADHD & Performance"},{name:"Sleep Onset — Rapid Induction",carrier:250,beat:.75,bandLabel:"Infra-Delta (0.5–1.0 Hz)",noise:"Brown",noiseVol:"50–55%",source:"Table 2 · Sleep Onset",group:"Sleep"},{name:"Sleep Maintenance — Overnight",carrier:250,beat:.75,bandLabel:"Infra-Delta (0.5–1.0 Hz)",noise:"Brown",noiseVol:"50–55%",source:"Table 2 · Sleep Maintenance",group:"Sleep"},{name:"Delta — Deep Sleep",carrier:180,beat:2,bandLabel:"δ Delta",noise:"Brown",noiseVol:"40–50%",source:"General",group:"General"},{name:"Theta — Hypnagogic",carrier:200,beat:6,bandLabel:"θ Theta",noise:"Pink",noiseVol:"40–50%",source:"General",group:"General"},{name:"Alpha — Relaxed Awareness",carrier:210,beat:10,bandLabel:"α Alpha",noise:"Pink",noiseVol:"35–45%",source:"General",group:"General"},{name:"Gamma — High Frequency",carrier:240,beat:40,bandLabel:"γ Gamma",noise:"Violet",noiseVol:"35–45%",source:"General · violet noise complements gamma",group:"General"},]; const PRESET_GROUPS = ["Focus Protocols","ADHD & Performance","Sleep","General"]; // ── Responsive breakpoint hook ── function useWindowWidth(){const [w,setW]=useState(()=>window.innerWidth);useEffect(()=>{const h=()=>setW(window.innerWidth); window.addEventListener("resize",h); return()=>window.removeEventListener("resize",h);},[]);return w}const BAND = (hz) =>{if (hz <= 4) return "δ Delta";if (hz <= 8) return "θ Theta";if (hz <= 13) return "α Alpha";if (hz <= 30) return "β Beta";return "γ Gamma"}; const uid = () => Math.random().toString(36).slice(2,9); // ═══════════════════════════════════════════════════════════════ // DESIGN SYSTEM — CortexOne Dark // ═══════════════════════════════════════════════════════════════ const C ={bg:"#0d0f14",surf: "#111621",card: "#141c2a",border: "#1a2438",teal: "#00e5c0",teal15: "rgba(0,229,192,0.12)",teal30: "rgba(0,229,192,0.25)",violet: "#7c3aed",violet15:"rgba(124,58,237,0.14)",violet30:"rgba(124,58,237,0.28)",muted: "#8896a5",text: "#c8d6e8",bright: "#eaf2ff",dim: "#2e3f55",danger: "#f43f5e",warn: "#f59e0b",amber15: "rgba(245,158,11,0.15)",blue: "#3b82f6",blue15: "rgba(59,130,246,0.13)",}; const MONO = "'Courier New', Courier, monospace"; // Primitive UI components const Btn = ({onClick,color=C.teal,solid=false,sm=false,disabled=false,style={},children})=>(<button onClick={onClick} disabled={disabled} style={{background: solid ? color : "transparent",border:`1px solid ${disabled?"#2e3f55":color}`,color: disabled ? C.muted : solid ? "#0a0d12" : color,padding: sm ? "4px 10px" : "7px 16px",cursor: disabled ? "not-allowed" : "pointer",fontFamily:MONO,fontSize: sm?"10px":"11px",letterSpacing:"0.1em",fontWeight:solid?"700":"400",textTransform:"uppercase",lineHeight:1.4,transition:"all 0.15s",...style}}>{children}</button>); const Label=({children})=>(<div style={{color:C.muted,fontSize:"10px",fontFamily:MONO,letterSpacing:"0.12em",textTransform:"uppercase",marginBottom:"5px"}}>{children}</div>); const Inp=({value,onChange,type="text",placeholder="",min,max,step,style={}})=>(<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max} step={step} style={{background:"#08090e",border:`1px solid ${C.border}`,borderRadius:0,color:C.text,padding:"8px 10px",fontFamily:MONO,fontSize:"13px",outline:"none",width:"100%",boxSizing:"border-box",...style}}/>); const Sel=({value,onChange,children})=>(<select value={value} onChange={onChange} style={{background:"#08090e",border:`1px solid ${C.border}`,borderRadius:0,color:C.text,padding:"8px 10px",fontFamily:MONO,fontSize:"13px",outline:"none",width:"100%",cursor:"pointer"}}>{children}</select>); const Field=({label,children,style={}})=>(<div style={{marginBottom:"14px",...style}}><Label>{label}</Label>{children}</div>); const TypeTag=({type})=>{const map={binaural:[C.violet,"BINARL"],tone:[C.teal,"TONE"],noise:[C.warn,"NOISE"],import:[C.blue,"IMPORT"]}const [col,txt]=map[type]||[C.muted,type];return <span style={{fontSize:"9px",fontFamily:MONO,letterSpacing:"0.1em",color:col,border:`1px solid ${col}`,padding:"2px 5px"}}>{txt}</span>}; const Divider=({label})=>(<div style={{display:"flex",alignItems:"center",gap:"10px",marginBottom:"16px",marginTop:"8px"}}> {label && <span style={{fontFamily:MONO,fontSize:"9px",color:C.muted,letterSpacing:"0.15em",textTransform:"uppercase",whiteSpace:"nowrap"}}>{label}</span>} <div style={{flex:1,height:"1px",background:C.border}}/> </div>); // ═══════════════════════════════════════════════════════════════ // VISUALIZER // ═══════════════════════════════════════════════════════════════ const Visualizer=({analyserRef,isPlaying})=>{const canvasRef=useRef(null);const rafRef=useRef(null);useEffect(()=>{const canvas=canvasRef.current; const ctx2=canvas.getContext("2d"); if(!isPlaying||!analyserRef.current){cancelAnimationFrame(rafRef.current); ctx2.clearRect(0,0,canvas.width,canvas.height); ctx2.strokeStyle=C.dim; ctx2.lineWidth=1; ctx2.beginPath(); ctx2.moveTo(0,canvas.height/2); ctx2.lineTo(canvas.width,canvas.height/2); ctx2.stroke(); return;} const analyser=analyserRef.current; const buf=new Uint8Array(analyser.frequencyBinCount); const draw=()=>{rafRef.current=requestAnimationFrame(draw); analyser.getByteTimeDomainData(buf); ctx2.clearRect(0,0,canvas.width,canvas.height); // Grid line ctx2.strokeStyle=C.dim; ctx2.lineWidth=.5; ctx2.beginPath(); ctx2.moveTo(0,canvas.height/2); ctx2.lineTo(canvas.width,canvas.height/2); ctx2.stroke(); // Waveform ctx2.strokeStyle=C.teal; ctx2.lineWidth=1.5; ctx2.shadowBlur=10; ctx2.shadowColor=C.teal; ctx2.beginPath(); const sl=canvas.width/buf.length; for(let i=0;i<buf.length;i++){const x=i*sl; const y=(buf[i]/128)*(canvas.height/2); i===0?ctx2.moveTo(x,y):ctx2.lineTo(x,y);} ctx2.stroke(); ctx2.shadowBlur=0;}; draw(); return()=>cancelAnimationFrame(rafRef.current);},[isPlaying]);return(<canvas ref={canvasRef} width={800} height={56} style={{width:"100%",height:"56px",display:"block",background:"#07080c",borderBottom:`1px solid ${C.border}`}}/>)}; // ═══════════════════════════════════════════════════════════════ // CREATOR TAB // ═══════════════════════════════════════════════════════════════ const CreatorTab=({onSave,onPreviewSound,previewId,presets,isMobile})=>{const [type,setType]=useState("binaural");const [name,setName]=useState("");const [carrier,setCarrier]=useState(200);const [beat,setBeat]=useState(6);const [freq,setFreq]=useState(432);const [wave,setWave]=useState("sine");const [audioBuffer,setAudioBuffer]=useState(null);const [loadedName,setLoadedName]=useState("");const [duration,setDuration]=useState(0);const [error,setError]=useState("");const [saved,setSaved]=useState("");const PREVIEW_ID = "__creator__";const isPreviewing = previewId === PREVIEW_ID;const buildTempSound = () =>{if (type === "binaural") return{id:PREVIEW_ID,name:"Preview",type:"binaural",params:{carrier:+carrier,beat:+beat}}if (type === "tone") return{id:PREVIEW_ID,name:"Preview",type:"tone",params:{frequency:+freq,waveform:wave}}if (type === "import") return audioBuffer ?{id:PREVIEW_ID,name:"Preview",type:"import",params:{},audioBuffer}: null;return null}const handlePreview = () =>{if (isPreviewing){onPreviewSound({id:PREVIEW_ID});return}// toggle off const sound = buildTempSound();if (!sound){setError("Import a file before previewing.");return}setError("");onPreviewSound(sound)}// Stop preview when switching type const handleTypeChange = (t) =>{if (isPreviewing) onPreviewSound({id:PREVIEW_ID});setType(t);setError("")}const [activePreset,setActivePreset]=useState(null);const applyPreset=p=>{setCarrier(p.carrier);setBeat(p.beat);setName(p.name);setActivePreset(p)}const handleImport=async e=>{const file=e.target.files[0];if(!file) return;setError("");const{ctx}=initAudioCtx();try{const ab=await file.arrayBuffer();const decoded=await ctx.decodeAudioData(ab);setAudioBuffer(decoded);setDuration(decoded.duration);const stem=file.name.replace(/\.[^.]+$/,"");setLoadedName(stem);setName(stem)}catch(e){setError("Could not decode audio file. Try WAV, MP3, or OGG.")}}const handleSave=()=>{if(!name.trim()){setError("Name required");return}setError("");let sound;if(type==="binaural") sound={id:uid(),name,type:"binaural",params:{carrier:+carrier,beat:+beat}}else if(type==="tone") sound={id:uid(),name,type:"tone",params:{frequency:+freq,waveform:wave}}else if(type==="import"){if(!audioBuffer){setError("Import a file first");return}sound={id:uid(),name,type:"import",params:{},audioBuffer}}onSave(sound);setSaved(`"${name}" saved to library`);setName("");setAudioBuffer(null);setLoadedName("");setDuration(0);setTimeout(()=>setSaved(""),3000)}const typeColors={binaural:C.violet,tone:C.teal,import:C.blue}const col=typeColors[type];return(<div> {} <div style={{display:"flex",gap:"8px",marginBottom:"24px"}}> {["binaural","tone","import"].map(t=>(<Btn key={t} onClick={()=>handleTypeChange(t)} solid={type===t} color={typeColors[t]} style={{flex:1,justifyContent:"center",display:"flex"}}> {t==="binaural"?"Binaural Beat":t==="tone"?"Sine Tone":"Import Audio"} </Btn>))} </div> {} {type==="binaural"&&(<div> <Field label="Monroe / Hemi-Sync Presets"> <Sel value="" onChange={e=>{const p=presets.find(x=>x.name===e.target.value);if(p)applyPreset(p);}}> <option value="">— Load preset —</option> {PRESET_GROUPS.map(grp=>(<optgroup key={grp} label={grp}> {presets.filter(p=>p.group===grp).map(p=>(<option key={p.name} value={p.name}>{p.name}</option>))} </optgroup>))} </Sel> </Field> <div style={{display:"grid",gridTemplateColumns:isMobile?"1fr":"1fr 1fr",gap:"16px"}}> <Field label={`Carrier: ${carrier} Hz`}> <Inp type="range" min={30} max={600} step={1} value={carrier} onChange={e=>{setCarrier(e.target.value);setActivePreset(null);}}/> <Inp type="number" min={30} max={600} step={1} value={carrier} onChange={e=>{setCarrier(e.target.value);setActivePreset(null);}} style={{marginTop:"6px"}}/> </Field> <Field label={`Beat: ${beat} Hz`}> <Inp type="range" min={.1} max={40} step={.05} value={beat} onChange={e=>{setBeat(e.target.value);setActivePreset(null);}}/> <Inp type="number" min={.1} max={40} step={.05} value={beat} onChange={e=>{setBeat(e.target.value);setActivePreset(null);}} style={{marginTop:"6px"}}/> </Field> </div> <div style={{background:"#07080c",border:`1px solid ${C.border}`,padding:"10px 14px",marginBottom:"16px"}}> <div style={{display:"flex",gap:"24px",flexWrap:"wrap",marginBottom: activePreset?"10px":"0"}}> {[["L Channel",`${carrier} Hz`],["R Channel",`${(+carrier + +beat).toFixed(2)} Hz`],["Beat",`${beat} Hz`],["Band",activePreset ? activePreset.bandLabel : BAND(+beat)],].map(([k,v])=>(<div key={k}> <div style={{fontFamily:MONO,fontSize:"9px",color:C.muted,letterSpacing:"0.1em",textTransform:"uppercase"}}>{k}</div> <div style={{fontFamily:MONO,fontSize:"13px",color:C.teal,marginTop:"2px"}}>{v}</div> </div>))} </div> {activePreset&&(<div style={{borderTop:`1px solid ${C.border}`,paddingTop:"8px",display:"flex",gap:"24px",flexWrap:"wrap"}}> {[["Noise",activePreset.noise],["Noise Vol",activePreset.noiseVol],["Source",activePreset.source],].map(([k,v])=>(<div key={k}> <div style={{fontFamily:MONO,fontSize:"9px",color:C.muted,letterSpacing:"0.1em",textTransform:"uppercase"}}>{k}</div> <div style={{fontFamily:MONO,fontSize:"11px",color:C.dim,marginTop:"2px"}}>{v}</div> </div>))} </div>)} </div> </div>)} {} {type==="tone"&&(<div style={{display:"grid",gridTemplateColumns:isMobile?"1fr":"1fr 1fr",gap:"16px"}}> <Field label={`Frequency: ${freq} Hz`}> <Inp type="range" min={20} max={20000} step={1} value={freq} onChange={e=>setFreq(e.target.value)}/> <Inp type="number" min={20} max={20000} step={1} value={freq} onChange={e=>setFreq(e.target.value)} style={{marginTop:"6px"}}/> </Field> <Field label="Waveform"> <Sel value={wave} onChange={e=>setWave(e.target.value)}> {["sine","square","sawtooth","triangle"].map(w=><option key={w}>{w}</option>)} </Sel> <div style={{fontFamily:MONO,fontSize:"10px",color:C.muted,marginTop:"8px"}}> {wave==="sine"&&"Pure, smooth tone. Best for binaural entrainment support."} {wave==="square"&&"Rich harmonics. Bright and buzzy character."} {wave==="sawtooth"&&"Full harmonic series. Warm but edgy."} {wave==="triangle"&&"Soft harmonics. Flute-like, gentle."} </div> </Field> </div>)} {} {type==="import"&&(<Field label="Audio File (WAV, MP3, OGG, FLAC, AAC)"> <div style={{border:`2px dashed ${C.border}`,padding:"28px",textAlign:"center",background:"#07080c",marginBottom:"8px"}}> <input type="file" accept="audio/*" onChange={handleImport} id="audio-import" style={{display:"none"}}/> <label htmlFor="audio-import" style={{cursor:"pointer",display:"block"}}> <div style={{fontFamily:MONO,fontSize:"11px",color:C.muted,letterSpacing:"0.1em"}}> CLICK TO SELECT FILE </div> {audioBuffer&&(<div style={{marginTop:"12px",color:C.teal,fontFamily:MONO,fontSize:"12px"}}> {loadedName} — {duration.toFixed(1)}s / {audioBuffer.numberOfChannels}ch / {audioBuffer.sampleRate}Hz </div>)} </label> </div> <div style={{fontFamily:MONO,fontSize:"10px",color:C.dim}}> Audio will loop seamlessly in mixes. Large files are kept in memory for this session. </div> </Field>)} <Divider/> <Field label="Sound Name"> <Inp value={name} onChange={e=>setName(e.target.value)} placeholder="Name this sound..."/> </Field> {error&&<div style={{color:C.danger,fontFamily:MONO,fontSize:"11px",marginBottom:"10px"}}>{error}</div>} {saved&&<div style={{color:C.teal,fontFamily:MONO,fontSize:"11px",marginBottom:"10px"}}>{saved}</div>} <div style={{display:"flex",gap:"10px",alignItems:"center"}}> <Btn onClick={handlePreview} color={isPreviewing?C.danger:col} style={{border:`1px solid ${isPreviewing?C.danger:col}`,background:isPreviewing?"rgba(244,63,94,0.12)":"transparent"}}> {isPreviewing?"■ Stop Preview":"▶ Preview"} </Btn> {isPreviewing&&(<span style={{fontFamily:MONO,fontSize:"10px",color:col,letterSpacing:"0.1em",animation:"pulse 1.5s infinite"}}>● LIVE</span>)} <Btn onClick={handleSave} solid color={col}>Save to Library</Btn> </div> </div>)}; // ═══════════════════════════════════════════════════════════════ // LIBRARY TAB // ═══════════════════════════════════════════════════════════════ const LibraryTab=({sounds,onPreview,onDelete,previewId})=>{const groups=[{key:"noise",label:"Noise"},{key:"binaural",label:"Binaural Beats"},{key:"tone",label:"Sine Tones"},{key:"import",label:"Imported Audio"},];return(<div> {groups.map(({key,label})=>{const items=sounds.filter(s=>s.type===key); if(!items.length) return null; return(<div key={key} style={{marginBottom:"28px"}}> <div style={{fontFamily:MONO,fontSize:"9px",letterSpacing:"0.18em",textTransform:"uppercase",color:C.muted,borderBottom:`1px solid ${C.border}`,paddingBottom:"6px",marginBottom:"10px",display:"flex",justifyContent:"space-between"}}> <span>{label}</span> <span>{items.length} sound{items.length!==1?"s":""}</span> </div> {items.map(sound=>{const active=previewId===sound.id; return(<div key={sound.id} style={{background:active?C.teal15:C.card,border:`1px solid ${active?C.teal:C.border}`,padding:"11px 14px",marginBottom:"5px",display:"flex",alignItems:"center",justifyContent:"space-between",transition:"all 0.2s",boxShadow:active?`0 0 14px rgba(0,229,192,.2)`:"none",}}> <div> <div style={{fontFamily:MONO,fontSize:"13px",color:C.bright,marginBottom:"5px"}}>{sound.name}</div> <div style={{display:"flex",gap:"8px",alignItems:"center"}}> <TypeTag type={sound.type}/> {sound.type==="binaural"&&<span style={{fontFamily:MONO,fontSize:"10px",color:C.muted}}> {sound.params.carrier}Hz + {sound.params.beat}Hz → {BAND(sound.params.beat)} </span>} {sound.type==="tone"&&<span style={{fontFamily:MONO,fontSize:"10px",color:C.muted}}> {sound.params.frequency}Hz {sound.params.waveform} </span>} {sound.type==="noise"&&<span style={{fontFamily:MONO,fontSize:"10px",color:C.muted}}>built-in</span>} {sound.type==="import"&&<span style={{fontFamily:MONO,fontSize:"10px",color:C.muted}}> {sound.audioBuffer ? `${sound.audioBuffer.duration.toFixed(1)}s` : ""} </span>} {sound.sessionOnly&&(<span style={{fontSize:"9px",fontFamily:MONO,letterSpacing:"0.1em",color:C.warn,border:`1px solid ${C.warn}`,padding:"2px 5px"}}> SESSION </span>)} </div> </div> <div style={{display:"flex",gap:"6px"}}> <Btn sm onClick={()=>onPreview(sound)} color={active?C.danger:C.teal}> {active?"■ Stop":"▶ Play"} </Btn> {!sound.builtin&&<Btn sm onClick={()=>onDelete(sound.id)} color={C.danger}>Del</Btn>} </div> </div>);})} </div>);})} {sounds.length===0&&<div style={{color:C.muted,fontFamily:MONO,fontSize:"13px"}}>Library is empty.</div>} </div>)}; // ═══════════════════════════════════════════════════════════════ // MIX STUDIO TAB // ═══════════════════════════════════════════════════════════════ const MixStudioTab=({sounds,mixes,onSaveMix,onPreviewMix,onDeleteMix,previewMixId,onExportMix,isMobile})=>{const [mixName,setMixName]=useState("");const [tracks,setTracks]=useState([]);const [error,setError]=useState("");const [saved,setSaved]=useState("");const addTrack=sid=>{if(tracks.find(t=>t.soundId===sid))return;setTracks(p=>[...p,{soundId:sid,volume:.75}])}const removeTrack=sid=>setTracks(p=>p.filter(t=>t.soundId!==sid));const setVol=(sid,v)=>setTracks(p=>p.map(t=>t.soundId===sid?{...t,volume:+v}:t));const handleSave=()=>{if(!mixName.trim()){setError("Mix name required");return}if(!tracks.length){setError("Add at least one track");return}setError("");onSaveMix({id:uid(),name:mixName,tracks:[...tracks]});setSaved(`"${mixName}" saved`);setMixName("");setTracks([]);setTimeout(()=>setSaved(""),3000)}const studioMix={id:"__studio__",name:"Studio Preview",tracks}const isPreviewing=previewMixId==="__studio__";return(<div style={{display:"grid",gridTemplateColumns:isMobile?"1fr":"240px 1fr",gap:"20px",alignItems:"start"}}> {} <div> <div style={{fontFamily:MONO,fontSize:"9px",letterSpacing:"0.15em",textTransform:"uppercase",color:C.muted,borderBottom:`1px solid ${C.border}`,paddingBottom:"6px",marginBottom:"10px"}}> Sound Library </div> <div style={{maxHeight:"520px",overflowY:"auto",paddingRight:"4px"}}> {sounds.map(s=>{const inMix=!!tracks.find(t=>t.soundId===s.id); return(<div key={s.id} onClick={()=>addTrack(s.id)} style={{padding:"9px 10px",marginBottom:"4px",cursor:"pointer",background:inMix?C.teal15:C.card,border:`1px solid ${inMix?C.teal:C.border}`,transition:"all 0.15s",}}> <div style={{fontFamily:MONO,fontSize:"12px",color:inMix?C.teal:C.bright,marginBottom:"3px"}}>{s.name}</div> <TypeTag type={s.type}/> </div>);})} </div> </div> {} <div> <div style={{fontFamily:MONO,fontSize:"9px",letterSpacing:"0.15em",textTransform:"uppercase",color:C.muted,borderBottom:`1px solid ${C.border}`,paddingBottom:"6px",marginBottom:"12px",display:"flex",justifyContent:"space-between"}}> <span>Mix Tracks</span> <span>{tracks.length} track{tracks.length!==1?"s":""}</span> </div> {!tracks.length&&(<div style={{color:C.dim,fontFamily:MONO,fontSize:"12px",marginBottom:"16px",border:`1px dashed ${C.border}`,padding:"20px",textAlign:"center"}}> ← Click sounds from the library to add tracks </div>)} {tracks.map(track=>{const sound=sounds.find(s=>s.id===track.soundId); if(!sound) return null; return(<div key={track.soundId} style={{background:C.card,border:`1px solid ${C.border}`,padding:"10px 12px",marginBottom:"6px"}}> <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"8px"}}> <div> <span style={{fontFamily:MONO,fontSize:"12px",color:C.bright,marginRight:"8px"}}>{sound.name}</span> <TypeTag type={sound.type}/> </div> <Btn sm onClick={()=>removeTrack(track.soundId)} color={C.danger}>×</Btn> </div> <div style={{display:"flex",alignItems:"center",gap:"8px"}}> <span style={{fontFamily:MONO,fontSize:"9px",color:C.muted,letterSpacing:"0.1em",textTransform:"uppercase",whiteSpace:"nowrap"}}>Vol</span> <input type="range" min={0} max={1} step={.01} value={track.volume} onChange={e=>setVol(track.soundId,e.target.value)} style={{flex:1,accentColor:C.teal,height:"3px"}}/> <span style={{fontFamily:MONO,fontSize:"11px",color:C.teal,width:"36px",textAlign:"right"}}>{Math.round(track.volume*100)}%</span> </div> </div>);})} {tracks.length>0&&(<div style={{display:"flex",gap:"8px",margin:"12px 0"}}> <Btn onClick={()=>onPreviewMix(studioMix)} color={isPreviewing?C.danger:C.violet}> {isPreviewing?"■ Stop Preview":"▶ Preview Mix"} </Btn> </div>)} <Divider label="Save Mix"/> <Field label="Mix Name"> <Inp value={mixName} onChange={e=>setMixName(e.target.value)} placeholder="Name this mix..."/> </Field> {error&&<div style={{color:C.danger,fontFamily:MONO,fontSize:"11px",marginBottom:"8px"}}>{error}</div>} {saved&&<div style={{color:C.teal,fontFamily:MONO,fontSize:"11px",marginBottom:"8px"}}>{saved}</div>} <Btn onClick={handleSave} solid color={C.teal}>Save Mix</Btn> {} {mixes.length>0&&(<div style={{marginTop:"28px"}}> <Divider label="Saved Mixes"/> {mixes.map(mix=>{const active=previewMixId===mix.id; const orphans=mix.tracks.filter(t=>!sounds.find(s=>s.id===t.soundId)); return(<div key={mix.id} style={{background:active?C.violet15:C.card,border:`1px solid ${active?C.violet:C.border}`,padding:"10px 12px",marginBottom:"5px",display:"flex",alignItems:"center",justifyContent:"space-between",boxShadow:active?`0 0 12px rgba(124,58,237,.25)`:"none",}}> <div> <div style={{fontFamily:MONO,fontSize:"13px",color:C.bright}}>{mix.name}</div> <div style={{fontFamily:MONO,fontSize:"10px",color:C.muted,marginTop:"2px"}}> {mix.tracks.length} track{mix.tracks.length!==1?"s":""} {orphans.length>0&&<span style={{color:C.warn,marginLeft:"8px"}}> ⚠ {orphans.length} track{orphans.length!==1?"s":""} missing (session audio) </span>} </div> </div> <div style={{display:"flex",gap:"6px"}}> <Btn sm onClick={()=>onPreviewMix(mix)} color={active?C.danger:C.violet}> {active?"■ Stop":"▶ Play"} </Btn> <Btn sm onClick={()=>onExportMix(mix)} color={C.teal}>↓ Export</Btn> <Btn sm onClick={()=>onDeleteMix(mix.id)} color={C.danger}>Del</Btn> </div> </div>);})} </div>)} </div> </div>)}; // ═══════════════════════════════════════════════════════════════ // PLAYLIST TAB // ═══════════════════════════════════════════════════════════════ const PlaylistTab=({mixes,playlist,setPlaylist,onPlayPlaylist,playlistStatus,isMobile})=>{const [selMix,setSelMix]=useState("");const [dur,setDur]=useState(30);const add=()=>{if(!selMix) return;const mix=mixes.find(m=>m.id===selMix);if(!mix) return;setPlaylist(p=>[...p,{id:uid(),mixId:selMix,mixName:mix.name,duration:+dur}])}const remove=id=>setPlaylist(p=>p.filter(i=>i.id!==id));const moveUp=(i)=>{if(i===0)return;const a=[...playlist];[a[i-1],a[i]]=[a[i],a[i-1]];setPlaylist(a)}const moveDown=(i)=>{if(i===playlist.length-1)return;const a=[...playlist];[a[i],a[i+1]]=[a[i+1],a[i]];setPlaylist(a)}const totalMin=playlist.reduce((s,i)=>s+i.duration,0);const{playing,currentIndex}=playlistStatus;return(<div> {} {mixes.length===0&&(<div style={{border:`1px dashed ${C.border}`,padding:"24px",textAlign:"center",fontFamily:MONO,fontSize:"12px",color:C.muted,marginBottom:"20px"}}> No mixes saved yet. Create and save mixes in the Mix Studio tab first. </div>)} {mixes.length>0&&(<div style={{background:C.card,border:`1px solid ${C.border}`,padding:"16px",marginBottom:"20px"}}> <div style={{display:"grid",gridTemplateColumns:isMobile?"1fr":"1fr 160px auto",gap:"10px",alignItems:"end"}}> <Field label="Mix" style={{marginBottom:0}}> <Sel value={selMix} onChange={e=>setSelMix(e.target.value)}> <option value="">— Select mix —</option> {mixes.map(m=><option key={m.id} value={m.id}>{m.name}</option>)} </Sel> </Field> <Field label="Duration (min)" style={{marginBottom:0}}> <Inp type="number" min={1} max={480} step={1} value={dur} onChange={e=>setDur(e.target.value)}/> </Field> <Btn onClick={add} solid color={C.teal} style={{marginBottom:"0",alignSelf:"flex-end"}}>Add</Btn> </div> </div>)} {} {playlist.length===0&&mixes.length>0&&(<div style={{color:C.muted,fontFamily:MONO,fontSize:"13px",marginBottom:"20px",border:`1px dashed ${C.border}`,padding:"20px",textAlign:"center"}}> Playlist is empty. Add mixes above. </div>)} {playlist.map((item,i)=>{const isCurrent=playing&&currentIndex===i; return(<div key={item.id} style={{background:isCurrent?C.violet15:C.card,border:`1px solid ${isCurrent?C.violet:C.border}`,padding:"10px 14px",marginBottom:"5px",display:"flex",alignItems:"center",gap:"10px",boxShadow:isCurrent?`0 0 16px rgba(124,58,237,.3)`:"none",transition:"all 0.3s",}}> <span style={{fontFamily:MONO,fontSize:"10px",color:isCurrent?C.violet:C.dim,width:"22px",textAlign:"right"}}>{i+1}</span> {isCurrent&&<span style={{fontSize:"11px",color:C.violet}}>▶</span>} <div style={{flex:1}}> <div style={{fontFamily:MONO,fontSize:"13px",color:isCurrent?C.bright:C.text}}>{item.mixName}</div> <div style={{fontFamily:MONO,fontSize:"10px",color:C.muted,marginTop:"2px"}}>{item.duration} min</div> </div> <div style={{display:"flex",gap:"4px"}}> <Btn sm onClick={()=>moveUp(i)} color={C.muted} disabled={i===0}>↑</Btn> <Btn sm onClick={()=>moveDown(i)} color={C.muted} disabled={i===playlist.length-1}>↓</Btn> <Btn sm onClick={()=>remove(item.id)} color={C.danger}>×</Btn> </div> </div>);})} {} {playlist.length>0&&(<div style={{marginTop:"20px",paddingTop:"16px",borderTop:`1px solid ${C.border}`,display:"flex",alignItems:"center",flexWrap:"wrap",gap:"14px"}}> <Btn onClick={onPlayPlaylist} solid={!playing} color={playing?C.danger:C.teal} style={{minWidth:"140px",justifyContent:"center",display:"flex"}}> {playing?"■ Stop Playlist":"▶ Play Playlist"} </Btn> <div style={{display:"flex",gap:"20px"}}> {[["Items",playlist.length],["Total",`${totalMin} min`],["Duration",`${(totalMin/60).toFixed(1)} hr`],].map(([k,v])=>(<div key={k}> <div style={{fontFamily:MONO,fontSize:"9px",color:C.muted,letterSpacing:"0.1em",textTransform:"uppercase"}}>{k}</div> <div style={{fontFamily:MONO,fontSize:"13px",color:C.text}}>{v}</div> </div>))} </div> {playing&&(<div style={{marginLeft:"auto",background:C.violet15,border:`1px solid ${C.violet}`,padding:"6px 14px",fontFamily:MONO,fontSize:"11px",color:C.violet}}> NOW PLAYING: {playlist[currentIndex]?.mixName} </div>)} </div>)} </div>)}; // ═══════════════════════════════════════════════════════════════ // PERSISTENCE — window.storage (artifact key-value store) // ═══════════════════════════════════════════════════════════════ const DB ={async load(key){try{const r=await window.storage.get(key);return r?JSON.parse(r.value):null}catch(e){return null}},async save(key,val){try{await window.storage.set(key,JSON.stringify(val))}catch(e){console.warn("Storage write failed",e)}},}; // Strip non-serializable fields before writing. Imported AudioBuffers are excluded. function serializeSounds(sounds){return sounds .filter(s=>!s.builtin && s.type!=="import") .map(({audioBuffer,...rest})=>rest);// drop AudioBuffer if somehow present}// ═══════════════════════════════════════════════════════════════ // APP ROOT // ═══════════════════════════════════════════════════════════════ const TABS=[{id:"creator",label:"Creator"},{id:"library",label:"Library"},{id:"studio",label:"Mix Studio"},{id:"playlist",label:"Playlist"},]; export default function App(){const [tab,setTab]=useState("creator");const [sounds,setSounds]=useState(INIT_SOUNDS);const [mixes,setMixes]=useState([]);const [playlist,setPlaylist]=useState([]);const [loaded,setLoaded]=useState(false);const [presets,setPresets]=useState(PRESET_FALLBACK);const [isPlaying,setIsPlaying]=useState(false);const [previewId,setPreviewId]=useState(null);const [previewMixId,setPreviewMixId]=useState(null);const [playlistStatus,setPlaylistStatus]=useState({playing:false,currentIndex:0});const windowWidth = useWindowWidth();const isMobile = windowWidth < 640;// ── Hydrate from storage on mount + fetch presets ── useEffect(()=>{(async()=>{// Fetch presets.json — falls back to PRESET_FALLBACK on any error try{const r = await fetch("/presets.json"); if(r.ok){const j=await r.json(); if(j.presets?.length) setPresets(j.presets);}}catch(e){} const [storedSounds,storedMixes,storedPlaylist] = await Promise.all([DB.load("mw:sounds"),DB.load("mw:mixes"),DB.load("mw:playlist"),]); if(storedSounds?.length) setSounds([...INIT_SOUNDS,...storedSounds]); if(storedMixes?.length) setMixes(storedMixes); if(storedPlaylist?.length) setPlaylist(storedPlaylist); setLoaded(true);})();},[]);const [exportTarget,setExportTarget]=useState(null);// mix being exported const noiseBufs=useRef({});const activeNodes=useRef([]);const analyserRef=useRef(null);const playlistTimers=useRef([]);const soundsRef=useRef(sounds);const mixesRef=useRef(mixes);useEffect(()=>{soundsRef.current=sounds;},[sounds]);useEffect(()=>{mixesRef.current=mixes;},[mixes]);const ensureInit=useCallback(()=>{const {ctx,analyser}=initAudioCtx(); analyserRef.current=analyser; if(!noiseBufs.current.white){["white","pink","brown","violet","blue","grey"].forEach(t=>{noiseBufs.current[t]=buildNoise(ctx,t);});} return {ctx,analyser};},[]);const killAll=useCallback(()=>{activeNodes.current.forEach(n=>{try{n.stop();}catch(e){}}); activeNodes.current=[]; playlistTimers.current.forEach(clearTimeout); playlistTimers.current=[]; setIsPlaying(false); setPreviewId(null); setPreviewMixId(null); setPlaylistStatus(s=>({...s,playing:false}));},[]);const playMixInternal=useCallback((mix,statusOverride=null)=>{const {ctx,analyser}=ensureInit(); const nodes=[]; mix.tracks.forEach(track=>{const sound=soundsRef.current.find(s=>s.id===track.soundId); if(!sound) return; const node=spawnNodes(ctx,analyser,sound,noiseBufs.current,track.volume); if(node) nodes.push(node);}); activeNodes.current=nodes; if(nodes.length>0){setIsPlaying(true); if(!statusOverride) setPreviewMixId(mix.id);}},[ensureInit]);const handlePreviewSound=useCallback(sound=>{if(previewId===sound.id){killAll(); return;} killAll(); const {ctx,analyser}=ensureInit(); const node=spawnNodes(ctx,analyser,sound,noiseBufs.current,.8); if(node){activeNodes.current=[node]; setIsPlaying(true); setPreviewId(sound.id);}},[previewId,killAll,ensureInit]);const handlePreviewMix=useCallback(mix=>{if(previewMixId===mix.id){killAll(); return;} killAll(); setTimeout(()=>{playMixInternal(mix); setPreviewMixId(mix.id);},50);},[previewMixId,killAll,playMixInternal]);const handlePlayPlaylist=useCallback(()=>{if(playlistStatus.playing){killAll(); return;} if(!playlist.length) return; ensureInit(); setPlaylistStatus({playing:true,currentIndex:0}); const advance=(i)=>{if(i>=playlist.length){killAll(); return;} const item=playlist[i]; const mix=mixesRef.current.find(m=>m.id===item.mixId); if(!mix){advance(i+1); return;} // Stop previous nodes activeNodes.current.forEach(n=>{try{n.stop();}catch(e){}}); activeNodes.current=[]; const {ctx,analyser}=initAudioCtx(); const nodes=[]; mix.tracks.forEach(track=>{const sound=soundsRef.current.find(s=>s.id===track.soundId); if(!sound) return; const node=spawnNodes(ctx,analyser,sound,noiseBufs.current,track.volume); if(node) nodes.push(node);}); activeNodes.current=nodes; setIsPlaying(nodes.length>0); setPlaylistStatus({playing:true,currentIndex:i}); const timer=setTimeout(()=>advance(i+1),item.duration*60*1000); playlistTimers.current=[timer];}; advance(0);},[playlistStatus.playing,playlist,killAll,ensureInit]);const handleSaveSound=s=>{// Imported audio is session-only — flag it so Library can show the badge const sound = s.type==="import" ?{...s,sessionOnly:true}: s;setSounds(p=>{const next=[...p,sound]; DB.save("mw:sounds",serializeSounds(next)); return next;})}const handleDeleteSound=id=>{setSounds(p=>{const next=p.filter(s=>s.id!==id||s.builtin); DB.save("mw:sounds",serializeSounds(next)); return next;})}const handleSaveMix=m=>{setMixes(p=>{const next=[...p,m]; DB.save("mw:mixes",next); return next;})}const handleDeleteMix=id=>{setMixes(p=>{const next=p.filter(m=>m.id!==id); DB.save("mw:mixes",next); return next;})}const handleSetPlaylist=updater=>{setPlaylist(prev=>{const next=typeof updater==="function"?updater(prev):updater; DB.save("mw:playlist",next); return next;})}if(!loaded) return(<div style={{background:C.bg,minHeight:"100vh",display:"flex",alignItems:"center",justifyContent:"center",fontFamily:MONO,fontSize:"11px",color:C.muted,letterSpacing:"0.15em"}}> LOADING… </div>);return(<div style={{background:C.bg,minHeight:"100vh",color:C.text,fontFamily:MONO}}> {} <div style={{borderBottom:`1px solid ${C.border}`,background:C.surf}}> <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"10px 24px 0"}}> <div> <span style={{fontSize:"15px",fontWeight:"700",letterSpacing:"0.2em",color:C.teal}}>MINDWAVE</span> <span style={{fontSize:"9px",letterSpacing:"0.2em",color:C.muted,marginLeft:"10px"}}> BINAURAL AUDIO STUDIO </span> </div> <div style={{display:"flex",alignItems:"center",gap:"10px"}}> {isPlaying&&<span style={{fontFamily:MONO,fontSize:"10px",color:C.teal,letterSpacing:"0.1em",animation:"pulse 1.5s infinite"}}>● LIVE</span>} {isPlaying&&<Btn sm onClick={killAll} color={C.danger}>■ Stop All</Btn>} </div> </div> <Visualizer analyserRef={analyserRef} isPlaying={isPlaying}/> {} <div style={{display:"flex",padding:"0 24px"}}> {TABS.map(t=>(<button key={t.id} onClick={()=>setTab(t.id)} style={{background:"transparent",border:"none",borderBottom:`2px solid ${tab===t.id?C.teal:"transparent"}`,color:tab===t.id?C.teal:C.muted,padding:"10px 20px",cursor:"pointer",fontFamily:MONO,fontSize:"11px",letterSpacing:"0.12em",textTransform:"uppercase",transition:"all 0.15s",}}>{t.label}</button>))} <div style={{flex:1}}/> <div style={{display:"flex",alignItems:"center",gap:"16px",padding:"0 4px",fontFamily:MONO,fontSize:"10px",color:C.dim}}> <span>{sounds.length} sounds</span> <span>{mixes.length} mixes</span> <span>{playlist.length} queued</span> </div> </div> </div> {} <div style={{padding: isMobile?"16px":"28px 24px",width:"100%",boxSizing:"border-box"}}> {tab==="creator"&&<CreatorTab onSave={handleSaveSound} onPreviewSound={handlePreviewSound} previewId={previewId} presets={presets} isMobile={isMobile}/>} {tab==="library"&&(<LibraryTab sounds={sounds} onPreview={handlePreviewSound} onDelete={handleDeleteSound} previewId={previewId}/>)} {tab==="studio"&&(<MixStudioTab sounds={sounds} mixes={mixes} onSaveMix={handleSaveMix} onPreviewMix={handlePreviewMix} onDeleteMix={handleDeleteMix} previewMixId={previewMixId} onExportMix={setExportTarget} isMobile={isMobile}/>)} {tab==="playlist"&&(<PlaylistTab mixes={mixes} playlist={playlist} setPlaylist={handleSetPlaylist} onPlayPlaylist={handlePlayPlaylist} playlistStatus={playlistStatus} isMobile={isMobile}/>)} </div> {exportTarget&&(<ExportModal mix={exportTarget} sounds={sounds} onClose={()=>setExportTarget(null)}/>)} <style>{` @keyframes pulse {0%,100%{opacity:1} 50%{opacity:.3}} input[type=range] {-webkit-appearance:none; appearance:none; background:${C.border}; height:3px; cursor:pointer;} input[type=range]::-webkit-slider-thumb {-webkit-appearance:none; width:14px; height:14px; background:${C.teal}; cursor:pointer;} input[type=range]::-moz-range-thumb {width:14px; height:14px; background:${C.teal}; border:none; cursor:pointer;} ::-webkit-scrollbar {width:4px;} ::-webkit-scrollbar-track {background:${C.bg};} ::-webkit-scrollbar-thumb {background:${C.dim};} select option {background:#0d0f14;} `}</style> </div>)}
