Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Passive Sonar Audio Synthesizer</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| --sonar-primary: #00ffaa; | |
| --sonar-secondary: #008f60; | |
| --sonar-bg: #021016; | |
| --panel-bg: rgba(2, 25, 35, 0.85); | |
| --text-muted: #6fa8a1; | |
| --accent-alert: #ff4d4d; | |
| --font-main: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| background-color: var(--sonar-bg); | |
| color: var(--sonar-primary); | |
| font-family: var(--font-main); | |
| height: 100vh; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| background-image: | |
| radial-gradient(circle at 50% 50%, rgba(0, 255, 170, 0.05) 0%, transparent 60%), | |
| linear-gradient(0deg, rgba(0,0,0,0.8) 0%, transparent 100%); | |
| } | |
| /* Header */ | |
| header { | |
| padding: 1rem 2rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid rgba(0, 255, 170, 0.3); | |
| background: rgba(0, 0, 0, 0.5); | |
| backdrop-filter: blur(5px); | |
| z-index: 10; | |
| } | |
| .brand { | |
| font-size: 1.2rem; | |
| font-weight: 700; | |
| letter-spacing: 2px; | |
| text-transform: uppercase; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .brand i { | |
| animation: pulse 2s infinite; | |
| } | |
| .anycoder-link { | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| font-size: 0.9rem; | |
| transition: color 0.3s; | |
| border: 1px solid rgba(111, 168, 161, 0.3); | |
| padding: 5px 12px; | |
| border-radius: 20px; | |
| } | |
| .anycoder-link:hover { | |
| color: var(--sonar-primary); | |
| border-color: var(--sonar-primary); | |
| background: rgba(0, 255, 170, 0.1); | |
| } | |
| /* Main Layout */ | |
| main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 300px 1fr 300px; | |
| gap: 1rem; | |
| padding: 1rem; | |
| overflow: hidden; | |
| } | |
| /* Panels */ | |
| .panel { | |
| background: var(--panel-bg); | |
| border: 1px solid rgba(0, 255, 170, 0.2); | |
| border-radius: 8px; | |
| padding: 1.5rem; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| overflow-y: auto; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.5); | |
| backdrop-filter: blur(10px); | |
| } | |
| .panel-title { | |
| font-size: 1rem; | |
| text-transform: uppercase; | |
| border-bottom: 1px solid var(--sonar-secondary); | |
| padding-bottom: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| /* Visualizer Area */ | |
| .visualizer-container { | |
| grid-column: 2; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| border: 1px solid var(--sonar-secondary); | |
| border-radius: 8px; | |
| background: #000; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .sonar-overlay { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 100%; | |
| height: 100%; | |
| background: conic-gradient(from 0deg, transparent 0deg, transparent 280deg, rgba(0, 255, 170, 0.1) 360deg); | |
| border-radius: 50%; | |
| animation: spin 4s linear infinite; | |
| pointer-events: none; | |
| opacity: 0.3; | |
| mix-blend-mode: screen; | |
| } | |
| /* Controls */ | |
| .control-group { | |
| margin-bottom: 1rem; | |
| } | |
| .control-label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.8rem; | |
| color: var(--text-muted); | |
| margin-bottom: 0.3rem; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: rgba(0, 255, 170, 0.2); | |
| border-radius: 3px; | |
| outline: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--sonar-primary); | |
| cursor: pointer; | |
| box-shadow: 0 0 10px var(--sonar-primary); | |
| margin-top: -5px; | |
| } | |
| input[type="range"]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 6px; | |
| cursor: pointer; | |
| } | |
| /* Buttons */ | |
| .btn { | |
| background: transparent; | |
| border: 1px solid var(--sonar-primary); | |
| color: var(--sonar-primary); | |
| padding: 10px 20px; | |
| font-family: inherit; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| border-radius: 4px; | |
| font-weight: bold; | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| } | |
| .btn:hover { | |
| background: rgba(0, 255, 170, 0.2); | |
| box-shadow: 0 0 15px rgba(0, 255, 170, 0.4); | |
| } | |
| .btn.active { | |
| background: var(--sonar-primary); | |
| color: #000; | |
| } | |
| .btn-trigger { | |
| background: rgba(0, 255, 170, 0.1); | |
| margin-top: 5px; | |
| } | |
| /* Start Overlay */ | |
| #start-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(2, 16, 22, 0.95); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 100; | |
| backdrop-filter: blur(10px); | |
| } | |
| #start-overlay h1 { | |
| font-size: 3rem; | |
| margin-bottom: 1rem; | |
| text-shadow: 0 0 20px var(--sonar-primary); | |
| } | |
| #start-overlay p { | |
| color: var(--text-muted); | |
| margin-bottom: 2rem; | |
| max-width: 600px; | |
| text-align: center; | |
| line-height: 1.6; | |
| } | |
| #start-btn { | |
| font-size: 1.5rem; | |
| padding: 15px 40px; | |
| border: 2px solid var(--sonar-primary); | |
| } | |
| /* Responsive */ | |
| @media (max-width: 1024px) { | |
| main { | |
| grid-template-columns: 1fr 1fr; | |
| grid-template-rows: auto 1fr; | |
| } | |
| .visualizer-container { | |
| grid-column: 1 / -1; | |
| grid-row: 1; | |
| height: 300px; | |
| } | |
| } | |
| @media (max-width: 768px) { | |
| main { | |
| grid-template-columns: 1fr; | |
| overflow-y: auto; | |
| } | |
| .visualizer-container { | |
| height: 250px; | |
| } | |
| body { | |
| overflow: auto; | |
| } | |
| } | |
| /* Animations */ | |
| @keyframes spin { | |
| from { transform: translate(-50%, -50%) rotate(0deg); } | |
| to { transform: translate(-50%, -50%) rotate(360deg); } | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 0.6; text-shadow: 0 0 5px var(--sonar-primary); } | |
| 50% { opacity: 1; text-shadow: 0 0 20px var(--sonar-primary); } | |
| 100% { opacity: 0.6; text-shadow: 0 0 5px var(--sonar-primary); } | |
| } | |
| .status-led { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background-color: #333; | |
| display: inline-block; | |
| margin-right: 8px; | |
| box-shadow: inset 0 0 2px rgba(0,0,0,0.5); | |
| } | |
| .status-led.on { | |
| background-color: var(--sonar-primary); | |
| box-shadow: 0 0 8px var(--sonar-primary); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Audio Context Start Overlay --> | |
| <div id="start-overlay"> | |
| <h1><i class="fa-solid fa-water"></i> PASSIVE SONAR SYNTH</h1> | |
| <p> | |
| ํจ์๋ธ ์๋ ์๋ฎฌ๋ ์ด์ ์ ์ํ ๊ณ ํ์ง ์์ค ์์ ํฉ์ฑ๊ธฐ์ ๋๋ค.<br> | |
| Web Audio API๋ฅผ ์ฌ์ฉํ์ฌ ๊ณ ๋ ์๋ฆฌ, ์ ๋ฐ ํ๋กํ ๋ฌ ์์, ๋ฐ๋ค ๋ฐฐ๊ฒฝ์์ ์ค์๊ฐ์ผ๋ก ์์ฑํฉ๋๋ค. | |
| </p> | |
| <button id="start-btn" class="btn"> | |
| <i class="fa-solid fa-power-off"></i> ์์คํ ๊ฐ๋ (Start System) | |
| </button> | |
| </div> | |
| <header> | |
| <div class="brand"> | |
| <i class="fa-solid fa-radar"></i> | |
| <span>Sonar Audio Lab</span> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link">Built with anycoder</a> | |
| </header> | |
| <main> | |
| <!-- Left Panel: Environment & Biologic --> | |
| <div class="panel"> | |
| <div class="panel-title"> | |
| <i class="fa-solid fa-globe-americas"></i> ํ๊ฒฝ (Environment) | |
| </div> | |
| <!-- Ocean Ambience --> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <span><span class="status-led" id="led-ocean"></span>ํด์ ๋ฐฐ๊ฒฝ์ (Ambience)</span> | |
| <span id="val-ocean-vol">0%</span> | |
| </div> | |
| <input type="range" id="ocean-vol" min="0" max="1" step="0.01" value="0"> | |
| <button class="btn btn-trigger" id="toggle-ocean" style="margin-top:5px; font-size: 0.8rem;"> | |
| ON / OFF | |
| </button> | |
| </div> | |
| <div class="panel-title" style="margin-top: 1rem;"> | |
| <i class="fa-solid fa-fish"></i> ์๋ฌผ (Biologic) | |
| </div> | |
| <!-- Whale Synth --> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <span>๊ณ ๋ ์๋ฆฌ ํฌ๊ธฐ (Whale Volume)</span> | |
| <span id="val-whale-vol">80%</span> | |
| </div> | |
| <input type="range" id="whale-vol" min="0" max="1" step="0.01" value="0.8"> | |
| </div> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <span>ํธ์ถ ๋น๋ (Call Pitch)</span> | |
| <span id="val-whale-pitch">Low</span> | |
| </div> | |
| <input type="range" id="whale-pitch" min="100" max="600" step="10" value="300"> | |
| </div> | |
| <button class="btn" id="btn-whale-call"> | |
| <i class="fa-solid fa-bullhorn"></i> ๊ณ ๋ ํธ์ถ (Trigger Call) | |
| </button> | |
| <div class="control-group" style="margin-top:10px;"> | |
| <div class="control-label"> | |
| <span>ํด๋ฆญ์ (Clicks)</span> | |
| </div> | |
| <button class="btn btn-trigger" id="btn-whale-click"> | |
| <i class="fa-regular fa-circle-dot"></i> ํด๋ฆญ์ ๋ฐ์ | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Center: Visualizer --> | |
| <div class="visualizer-container"> | |
| <canvas id="scope"></canvas> | |
| <div class="sonar-overlay"></div> | |
| <div style="position: absolute; bottom: 10px; left: 10px; font-size: 0.8rem; color: var(--sonar-primary);"> | |
| LOFARGRAM PREVIEW | |
| </div> | |
| </div> | |
| <!-- Right Panel: Mechanical Target --> | |
| <div class="panel"> | |
| <div class="panel-title"> | |
| <i class="fa-solid fa-ship"></i> ํ์ ์์ (Target Noise) | |
| </div> | |
| <div class="control-group"> | |
| <button class="btn" id="toggle-ship"> | |
| <i class="fa-solid fa-power-off"></i> ์์ง ์๋ (Engine Start) | |
| </button> | |
| </div> | |
| <!-- Propeller RPM --> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <span>ํ๋กํ ๋ฌ ์๋ (RPM)</span> | |
| <span id="val-rpm">Stopped</span> | |
| </div> | |
| <input type="range" id="ship-rpm" min="0" max="15" step="0.1" value="0"> | |
| </div> | |
| <!-- Engine Tone --> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <span>์์ง ํค (Engine Tone)</span> | |
| <span id="val-engine-tone">Low</span> | |
| </div> | |
| <input type="range" id="engine-tone" min="50" max="200" step="1" value="80"> | |
| </div> | |
| <!-- Cavitation --> | |
| <div class="control-group"> | |
| <div class="control-label"> | |
| <span>๊ณต๋ ํ์ (Cavitation Noise)</span> | |
| <span id="val-cavitation">50%</span> | |
| </div> | |
| <input type="range" id="cavitation-vol" min="0" max="1" step="0.01" value="0.5"> | |
| </div> | |
| <!-- Distance Filter --> | |
| <div class="control-group" style="margin-top: 1rem; border-top: 1px solid var(--sonar-secondary); padding-top: 1rem;"> | |
| <div class="control-label"> | |
| <span>๊ฑฐ๋ฆฌ ๊ฐ์ (Distance Filter)</span> | |
| <span id="val-distance">Close</span> | |
| </div> | |
| <input type="range" id="distance-filter" min="100" max="2000" step="10" value="2000"> | |
| <p style="font-size: 0.7rem; color: var(--text-muted); margin-top: 5px;"> | |
| ๋ฎ์์๋ก ๋ฉ๋ฆฌ ์๋ ์๋ฆฌ (Low Pass) | |
| </p> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| /** | |
| * Sonar Audio Synthesizer Logic | |
| * Uses Web Audio API to generate sounds procedurally. | |
| */ | |
| // Audio Context | |
| let audioCtx; | |
| let masterGain; | |
| let analyser; | |
| let isSystemOn = false; | |
| // Visualization | |
| const canvas = document.getElementById('scope'); | |
| const canvasCtx = canvas.getContext('2d'); | |
| // State | |
| let oceanNode = null; | |
| let shipNodes = null; | |
| // --- Initialization --- | |
| const initAudio = () => { | |
| if (audioCtx) return; | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| audioCtx = new AudioContext(); | |
| // Master Gain (Volume) | |
| masterGain = audioCtx.createGain(); | |
| masterGain.gain.value = 0.8; | |
| // Analyser for Visualization | |
| analyser = audioCtx.createAnalyser(); | |
| analyser.fftSize = 2048; | |
| masterGain.connect(analyser); | |
| analyser.connect(audioCtx.destination); | |
| drawVisualizer(); | |
| isSystemOn = true; | |
| }; | |
| document.getElementById('start-btn').addEventListener('click', () => { | |
| initAudio(); | |
| if(audioCtx.state === 'suspended') audioCtx.resume(); | |
| document.getElementById('start-overlay').style.opacity = '0'; | |
| setTimeout(() => { | |
| document.getElementById('start-overlay').style.display = 'none'; | |
| }, 500); | |
| }); | |
| // --- Helper: Create Noise Buffer --- | |
| function createNoiseBuffer() { | |
| const bufferSize = audioCtx.sampleRate * 2; // 2 seconds | |
| const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = Math.random() * 2 - 1; // White noise | |
| } | |
| return buffer; | |
| } | |
| // --- Sound Module: Ocean Ambience --- | |
| // Pinkish noise with heavy lowpass filter | |
| let isOceanOn = false; | |
| function toggleOcean() { | |
| if (!audioCtx) return; | |
| if (isOceanOn) { | |
| // Stop | |
| if (oceanNode) { | |
| oceanNode.source.stop(); | |
| oceanNode.source.disconnect(); | |
| oceanNode = null; | |
| } | |
| isOceanOn = false; | |
| document.getElementById('led-ocean').classList.remove('on'); | |
| } else { | |
| // Start | |
| const buffer = createNoiseBuffer(); | |
| const source = audioCtx.createBufferSource(); | |
| source.buffer = buffer; | |
| source.loop = true; | |
| // Filter to make it sound "underwater" (Pink/Brown noise approx) | |
| const filter = audioCtx.createBiquadFilter(); | |
| filter.type = 'lowpass'; | |
| filter.frequency.value = 400; | |
| const gain = audioCtx.createGain(); | |
| gain.gain.value = document.getElementById('ocean-vol').value; | |
| source.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(masterGain); | |
| oceanNode = { source, gain, filter }; | |
| source.start(); | |
| isOceanOn = true; | |
| document.getElementById('led-ocean').classList.add('on'); | |
| } | |
| } | |
| document.getElementById('toggle-ocean').addEventListener('click', toggleOcean); | |
| document.getElementById('ocean-vol').addEventListener('input', (e) => { | |
| document.getElementById('val-ocean-vol').innerText = Math.round(e.target.value * 100) + '%'; | |
| if (oceanNode) oceanNode.gain.gain.setTargetAtTime(e.target.value, audioCtx.currentTime, 0.1); | |
| }); | |
| // --- Sound Module: Whale Synth --- | |
| // Sine waves with pitch bending and reverb/delay | |
| function playWhaleCall() { | |
| if (!audioCtx) return; | |
| const t = audioCtx.currentTime; | |
| const basePitch = parseInt(document.getElementById('whale-pitch').value); | |
| const volume = parseFloat(document.getElementById('whale-vol').value); | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| const filter = audioCtx.createBiquadFilter(); // Smooth out edges | |
| // Simple Reverb using Delay | |
| const delay = audioCtx.createDelay(); | |
| const delayGain = audioCtx.createGain(); | |
| osc.type = 'sine'; | |
| // Pitch Envelope (The "Moan") | |
| osc.frequency.setValueAtTime(basePitch, t); | |
| osc.frequency.exponentialRampToValueAtTime(basePitch * 1.5, t + 1.5); // Pitch up | |
| osc.frequency.exponentialRampToValueAtTime(basePitch * 0.8, t + 3.0); // Pitch down | |
| // Amplitude Envelope | |
| gain.gain.setValueAtTime(0, t); | |
| gain.gain.linearRampToValueAtTime(volume, t + 0.5); // Attack | |
| gain.gain.linearRampToValueAtTime(volume * 0.8, t + 2.0); // Sustain-ish | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 4.0); // Release | |
| // Filter | |
| filter.type = 'lowpass'; | |
| filter.frequency.value = 1500; | |
| // Delay settings (Echo) | |
| delay.delayTime.value = 0.4; | |
| delayGain.gain.value = 0.4; | |
| // Connections | |
| osc.connect(filter); | |
| filter.connect(gain); | |
| // Dry path | |
| gain.connect(masterGain); | |
| // Wet path (Echo) | |
| gain.connect(delay); | |
| delay.connect(delayGain); | |
| delayGain.connect(delay); // Feedback loop | |
| delayGain.connect(masterGain); | |
| osc.start(t); | |
| osc.stop(t + 5.0); | |
| } | |
| function playWhaleClick() { | |
| if (!audioCtx) return; | |
| const t = audioCtx.currentTime; | |
| const volume = parseFloat(document.getElementById('whale-vol').value); | |
| // Create a burst of high frequency noise/sine | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.type = 'triangle'; | |
| osc.frequency.setValueAtTime(3000, t); | |
| osc.frequency.exponentialRampToValueAtTime(100, t + 0.05); | |
| gain.gain.setValueAtTime(volume, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.05); | |
| osc.connect(gain); | |
| gain.connect(masterGain); | |
| osc.start(t); | |
| osc.stop(t + 0.1); | |
| } | |
| document.getElementById('btn-whale-call').addEventListener('click', playWhaleCall); | |
| document.getElementById('btn-whale-click').addEventListener('click', () => { | |
| // Play a sequence of clicks | |
| let count = 0; | |
| const interval = setInterval(() => { | |
| playWhaleClick(); | |
| count++; | |
| if(count > 5) clearInterval(interval); | |
| }, 150); // rapid clicks | |
| }); | |
| document.getElementById('whale-pitch').addEventListener('input', (e) => { | |
| document.getElementById('val-whale-pitch').innerText = e.target.value + 'Hz'; | |
| }); | |
| document.getElementById('whale-vol').addEventListener('input', (e) => { | |
| document.getElementById('val-whale-vol').innerText = Math.round(e.target.value * 100) + '%'; | |
| }); | |
| // --- Sound Module: Ship Propeller --- | |
| // Complex: Engine drone (Oscillator) + Cavitation (Modulated Noise) | |
| let isShipOn = false; | |
| function updateShipParams() { | |
| if (!shipNodes) return; | |
| const rpm = parseFloat(document.getElementById('ship-rpm').value); | |
| const tone = parseFloat(document.getElementById('engine-tone').value); | |
| const cavVol = parseFloat(document.getElementById('cavitation-vol').value); | |
| const distFreq = parseFloat(document.getElementById('distance-filter').value); | |
| // Update Engine Tone | |
| shipNodes.engineOsc.frequency.setTargetAtTime(tone, audioCtx.currentTime, 0.2); | |
| // Update RPM (LFO speed) | |
| // If RPM is 0, stop sound effectively | |
| const lfoFreq = rpm; | |
| shipNodes.lfo.frequency.setTargetAtTime(lfoFreq, audioCtx.currentTime, 0.5); | |
| // Update Volumes based on RPM (faster = louder generally) | |
| const engineBaseVol = rpm > 0 ? 0.3 : 0; | |
| shipNodes.engineGain.gain.setTargetAtTime(engineBaseVol, audioCtx.currentTime, 0.5); | |
| const cavBaseVol = rpm > 0 ? cavVol : 0; | |
| shipNodes.cavitationMasterGain.gain.setTargetAtTime(cavBaseVol, audioCtx.currentTime, 0.5); | |
| // Update Distance Filter | |
| shipNodes.masterFilter.frequency.setTargetAtTime(distFreq, audioCtx.currentTime, 0.2); | |
| } | |
| function toggleShip() { | |
| if (!audioCtx) return; | |
| const btn = document.getElementById('toggle-ship'); | |
| if (isShipOn) { | |
| // Stop | |
| shipNodes.engineOsc.stop(); | |
| shipNodes.lfo.stop(); | |
| shipNodes.noiseSource.stop(); | |
| // Clean disconnect | |
| shipNodes.masterFilter.disconnect(); | |
| shipNodes = null; | |
| isShipOn = false; | |
| btn.classList.remove('active'); | |
| btn.innerHTML = '<i class="fa-solid fa-power-off"></i> ์์ง ์๋ (Engine Start)'; | |
| } else { | |
| // Start | |
| const t = audioCtx.currentTime; | |
| // 1. Master Filter (Distance simulation) | |
| const masterFilter = audioCtx.createBiquadFilter(); | |
| masterFilter.type = 'lowpass'; | |
| masterFilter.frequency.value = 2000; | |
| masterFilter.connect(masterGain); | |
| // 2. LFO (The RPM Rhythm) | |
| const lfo = audioCtx.createOscillator(); | |
| lfo.type = 'sine'; | |
| lfo.frequency.value = 0; // Start at 0 | |
| // 3. Engine Drone (Low Sawtooth) | |
| const engineOsc = audioCtx.createOscillator(); | |
| engineOsc.type = 'sawtooth'; | |
| engineOsc.frequency.value = 100; | |
| const engineGain = audioCtx.createGain(); | |
| engineGain.gain.value = 0; | |
| // Engine LFO modulation (Engine throbs slightly) | |
| const engineLfoGain = audioCtx.createGain(); | |
| engineLfoGain.gain.value = 0.3; // Depth of modulation | |
| lfo.connect(engineLfoGain); | |
| engineLfoGain.connect(engineGain.gain); // AM Synthesis | |
| engineOsc.connect(engineGain); | |
| engineGain.connect(masterFilter); | |
| // 4. Cavitation (White Noise modulated by LFO) | |
| const noiseBuffer = createNoiseBuffer(); | |
| const noiseSource = audioCtx.createBufferSource(); | |
| noiseSource.buffer = noiseBuffer; | |
| noiseSource.loop = true; | |
| const noiseFilter = audioCtx.createBiquadFilter(); | |
| noiseFilter.type = 'bandpass'; | |
| noiseFilter.frequency.value = 800; | |
| noiseFilter.Q.value = 1; | |
| const cavitationGain = audioCtx.createGain(); | |
| cavitationGain.gain.value = 0; // Controlled by LFO | |
| // To map LFO (-1 to 1) to Gain (0 to 1), we need a bias | |
| // But simple AM is fine here. We want the swoosh-swoosh. | |
| const cavLfoGain = audioCtx.createGain(); | |
| cavLfoGain.gain.value = 0.8; // Depth | |
| const cavitationMasterGain = audioCtx.createGain(); | |
| cavitationMasterGain.gain.value = 0; // Overall volume | |
| lfo.connect(cavLfoGain); | |
| cavLfoGain.connect(cavitationGain.gain); | |
| noiseSource.connect(noiseFilter); | |
| noiseFilter.connect(cavitationGain); | |
| cavitationGain.connect(cavitationMasterGain); | |
| cavitationMasterGain.connect(masterFilter); | |
| // Start everything | |
| lfo.start(t); | |
| engineOsc.start(t); | |
| noiseSource.start(t); | |
| shipNodes = { | |
| masterFilter, lfo, engineOsc, engineGain, | |
| noiseSource, cavitationMasterGain | |
| }; | |
| isShipOn = true; | |
| btn.classList.add('active'); | |
| btn.innerHTML = '<i class="fa-solid fa-stop"></i> ์์ง ์ ์ง (Engine Stop)'; | |
| // Apply initial slider values | |
| updateShipParams(); | |
| } | |
| } | |
| document.getElementById('toggle-ship').addEventListener('click', toggleShip); | |
| // Ship Controls Listeners | |
| ['ship-rpm', 'engine-tone', 'cavitation-vol', 'distance-filter'].forEach(id => { | |
| document.getElementById(id).addEventListener('input', (e) => { | |
| // Update UI labels | |
| if(id === 'ship-rpm') document.getElementById('val-rpm').innerText = e.target.value + ' Hz'; | |
| if(id === 'engine-tone') document.getElementById('val-engine-tone').innerText = e.target.value + ' Hz'; | |
| if(id === 'cavitation-vol') document.getElementById('val-cavitation').innerText = Math.round(e.target.value*100) + '%'; | |
| if(id === 'distance-filter') document.getElementById('val-distance').innerText = e.target.value + ' Hz'; | |
| updateShipParams(); | |
| }); | |
| }); | |
| // --- Visualizer Logic --- | |
| function drawVisualizer() { | |
| requestAnimationFrame(drawVisualizer); | |
| const bufferLength = analyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| analyser.getByteTimeDomainData(dataArray); | |
| canvasCtx.fillStyle = 'rgba(0, 15, 20, 0.2)'; // Trail effect | |
| canvasCtx.fillRect(0, 0, canvas.width, canvas.height); | |
| canvasCtx.lineWidth = 2; | |
| canvasCtx.strokeStyle = '#00ffaa'; | |
| canvasCtx.shadowBlur = 5; | |
| canvasCtx.shadowColor = '#00ffaa'; | |
| canvasCtx.beginPath(); | |
| const sliceWidth = canvas.width * 1.0 / bufferLength; | |
| let x = 0; | |
| for (let i = 0; i < bufferLength; i++) { | |
| const v = dataArray[i] / 128.0; | |
| const y = v * canvas.height / 2; | |
| if (i === 0) { | |
| canvasCtx.moveTo(x, y); | |
| } else { | |
| canvasCtx.lineTo(x, y); | |
| } | |
| x += sliceWidth; | |
| } | |
| canvasCtx.lineTo(canvas.width, canvas.height / 2); | |
| canvasCtx.stroke(); | |
| } | |
| // Resize Canvas | |
| function resizeCanvas() { | |
| const container = document.querySelector('.visualizer-container'); | |
| canvas.width = container.clientWidth; | |
| canvas.height = container.clientHeight; | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| resizeCanvas(); | |
| </script> | |
| </body> | |
| </html> |