Pulsatile Tinnitus & Ear Clicking Sound Generator

Does your tinnitus pulse, whoosh, click, or throb? This free tool helps you match and identify the sound you hear, so you can describe it to your doctor.

A note about pulsatile tinnitus and your health: Pulsatile tinnitus is different from the constant ringing most people associate with tinnitus. Because it often involves blood flow, it can sometimes point to an underlying condition that a doctor can identify and treat. This is good news -- unlike most tinnitus, pulsatile tinnitus often has a specific, treatable cause. We recommend that anyone with pulsatile tinnitus discuss it with their doctor, especially if it is a new symptom.

Match Pulsating Sound

My tinnitus pulses, whooshes, or thumps -- often in rhythm with my heartbeat.

Match Ear Clicking

My ear clicks, pops, crackles, or flutters -- regularly or irregularly.

Browse Sound Presets

Listen to example sounds of common pulsatile tinnitus and ear clicking presentations.

Quick Symptom Check

These questions help us provide relevant guidance. Your answers are not stored or transmitted -- all processing happens in your browser.

1. When did this sound start?

2. Does the sound sync with your heartbeat?

3. Is the sound in one ear or both?

4. Do you have any of these associated symptoms? (check all that apply)

5. Have you seen a doctor about this sound?


Quick Presets:
Click to play
Very Low — Low — Mid — High
Resting heart rate range
Tap along with your pulse or tinnitus rhythm
Pulse Shape:
Subtle pulsation ——— Full silence between pulses
Tone Character:
Wide (breathy) ——— Narrow (tonal)
Ear:
Quick Presets:
Click to play
Click Character:
Rate Mode:
Dull thuds ——— Sharp, bright clicks
Ear:

Pulsatile Tinnitus Presets

Ear Clicking Presets

Saved Profiles

No saved profiles yet. Use the "Save Profile" button after adjusting sounds to your liking.

Share This With Your Doctor

Your matched sound profile can help your doctor understand exactly what you are hearing. You can export your settings and bring them to your appointment.

When to seek medical evaluation:

  • Your pulsatile tinnitus is new (started within the past few weeks)
  • It is only in one ear
  • It is getting louder or more frequent
  • You also have headaches, vision changes, or dizziness

What kind of doctor to see: An ENT (otolaryngologist) is usually the first specialist to evaluate pulsatile tinnitus. They may order imaging such as an MRI/MRA, CT angiography, or ultrasound of the neck arteries.

What Is Pulsatile Tinnitus?

Pulsatile tinnitus is a rhythmic sound perceived in one or both ears that typically synchronizes with the heartbeat. Unlike the steady ringing, buzzing, or hissing of tonal tinnitus, pulsatile tinnitus has a distinct temporal pattern. Patients commonly describe it as a whooshing, thumping, pulsing, or rushing sound that keeps time with their pulse.

The key difference is that tonal tinnitus is a phantom sound generated by the nervous system, while pulsatile tinnitus usually has a physical, identifiable sound source -- most often turbulent blood flow near the ear. This makes pulsatile tinnitus fundamentally different in both its causes and its clinical significance. Approximately 10% of people with tinnitus experience pulsatile symptoms.

Common Causes of Pulsatile Tinnitus

Because pulsatile tinnitus often originates from blood flow, its causes are frequently vascular:

  • Atherosclerosis: Narrowing of the carotid artery or its branches near the ear, creating turbulent blood flow.
  • High blood pressure: Increased force of blood flow can make the sound of blood moving through vessels near the ear audible.
  • Arteriovenous malformations: Abnormal connections between arteries and veins near the ear creating high-velocity turbulent flow.
  • Idiopathic intracranial hypertension (IIH): Elevated cerebrospinal fluid pressure causing venous sinus changes and turbulent flow.
  • Glomus tumors: Highly vascular benign tumors of the middle ear or jugular bulb.
  • Middle ear myoclonus: Involuntary rhythmic contractions of the muscles in the middle ear.

Pulsatile Tinnitus vs. Regular Tinnitus

Regular (tonal) tinnitus produces a constant ringing, buzzing, or hissing sound and is typically caused by hearing loss or neural changes. In contrast, pulsatile tinnitus produces a rhythmic sound and is more likely to have an identifiable, potentially treatable physical cause. AudioNotch's notched sound therapy is designed for tonal tinnitus. For pulsatile tinnitus, the priority is identifying and treating the underlying cause through medical evaluation.

What Is Ear Clicking?

Ear clicking refers to repetitive clicking, popping, or crackling sounds perceived in the ear. These sounds are typically caused by involuntary contractions of the middle ear muscles, Eustachian tube dysfunction, or palatal myoclonus.

  • Middle ear myoclonus: Rhythmic clicking from involuntary contractions of the tensor tympani or stapedius muscles.
  • Eustachian tube dysfunction: Clicking or popping when swallowing, yawning, or with jaw movement.
  • Palatal myoclonus: Rhythmic clicking from palatal muscle spasm, often audible to others.
  • TMJ disorders: Clicking associated with jaw movement, not true tinnitus but frequently reported as ear clicking.

If ear clicking is persistent, worsening, or accompanied by hearing loss, a visit to an ENT specialist is recommended.

Frequently Asked Questions

Is pulsatile tinnitus dangerous?

Pulsatile tinnitus is different from regular tinnitus because it often has an identifiable physical cause. While many causes are benign, some can indicate conditions that benefit from treatment. We recommend that anyone with pulsatile tinnitus discuss it with their doctor, especially if it is a new symptom or accompanied by headaches, vision changes, or dizziness.

Can pulsatile tinnitus go away on its own?

Some cases resolve on their own, particularly those related to temporary conditions like ear infections, stress, or high blood pressure. However, because pulsatile tinnitus can sometimes indicate an underlying condition, it is best to have it evaluated by a healthcare professional.

What does pulsatile tinnitus sound like?

It is commonly described as a whooshing, thumping, rushing, or pulsing sound that keeps time with your heartbeat. Some people describe it as hearing their heartbeat in their ear. Our sound generator above lets you recreate and match these sounds to help describe your symptoms to a doctor.

Why do I hear clicking in my ear?

Ear clicking can have several causes including middle ear myoclonus, Eustachian tube dysfunction, tensor tympani syndrome, or palatal myoclonus. If clicking is persistent or bothersome, an ENT specialist can help determine the cause.

Should I see a doctor for pulsatile tinnitus?

Yes. Unlike constant ringing tinnitus, pulsatile tinnitus often has a specific, identifiable cause that may be treatable. This is especially important if the symptom is new, only in one ear, getting worse, or accompanied by headaches, vision changes, or dizziness. An ENT specialist is typically the best first doctor to see.

Medical Disclaimer

This tool is provided for educational and sound-matching purposes only. It is not a diagnostic tool and cannot identify the cause of your tinnitus.

Important: Pulsatile tinnitus can be a symptom of underlying medical conditions, some of which require treatment. Unlike constant ringing tinnitus, pulsatile tinnitus often has an identifiable and treatable cause. We strongly recommend that anyone experiencing pulsatile tinnitus consult with a healthcare professional, particularly an ear, nose, and throat (ENT) specialist.

AudioNotch does not provide medical advice, diagnosis, or treatment. The information and tools on this page are not a substitute for professional medical evaluation.

Do you also hear a constant ringing or buzzing?

AudioNotch's notched sound therapy is designed to treat steady tonal tinnitus. If you experience both pulsatile and tonal tinnitus, our therapy tools may help with the tonal component.

Try Our Frequency Matcher

Save Sound Profile

Explore More Free Tinnitus Tools

Tinnitus Frequency Matcher

Find the exact frequency of your tinnitus with guided binary search and octave confusion detection.

Tinnitus Handicap Inventory

Take the clinically validated 25-question THI assessment. Track your tinnitus severity over time.

Free Online Hearing Test

Get a full audiogram with calibrated pure-tone audiometry. Test all standard frequencies from 250 Hz to 8 kHz.


// ================================================================== // TRANSLATION OBJECT // ================================================================== var T = { clickToPlay: "Click to play", playing: "Playing...", detected: "Detected:", bpm: "BPM", confidenceGood: "Good", confidenceFair: "Fair", confidenceLow: "Low", confidence: "Confidence:", keepTapping: "Keep tapping...", taps: "taps", tapAlongPrompt: "Tap along with your pulse or tinnitus rhythm", belowRestingHR: "Below resting heart rate", restingHRRange: "Resting heart rate range", elevatedHR: "Elevated / exercise heart rate", weRecommendSeeingDoctor: "We recommend seeing a doctor soon.", basedOnResponses: "Based on your responses, your symptoms include recent onset and", whileManyBenign: "While there are many benign causes of pulsatile tinnitus, these features are worth having a doctor evaluate promptly. An ENT specialist or neurologist can perform imaging studies to determine the cause.", noteNewPulsatile: "New pulsatile tinnitus is worth mentioning to your doctor at your next visit, even if you do not have other symptoms.", noteAssociatedSymptoms: "You mentioned associated symptoms", recommendDiscussing: "We recommend discussing these with a doctor to rule out treatable causes.", note: "Note:", noSavedProfiles: "No saved profiles yet.", load: "Load", loadAndCustomize: "Load & Customize", deleteProfile: "Delete profile", browserNotSupported: "Browser Not Supported", browserNotSupportedMsg: "Your browser does not support the Web Audio API required for this tool. Please use a modern browser like Chrome, Firefox, Safari, or Edge.", headaches: "headaches", visionChanges: "vision changes", dizziness: "dizziness", hearingLoss: "hearing loss", facialNumbness: "facial numbness or weakness", loadPreset: "Load preset:" }; // ================================================================== // PULSATILE TINNITUS & EAR CLICKING GENERATOR - AudioNotch // ================================================================== // --- PRESETS --- var PULSATILE_PRESETS = [ { id:'heartbeat-whoosh', name:'Heartbeat Whoosh', description:'Low-frequency whooshing synchronized with the pulse', params:{ baseFrequency:150, pulseRate:72, pulseShape:'soft-whoosh', pulseWidth:250, modulationDepth:85, toneCharacter:'filtered-noise', filterQ:5, volume:50, pan:0 }}, { id:'arterial-thump', name:'Arterial Thump', description:'Sharp thumping pulse felt deep in the ear', params:{ baseFrequency:100, pulseRate:72, pulseShape:'sharp-pulse', pulseWidth:150, modulationDepth:95, toneCharacter:'filtered-noise', filterQ:10, volume:50, pan:0 }}, { id:'venous-hum', name:'Venous Hum', description:'Continuous low hum with gentle pulsatile modulation', params:{ baseFrequency:120, pulseRate:72, pulseShape:'soft-whoosh', pulseWidth:300, modulationDepth:40, toneCharacter:'filtered-noise', filterQ:3, volume:50, pan:0 }}, { id:'rushing-waterfall', name:'Rushing / Waterfall', description:'Broadband rushing with rhythmic intensity changes', params:{ baseFrequency:300, pulseRate:75, pulseShape:'soft-whoosh', pulseWidth:280, modulationDepth:60, toneCharacter:'broadband', filterQ:5, volume:50, pan:0 }}, { id:'double-beat', name:'Double Beat', description:'Lub-dub heartbeat where both heart sounds are audible', params:{ baseFrequency:100, pulseRate:72, pulseShape:'double-pulse', pulseWidth:200, modulationDepth:90, toneCharacter:'filtered-noise', filterQ:8, volume:50, pan:0 }}, { id:'exercise-pulse', name:'Exercise Pulse', description:'Fast prominent pulsing during or after physical activity', params:{ baseFrequency:200, pulseRate:120, pulseShape:'sharp-pulse', pulseWidth:120, modulationDepth:80, toneCharacter:'filtered-noise', filterQ:6, volume:50, pan:0 }} ]; var CLICKING_PRESETS = [ { id:'rhythmic-clicking', name:'Rhythmic Clicking', description:'Regular metronome-like clicking from middle ear myoclonus', params:{ clickCharacter:'sharp-click', clickRate:3, clickMode:'regular', regularity:100, brightness:1000, pan:0, volume:50 }}, { id:'typewriter', name:'Typewriter Tinnitus', description:'Irregular clicking bursts resembling typewriter keys', params:{ clickCharacter:'sharp-click', clickRate:5, clickMode:'irregular', regularity:30, brightness:4000, pan:0, volume:50 }}, { id:'eustachian-pop', name:'Eustachian Pop', description:'Soft popping like swallowing or yawning', params:{ clickCharacter:'soft-pop', clickRate:1, clickMode:'irregular', regularity:50, brightness:200, pan:0, volume:50 }}, { id:'crackling-ear', name:'Crackling Ear', description:'Crackling or Rice Krispies sound', params:{ clickCharacter:'crackling', clickRate:2, clickMode:'irregular', regularity:20, brightness:1000, pan:0, volume:50 }}, { id:'fluttering', name:'Fluttering / Buzzing', description:'Rapid fluttering from high-rate muscle spasm', params:{ clickCharacter:'flutter', clickRate:20, clickMode:'regular', regularity:100, brightness:600, pan:0, volume:50 }}, { id:'palatal-myoclonus', name:'Palatal Myoclonus', description:'Regular clicking at 1-3 Hz from palatal muscle spasm', params:{ clickCharacter:'soft-pop', clickRate:2, clickMode:'regular', regularity:100, brightness:200, pan:0, volume:50 }} ]; // --- STATE --- var audioCtx = null; var pulseIsPlaying = false; var clickIsPlaying = false; var currentToolTab = 'pulsatile'; var screeningTarget = 'pulsatile'; // Pulsatile audio nodes var pulseSource = null; var pulseNoiseBuffer = null; var pulseGainNode = null; var pulseMasterGain = null; var pulsePanner = null; var pulseAnalyser = null; var pulseFilter = null; var pulseSchedulerId = null; var pulseNextTime = 0; // Clicking audio nodes var clickMasterGain = null; var clickPanner = null; var clickAnalyser = null; var clickSchedulerId = null; var clickNextTime = 0; // Tap tempo var tapTimes = []; var tapMaxTaps = 8; var tapTimeout = 3000; var tapMinInterval = 200; // Visualization var pulseAnimId = null; var clickAnimId = null; var pulseWaveAnimId = null; var clickWaveAnimId = null; // --- UTILITY --- function getAudioContext() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } return audioCtx; } function createNoiseBuffer(ctx) { if (pulseNoiseBuffer) return pulseNoiseBuffer; var sr = ctx.sampleRate; var buf = ctx.createBuffer(1, sr * 2, sr); var data = buf.getChannelData(0); for (var i = 0; i < data.length; i++) { data[i] = Math.random() * 2 - 1; } pulseNoiseBuffer = buf; return buf; } // Logarithmic frequency slider: map 0-1000 -> 50-2000 Hz logarithmically function sliderToFreq(val) { var minLog = Math.log(50); var maxLog = Math.log(2000); return Math.round(Math.exp(minLog + (val / 1000) * (maxLog - minLog))); } function freqToSlider(freq) { var minLog = Math.log(50); var maxLog = Math.log(2000); return Math.round(((Math.log(freq) - minLog) / (maxLog - minLog)) * 1000); } // --- SECTION/TAB MANAGEMENT --- function showSection(id) { var sections = document.querySelectorAll('.ptg-section'); for (var i = 0; i < sections.length; i++) { sections[i].classList.remove('active'); } var el = document.getElementById(id); if (el) el.classList.add('active'); } function switchToolTab(tab) { currentToolTab = tab; var tabs = document.querySelectorAll('.ptg-tab'); var contents = document.querySelectorAll('.ptg-tab-content'); for (var i = 0; i < tabs.length; i++) { tabs[i].classList.remove('active'); tabs[i].setAttribute('aria-selected','false'); } for (var i = 0; i < contents.length; i++) { contents[i].classList.remove('active'); } if (tab === 'pulsatile') { tabs[0].classList.add('active'); tabs[0].setAttribute('aria-selected','true'); document.getElementById('ptg-tab-pulsatile').classList.add('active'); } else if (tab === 'clicking') { tabs[1].classList.add('active'); tabs[1].setAttribute('aria-selected','true'); document.getElementById('ptg-tab-clicking').classList.add('active'); } else { tabs[2].classList.add('active'); tabs[2].setAttribute('aria-selected','true'); document.getElementById('ptg-tab-presets').classList.add('active'); loadPresetLibrary(); } } function toggleFAQ(el) { var item = el.parentElement; item.classList.toggle('open'); } // --- SCREENING --- function startScreening(target) { screeningTarget = target; showSection('ptg-screening'); } function skipScreening() { showSection('ptg-tool'); if (screeningTarget === 'clicking') switchToolTab('clicking'); else switchToolTab('pulsatile'); renderPresetRows(); } function submitScreening() { var sq1 = document.querySelector('input[name="sq1"]:checked'); var sq4boxes = document.querySelectorAll('input[name="sq4"]:checked'); var sq5 = document.querySelector('input[name="sq5"]:checked'); var onset = sq1 ? sq1.value : 'unknown'; var redFlags = []; for (var i = 0; i < sq4boxes.length; i++) { var v = sq4boxes[i].value; if (v !== 'none') redFlags.push(v); } var seenDoc = sq5 ? sq5.value : 'unknown'; var banner = document.getElementById('ptg-screening-warning-banner'); var flagNames = { headaches: T.headaches, vision: T.visionChanges, dizziness: T.dizziness, hearing_loss: T.hearingLoss, facial: T.facialNumbness }; if ((onset === 'days' || onset === 'weeks') && redFlags.length > 0) { var flagList = redFlags.map(function(f){ return flagNames[f] || f; }).join(', '); banner.innerHTML = '

' + T.weRecommendSeeingDoctor + ' ' + T.basedOnResponses + ' ' + flagList + '. ' + T.whileManyBenign + '

'; banner.classList.remove('ptg-hidden'); } else if (onset === 'days' || onset === 'weeks') { banner.innerHTML = '

' + T.note + ' ' + T.noteNewPulsatile + '

'; banner.classList.remove('ptg-hidden'); } else if (seenDoc === 'no' && redFlags.length > 0) { banner.innerHTML = '

' + T.note + ' ' + T.noteAssociatedSymptoms + ' (' + redFlags.map(function(f){ return flagNames[f] || f; }).join(', ') + '). ' + T.recommendDiscussing + '

'; banner.classList.remove('ptg-hidden'); } else { banner.classList.add('ptg-hidden'); } showSection('ptg-tool'); if (screeningTarget === 'clicking') switchToolTab('clicking'); else switchToolTab('pulsatile'); renderPresetRows(); } // Mutual exclusion: "None of the above" deselects symptoms and vice versa function onSymptomCheck(el) { var allBoxes = document.querySelectorAll('input[name="sq4"]'); if (el.value === 'none' && el.checked) { for (var i = 0; i < allBoxes.length; i++) { if (allBoxes[i].value !== 'none') allBoxes[i].checked = false; } } else if (el.value !== 'none' && el.checked) { for (var i = 0; i < allBoxes.length; i++) { if (allBoxes[i].value === 'none') allBoxes[i].checked = false; } } } // --- PULSATILE TINNITUS AUDIO ENGINE --- function getPulseParams() { var freqSlider = document.getElementById('ptg-freq'); return { baseFrequency: sliderToFreq(parseInt(freqSlider.value)), pulseRate: parseInt(document.getElementById('ptg-bpm').value), pulseShape: (document.querySelector('input[name="pulseShape"]:checked') || {}).value || 'soft-whoosh', pulseWidth: parseInt(document.getElementById('ptg-pw').value) / 1000, modulationDepth: parseInt(document.getElementById('ptg-mod').value) / 100, toneCharacter: (document.querySelector('input[name="toneChar"]:checked') || {}).value || 'filtered-noise', filterQ: parseInt(document.getElementById('ptg-filterq').value), volume: parseInt(document.getElementById('ptg-vol-pulse').value) / 100, pan: parseFloat((document.querySelector('input[name="pulsePan"]:checked') || {}).value || '0') }; } function buildPulseGraph() { var ctx = getAudioContext(); var p = getPulseParams(); // Cleanup old nodes destroyPulseGraph(); // Create nodes pulseGainNode = ctx.createGain(); pulseGainNode.gain.value = 0.0001; pulseMasterGain = ctx.createGain(); pulseMasterGain.gain.value = p.volume; pulsePanner = ctx.createStereoPanner(); pulsePanner.pan.value = p.pan; pulseAnalyser = ctx.createAnalyser(); pulseAnalyser.fftSize = 2048; // Source if (p.toneCharacter === 'sine') { pulseSource = ctx.createOscillator(); pulseSource.type = 'sine'; pulseSource.frequency.value = p.baseFrequency; pulseSource.connect(pulseGainNode); pulseSource.start(); } else if (p.toneCharacter === 'filtered-noise') { var noiseBuf = createNoiseBuffer(ctx); pulseSource = ctx.createBufferSource(); pulseSource.buffer = noiseBuf; pulseSource.loop = true; pulseFilter = ctx.createBiquadFilter(); pulseFilter.type = 'bandpass'; pulseFilter.frequency.value = p.baseFrequency; pulseFilter.Q.value = p.filterQ; pulseSource.connect(pulseFilter); pulseFilter.connect(pulseGainNode); pulseSource.start(); } else if (p.toneCharacter === 'broadband') { var noiseBuf = createNoiseBuffer(ctx); pulseSource = ctx.createBufferSource(); pulseSource.buffer = noiseBuf; pulseSource.loop = true; pulseSource.connect(pulseGainNode); pulseSource.start(); } else if (p.toneCharacter === 'click-impulse') { var noiseBuf = createNoiseBuffer(ctx); pulseSource = ctx.createBufferSource(); pulseSource.buffer = noiseBuf; pulseSource.loop = true; pulseFilter = ctx.createBiquadFilter(); pulseFilter.type = 'bandpass'; pulseFilter.frequency.value = p.baseFrequency; pulseFilter.Q.value = 15; pulseSource.connect(pulseFilter); pulseFilter.connect(pulseGainNode); pulseSource.start(); } // Chain: pulseGain -> masterGain -> panner -> analyser -> destination pulseGainNode.connect(pulseMasterGain); pulseMasterGain.connect(pulsePanner); pulsePanner.connect(pulseAnalyser); pulseAnalyser.connect(ctx.destination); } function destroyPulseGraph() { if (pulseSource) { try { pulseSource.stop(); } catch(e){} try { pulseSource.disconnect(); } catch(e){} pulseSource = null; } if (pulseFilter) { try { pulseFilter.disconnect(); } catch(e){} pulseFilter = null; } if (pulseGainNode) { try { pulseGainNode.disconnect(); } catch(e){} pulseGainNode = null; } if (pulseMasterGain) { try { pulseMasterGain.disconnect(); } catch(e){} pulseMasterGain = null; } if (pulsePanner) { try { pulsePanner.disconnect(); } catch(e){} pulsePanner = null; } if (pulseAnalyser) { try { pulseAnalyser.disconnect(); } catch(e){} pulseAnalyser = null; } } function schedulePulse(startTime, p) { if (!pulseGainNode) return; var gain = pulseGainNode.gain; var minGain = Math.max(1.0 - p.modulationDepth, 0.0001); var maxGain = 1.0; var dur = p.pulseWidth; switch (p.pulseShape) { case 'soft-whoosh': gain.setValueAtTime(minGain, startTime); gain.linearRampToValueAtTime(maxGain, startTime + dur * 0.35); gain.linearRampToValueAtTime(maxGain, startTime + dur * 0.55); gain.linearRampToValueAtTime(minGain, startTime + dur * 0.9); break; case 'sharp-pulse': gain.setValueAtTime(minGain, startTime); gain.linearRampToValueAtTime(maxGain, startTime + dur * 0.05); gain.exponentialRampToValueAtTime(Math.max(minGain, 0.001), startTime + dur * 0.9); break; case 'click': var clickDur = Math.min(dur, 0.05); gain.setValueAtTime(minGain, startTime); gain.linearRampToValueAtTime(maxGain, startTime + 0.002); gain.linearRampToValueAtTime(minGain, startTime + clickDur); break; case 'double-pulse': var firstDur = dur * 0.25; var gap = 0.2; var secondDur = dur * 0.2; gain.setValueAtTime(minGain, startTime); gain.linearRampToValueAtTime(maxGain, startTime + firstDur * 0.1); gain.exponentialRampToValueAtTime(Math.max(minGain, 0.001), startTime + firstDur); var secondStart = startTime + firstDur + gap; var secondMax = minGain + (maxGain - minGain) * 0.6; gain.setValueAtTime(Math.max(minGain, 0.001), secondStart); gain.linearRampToValueAtTime(secondMax, secondStart + secondDur * 0.1); gain.exponentialRampToValueAtTime(Math.max(minGain, 0.001), secondStart + secondDur); break; } } function startPulseScheduler() { var ctx = getAudioContext(); pulseNextTime = ctx.currentTime + 0.05; pulseSchedulerId = setInterval(function() { if (!pulseGainNode || !audioCtx) return; var p = getPulseParams(); var lookAhead = 0.1; var beatInterval = 60.0 / p.pulseRate; while (pulseNextTime < audioCtx.currentTime + lookAhead) { schedulePulse(pulseNextTime, p); pulseNextTime += beatInterval; } }, 25); } function stopPulseScheduler() { if (pulseSchedulerId) { clearInterval(pulseSchedulerId); pulseSchedulerId = null; } } function togglePulsatilePlay() { if (pulseIsPlaying) { stopPulseScheduler(); destroyPulseGraph(); pulseIsPlaying = false; document.getElementById('ptg-play-pulse-icon').className = 'fa fa-play'; document.getElementById('ptg-play-pulse').classList.remove('playing'); document.getElementById('ptg-play-status-pulse').textContent = T.clickToPlay; stopPulseVisualization(); } else { if (clickIsPlaying) { toggleClickingPlay(); } buildPulseGraph(); startPulseScheduler(); pulseIsPlaying = true; document.getElementById('ptg-play-pulse-icon').className = 'fa fa-pause'; document.getElementById('ptg-play-pulse').classList.add('playing'); document.getElementById('ptg-play-status-pulse').textContent = T.playing; startPulseVisualization(); } } // --- CLICKING AUDIO ENGINE --- function getClickParams() { return { clickCharacter: (document.querySelector('input[name="clickChar"]:checked') || {}).value || 'sharp-click', clickRate: parseInt(document.getElementById('ptg-clickrate').value), clickMode: (document.querySelector('input[name="clickMode"]:checked') || {}).value || 'regular', regularity: parseInt(document.getElementById('ptg-regularity').value) / 100, brightness: parseInt(document.getElementById('ptg-brightness').value), pan: parseFloat((document.querySelector('input[name="clickPan"]:checked') || {}).value || '0'), volume: parseInt(document.getElementById('ptg-vol-click').value) / 100 }; } function createClickBuffer(ctx, character) { var sr = ctx.sampleRate; var buf, data; switch (character) { case 'sharp-click': buf = ctx.createBuffer(1, Math.floor(sr * 0.005), sr); data = buf.getChannelData(0); data[0] = 1.0; for (var i = 1; i < data.length; i++) { data[i] = Math.exp(-i / (sr * 0.001)); } return buf; case 'soft-pop': buf = ctx.createBuffer(1, Math.floor(sr * 0.02), sr); data = buf.getChannelData(0); for (var i = 0; i < data.length; i++) { var t = i / sr; data[i] = Math.sin(2 * Math.PI * 200 * t) * Math.exp(-t / 0.005); } return buf; case 'crackling': buf = ctx.createBuffer(1, Math.floor(sr * 0.08), sr); data = buf.getChannelData(0); var numClicks = 3 + Math.floor(Math.random() * 5); for (var c = 0; c < numClicks; c++) { var pos = Math.floor(Math.random() * data.length); var amp = 0.3 + Math.random() * 0.7; data[pos] = amp; for (var i = 1; i < Math.min(Math.floor(sr * 0.003), data.length - pos); i++) { data[pos + i] += amp * Math.exp(-i / (sr * 0.0008)); } } return buf; case 'flutter': buf = ctx.createBuffer(1, Math.floor(sr * 0.1), sr); data = buf.getChannelData(0); var modFreq = 25; for (var i = 0; i < data.length; i++) { var t = i / sr; data[i] = (Math.random() * 2 - 1) * Math.exp(-t / 0.05) * (0.5 + 0.5 * Math.sin(2 * Math.PI * modFreq * t)); } return buf; default: buf = ctx.createBuffer(1, Math.floor(sr * 0.005), sr); data = buf.getChannelData(0); data[0] = 1.0; for (var i = 1; i < data.length; i++) { data[i] = Math.exp(-i / (sr * 0.001)); } return buf; } } function playOneClick(ctx, time, cp) { var buf = createClickBuffer(ctx, cp.clickCharacter); var source = ctx.createBufferSource(); source.buffer = buf; var filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = cp.brightness; filter.Q.value = 1; source.connect(filter); filter.connect(clickMasterGain); source.start(time); } function buildClickGraph() { var ctx = getAudioContext(); var cp = getClickParams(); destroyClickGraph(); clickMasterGain = ctx.createGain(); clickMasterGain.gain.value = cp.volume; clickPanner = ctx.createStereoPanner(); clickPanner.pan.value = cp.pan; clickAnalyser = ctx.createAnalyser(); clickAnalyser.fftSize = 2048; clickMasterGain.connect(clickPanner); clickPanner.connect(clickAnalyser); clickAnalyser.connect(ctx.destination); } function destroyClickGraph() { if (clickMasterGain) { try { clickMasterGain.disconnect(); } catch(e){} clickMasterGain = null; } if (clickPanner) { try { clickPanner.disconnect(); } catch(e){} clickPanner = null; } if (clickAnalyser) { try { clickAnalyser.disconnect(); } catch(e){} clickAnalyser = null; } } function startClickScheduler() { var ctx = getAudioContext(); clickNextTime = ctx.currentTime + 0.05; clickSchedulerId = setInterval(function() { if (!clickMasterGain || !audioCtx) return; var cp = getClickParams(); var lookAhead = 0.15; var baseInterval = 1.0 / cp.clickRate; while (clickNextTime < audioCtx.currentTime + lookAhead) { playOneClick(audioCtx, clickNextTime, cp); if (cp.clickMode === 'regular') { clickNextTime += baseInterval; } else { var jitter = (1 - cp.regularity) * (-Math.log(Math.random() + 0.0001) * baseInterval); var regularPart = cp.regularity * baseInterval; clickNextTime += Math.max(regularPart + jitter, 0.03); } } }, 25); } function stopClickScheduler() { if (clickSchedulerId) { clearInterval(clickSchedulerId); clickSchedulerId = null; } } function toggleClickingPlay() { if (clickIsPlaying) { stopClickScheduler(); destroyClickGraph(); clickIsPlaying = false; document.getElementById('ptg-play-click-icon').className = 'fa fa-play'; document.getElementById('ptg-play-click').classList.remove('playing'); document.getElementById('ptg-play-status-click').textContent = T.clickToPlay; stopClickVisualization(); } else { if (pulseIsPlaying) { togglePulsatilePlay(); } buildClickGraph(); startClickScheduler(); clickIsPlaying = true; document.getElementById('ptg-play-click-icon').className = 'fa fa-pause'; document.getElementById('ptg-play-click').classList.add('playing'); document.getElementById('ptg-play-status-click').textContent = T.playing; startClickVisualization(); } } // --- TAP TEMPO --- function handleTap() { var now = performance.now(); if (tapTimes.length > 0 && (now - tapTimes[tapTimes.length - 1]) > tapTimeout) { tapTimes = []; } tapTimes.push(now); if (tapTimes.length > tapMaxTaps) tapTimes.shift(); var result = computeTapBPM(); if (result) { var confLabel = result.confidence > 0.8 ? T.confidenceGood : result.confidence > 0.5 ? T.confidenceFair : T.confidenceLow; document.getElementById('ptg-tap-result').innerHTML = T.detected + ' ' + result.bpm + ' ' + T.bpm + ' (' + T.confidence + ' ' + confLabel + ')'; var fill = document.getElementById('ptg-tap-confidence-fill'); fill.style.width = (result.confidence * 100) + '%'; fill.style.background = result.confidence > 0.8 ? 'var(--ptg-success)' : result.confidence > 0.5 ? 'var(--ptg-warning)' : 'var(--ptg-danger)'; var bpmSlider = document.getElementById('ptg-bpm'); var clampedBpm = Math.max(30, Math.min(200, result.bpm)); bpmSlider.value = clampedBpm; updatePulseSliderDisplay(); } else { document.getElementById('ptg-tap-result').textContent = T.keepTapping + ' (' + tapTimes.length + ' ' + T.taps + ')'; } } function computeTapBPM() { if (tapTimes.length < 2) return null; var intervals = []; for (var i = 1; i < tapTimes.length; i++) { var interval = tapTimes[i] - tapTimes[i - 1]; if (interval >= tapMinInterval) intervals.push(interval); } if (intervals.length === 0) return null; intervals.sort(function(a, b) { return a - b; }); var median = intervals[Math.floor(intervals.length / 2)]; var bpm = Math.round(60000 / median); var mean = intervals.reduce(function(a, b) { return a + b; }, 0) / intervals.length; var variance = intervals.reduce(function(sum, val) { return sum + Math.pow(val - mean, 2); }, 0) / intervals.length; var stdDev = Math.sqrt(variance); var confidence = 1 - Math.min(stdDev / mean, 1); return { bpm: bpm, confidence: confidence, tapCount: tapTimes.length }; } // --- VISUALIZATION --- function startPulseVisualization() { var circle = document.getElementById('ptg-pulse-circle'); function animatePulseCircle() { if (!pulseIsPlaying || !audioCtx) return; var p = getPulseParams(); var beatInterval = 60.0 / p.pulseRate; var elapsed = audioCtx.currentTime % beatInterval; var phase = elapsed / beatInterval; var intensity = 0; var dur = p.pulseWidth; var timeInBeat = phase * beatInterval; if (timeInBeat < dur) { var envPhase = timeInBeat / dur; switch (p.pulseShape) { case 'soft-whoosh': if (envPhase < 0.35) intensity = envPhase / 0.35; else if (envPhase < 0.55) intensity = 1; else if (envPhase < 0.9) intensity = 1 - ((envPhase - 0.55) / 0.35); else intensity = 0; break; case 'sharp-pulse': if (envPhase < 0.05) intensity = envPhase / 0.05; else intensity = Math.exp(-(envPhase - 0.05) * 5); break; case 'click': intensity = envPhase < 0.2 ? 1 : 0; break; case 'double-pulse': if (envPhase < 0.25) intensity = envPhase < 0.05 ? envPhase / 0.05 : Math.exp(-(envPhase - 0.05) * 8); else if (envPhase < 0.6) intensity = 0; else intensity = (envPhase < 0.65 ? (envPhase - 0.6) / 0.05 : Math.exp(-(envPhase - 0.65) * 8)) * 0.6; break; } } intensity *= p.modulationDepth; var scale = 0.85 + intensity * 0.3; var opacity = 0.3 + intensity * 0.7; circle.style.transform = 'scale(' + scale + ')'; circle.style.opacity = opacity; circle.style.borderColor = 'rgba(255,119,0,' + (0.3 + intensity * 0.7) + ')'; pulseAnimId = requestAnimationFrame(animatePulseCircle); } pulseAnimId = requestAnimationFrame(animatePulseCircle); var canvas = document.getElementById('ptg-waveform-pulse'); var canvasCtx = canvas.getContext('2d'); function drawPulseWaveform() { if (!pulseIsPlaying || !pulseAnalyser) return; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; var bufLen = pulseAnalyser.frequencyBinCount; var dataArray = new Uint8Array(bufLen); pulseAnalyser.getByteTimeDomainData(dataArray); canvasCtx.fillStyle = '#12121f'; canvasCtx.fillRect(0, 0, canvas.width, canvas.height); canvasCtx.lineWidth = 2; canvasCtx.strokeStyle = '#FF7700'; canvasCtx.beginPath(); var sliceWidth = canvas.width / bufLen; var x = 0; for (var i = 0; i < bufLen; i++) { var v = dataArray[i] / 128.0; var 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(); pulseWaveAnimId = requestAnimationFrame(drawPulseWaveform); } pulseWaveAnimId = requestAnimationFrame(drawPulseWaveform); } function stopPulseVisualization() { if (pulseAnimId) { cancelAnimationFrame(pulseAnimId); pulseAnimId = null; } if (pulseWaveAnimId) { cancelAnimationFrame(pulseWaveAnimId); pulseWaveAnimId = null; } var circle = document.getElementById('ptg-pulse-circle'); circle.style.transform = 'scale(1)'; circle.style.opacity = '1'; circle.style.borderColor = 'var(--ptg-primary)'; var canvas = document.getElementById('ptg-waveform-pulse'); var canvasCtx = canvas.getContext('2d'); canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; canvasCtx.fillStyle = '#12121f'; canvasCtx.fillRect(0, 0, canvas.width, canvas.height); } function startClickVisualization() { var canvas = document.getElementById('ptg-waveform-click'); var canvasCtx = canvas.getContext('2d'); function drawClickWaveform() { if (!clickIsPlaying || !clickAnalyser) return; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; var bufLen = clickAnalyser.frequencyBinCount; var dataArray = new Uint8Array(bufLen); clickAnalyser.getByteTimeDomainData(dataArray); canvasCtx.fillStyle = '#12121f'; canvasCtx.fillRect(0, 0, canvas.width, canvas.height); canvasCtx.lineWidth = 2; canvasCtx.strokeStyle = '#FF7700'; canvasCtx.beginPath(); var sliceWidth = canvas.width / bufLen; var x = 0; for (var i = 0; i < bufLen; i++) { var v = dataArray[i] / 128.0; var 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(); clickWaveAnimId = requestAnimationFrame(drawClickWaveform); } clickWaveAnimId = requestAnimationFrame(drawClickWaveform); } function stopClickVisualization() { if (clickWaveAnimId) { cancelAnimationFrame(clickWaveAnimId); clickWaveAnimId = null; } var canvas = document.getElementById('ptg-waveform-click'); var canvasCtx = canvas.getContext('2d'); canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; canvasCtx.fillStyle = '#12121f'; canvasCtx.fillRect(0, 0, canvas.width, canvas.height); } // --- SLIDER DISPLAY UPDATES --- function updatePulseSliderDisplay() { var freq = sliderToFreq(parseInt(document.getElementById('ptg-freq').value)); document.getElementById('ptg-freq-val').textContent = freq + ' Hz'; var bpm = parseInt(document.getElementById('ptg-bpm').value); document.getElementById('ptg-bpm-val').textContent = bpm + ' BPM'; var hint = bpm < 60 ? T.belowRestingHR : bpm <= 100 ? T.restingHRRange : T.elevatedHR; document.getElementById('ptg-bpm-hint').textContent = hint; document.getElementById('ptg-pw-val').textContent = document.getElementById('ptg-pw').value + ' ms'; document.getElementById('ptg-mod-val').textContent = document.getElementById('ptg-mod').value + '%'; document.getElementById('ptg-filterq-val').textContent = document.getElementById('ptg-filterq').value; document.getElementById('ptg-vol-pulse-val').textContent = document.getElementById('ptg-vol-pulse').value + '%'; var tc = (document.querySelector('input[name="toneChar"]:checked') || {}).value; document.getElementById('ptg-filterq-group').style.display = (tc === 'filtered-noise') ? 'block' : 'none'; if (pulseIsPlaying && pulseMasterGain) { var p = getPulseParams(); pulseMasterGain.gain.setTargetAtTime(p.volume, audioCtx.currentTime, 0.02); pulsePanner.pan.setTargetAtTime(p.pan, audioCtx.currentTime, 0.02); if (pulseFilter) { pulseFilter.frequency.setTargetAtTime(p.baseFrequency, audioCtx.currentTime, 0.02); pulseFilter.Q.setTargetAtTime(p.filterQ, audioCtx.currentTime, 0.02); } if (pulseSource && pulseSource.frequency) { pulseSource.frequency.setTargetAtTime(p.baseFrequency, audioCtx.currentTime, 0.02); } } } function updateClickSliderDisplay() { document.getElementById('ptg-clickrate-val').textContent = document.getElementById('ptg-clickrate').value + ' /sec'; document.getElementById('ptg-regularity-val').textContent = document.getElementById('ptg-regularity').value + '%'; document.getElementById('ptg-brightness-val').textContent = document.getElementById('ptg-brightness').value + ' Hz'; document.getElementById('ptg-vol-click-val').textContent = document.getElementById('ptg-vol-click').value + '%'; if (clickIsPlaying && clickMasterGain) { var cp = getClickParams(); clickMasterGain.gain.setTargetAtTime(cp.volume, audioCtx.currentTime, 0.02); clickPanner.pan.setTargetAtTime(cp.pan, audioCtx.currentTime, 0.02); } } function updateClickModeUI() { var mode = (document.querySelector('input[name="clickMode"]:checked') || {}).value; document.getElementById('ptg-regularity-group').style.display = (mode === 'irregular') ? 'block' : 'none'; } function onToneCharacterChange() { updatePulseSliderDisplay(); if (pulseIsPlaying) { stopPulseScheduler(); destroyPulseGraph(); buildPulseGraph(); startPulseScheduler(); } } // --- PRESETS --- function renderPresetRows() { var pulseRow = document.getElementById('ptg-pulsatile-presets'); var clickRow = document.getElementById('ptg-clicking-presets'); pulseRow.innerHTML = ''; clickRow.innerHTML = ''; PULSATILE_PRESETS.forEach(function(pr) { var card = document.createElement('div'); card.className = 'ptg-preset-card'; card.setAttribute('role', 'button'); card.setAttribute('tabindex', '0'); card.setAttribute('aria-label', T.loadPreset + ' ' + pr.name); card.innerHTML = '
' + pr.name + '
' + pr.description + '
'; card.onclick = function() { loadPulsatilePreset(pr); }; pulseRow.appendChild(card); }); CLICKING_PRESETS.forEach(function(pr) { var card = document.createElement('div'); card.className = 'ptg-preset-card'; card.setAttribute('role', 'button'); card.setAttribute('tabindex', '0'); card.setAttribute('aria-label', T.loadPreset + ' ' + pr.name); card.innerHTML = '
' + pr.name + '
' + pr.description + '
'; card.onclick = function() { loadClickingPreset(pr); }; clickRow.appendChild(card); }); } function loadPulsatilePreset(pr) { var p = pr.params; document.getElementById('ptg-freq').value = freqToSlider(p.baseFrequency); document.getElementById('ptg-bpm').value = p.pulseRate; document.getElementById('ptg-pw').value = p.pulseWidth; document.getElementById('ptg-mod').value = p.modulationDepth; document.getElementById('ptg-filterq').value = p.filterQ; document.getElementById('ptg-vol-pulse').value = p.volume; var shapeRadios = document.querySelectorAll('input[name="pulseShape"]'); for (var i = 0; i < shapeRadios.length; i++) { shapeRadios[i].checked = shapeRadios[i].value === p.pulseShape; } var toneRadios = document.querySelectorAll('input[name="toneChar"]'); for (var i = 0; i < toneRadios.length; i++) { toneRadios[i].checked = toneRadios[i].value === p.toneCharacter; } var panRadios = document.querySelectorAll('input[name="pulsePan"]'); for (var i = 0; i < panRadios.length; i++) { panRadios[i].checked = panRadios[i].value === String(p.pan); } updatePulseSliderDisplay(); if (pulseIsPlaying) { onToneCharacterChange(); } } function loadClickingPreset(pr) { var p = pr.params; document.getElementById('ptg-clickrate').value = p.clickRate; document.getElementById('ptg-regularity').value = p.regularity; document.getElementById('ptg-brightness').value = p.brightness; document.getElementById('ptg-vol-click').value = p.volume; var charRadios = document.querySelectorAll('input[name="clickChar"]'); for (var i = 0; i < charRadios.length; i++) { charRadios[i].checked = charRadios[i].value === p.clickCharacter; } var modeRadios = document.querySelectorAll('input[name="clickMode"]'); for (var i = 0; i < modeRadios.length; i++) { modeRadios[i].checked = modeRadios[i].value === p.clickMode; } var panRadios = document.querySelectorAll('input[name="clickPan"]'); for (var i = 0; i < panRadios.length; i++) { panRadios[i].checked = panRadios[i].value === String(p.pan); } updateClickModeUI(); updateClickSliderDisplay(); } function loadPresetLibrary() { var pulseGrid = document.getElementById('ptg-presets-pulsatile-grid'); var clickGrid = document.getElementById('ptg-presets-clicking-grid'); pulseGrid.innerHTML = ''; clickGrid.innerHTML = ''; PULSATILE_PRESETS.forEach(function(pr) { var col = document.createElement('div'); col.className = 'col-md-4 col-sm-6'; col.innerHTML = '

' + pr.name + '

' + pr.description + '

'; pulseGrid.appendChild(col); }); CLICKING_PRESETS.forEach(function(pr) { var col = document.createElement('div'); col.className = 'col-md-4 col-sm-6'; col.innerHTML = '

' + pr.name + '

' + pr.description + '

'; clickGrid.appendChild(col); }); loadSavedProfiles(); } // --- SAVE/LOAD PROFILES --- function openSaveModal() { document.getElementById('ptg-save-modal').classList.add('active'); document.getElementById('ptg-profile-name').focus(); } function closeSaveModal() { document.getElementById('ptg-save-modal').classList.remove('active'); document.getElementById('ptg-profile-name').value = ''; document.getElementById('ptg-profile-notes').value = ''; } function saveProfile() { var name = document.getElementById('ptg-profile-name').value.trim(); if (!name) { document.getElementById('ptg-profile-name').style.borderColor = 'var(--ptg-danger)'; return; } var notes = document.getElementById('ptg-profile-notes').value.trim(); var profile = { id: 'user-' + Date.now(), name: name, notes: notes, createdAt: new Date().toISOString(), toolTab: currentToolTab }; if (currentToolTab === 'pulsatile') { profile.params = getPulseParams(); profile.params.modulationDepth = profile.params.modulationDepth * 100; profile.params.pulseWidth = profile.params.pulseWidth * 1000; profile.params.volume = profile.params.volume * 100; } else { profile.params = getClickParams(); profile.params.regularity = profile.params.regularity * 100; profile.params.volume = profile.params.volume * 100; } var profiles = JSON.parse(localStorage.getItem('ptg-profiles') || '[]'); profiles.push(profile); localStorage.setItem('ptg-profiles', JSON.stringify(profiles)); closeSaveModal(); loadSavedProfiles(); } function loadSavedProfiles() { var container = document.getElementById('ptg-saved-profiles'); var profiles = JSON.parse(localStorage.getItem('ptg-profiles') || '[]'); if (profiles.length === 0) { container.innerHTML = '

' + T.noSavedProfiles + '

'; return; } container.innerHTML = ''; profiles.forEach(function(prof, idx) { var item = document.createElement('div'); item.className = 'ptg-profile-item'; item.innerHTML = '
' + prof.name + ' (' + prof.toolTab + ')
' + new Date(prof.createdAt).toLocaleDateString() + (prof.notes ? ' - ' + prof.notes : '') + '
'; container.appendChild(item); }); } function loadSavedProfile(idx) { var profiles = JSON.parse(localStorage.getItem('ptg-profiles') || '[]'); var prof = profiles[idx]; if (!prof) return; if (prof.toolTab === 'pulsatile') { switchToolTab('pulsatile'); loadPulsatilePreset(prof); } else { switchToolTab('clicking'); loadClickingPreset(prof); } } function deleteSavedProfile(idx) { var profiles = JSON.parse(localStorage.getItem('ptg-profiles') || '[]'); profiles.splice(idx, 1); localStorage.setItem('ptg-profiles', JSON.stringify(profiles)); loadSavedProfiles(); } function exportJSON() { var data; if (currentToolTab === 'pulsatile') { data = { type: 'pulsatile', params: getPulseParams(), exportedAt: new Date().toISOString(), tool: 'AudioNotch Pulsatile Tinnitus Generator' }; } else { data = { type: 'clicking', params: getClickParams(), exportedAt: new Date().toISOString(), tool: 'AudioNotch Pulsatile Tinnitus Generator' }; } var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'audionotch-sound-profile.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // --- EVENT LISTENERS --- function initEventListeners() { var pulseSliders = ['ptg-freq', 'ptg-bpm', 'ptg-pw', 'ptg-mod', 'ptg-filterq', 'ptg-vol-pulse']; pulseSliders.forEach(function(id) { var el = document.getElementById(id); if (el) el.addEventListener('input', updatePulseSliderDisplay); }); var toneRadios = document.querySelectorAll('input[name="toneChar"]'); for (var i = 0; i < toneRadios.length; i++) { toneRadios[i].addEventListener('change', onToneCharacterChange); } var panRadios = document.querySelectorAll('input[name="pulsePan"]'); for (var i = 0; i < panRadios.length; i++) { panRadios[i].addEventListener('change', updatePulseSliderDisplay); } var clickSliders = ['ptg-clickrate', 'ptg-regularity', 'ptg-brightness', 'ptg-vol-click']; clickSliders.forEach(function(id) { var el = document.getElementById(id); if (el) el.addEventListener('input', updateClickSliderDisplay); }); var clickPanRadios = document.querySelectorAll('input[name="clickPan"]'); for (var i = 0; i < clickPanRadios.length; i++) { clickPanRadios[i].addEventListener('change', updateClickSliderDisplay); } var cards = document.querySelectorAll('.ptg-card-hover'); for (var i = 0; i < cards.length; i++) { cards[i].addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.click(); } }); } document.getElementById('ptg-save-modal').addEventListener('click', function(e) { if (e.target === this) closeSaveModal(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSaveModal(); }); } // --- CLEANUP ON PAGE LEAVE --- window.addEventListener('beforeunload', function() { if (pulseIsPlaying) { stopPulseScheduler(); destroyPulseGraph(); } if (clickIsPlaying) { stopClickScheduler(); destroyClickGraph(); } if (audioCtx) { try { audioCtx.close(); } catch(e){} } }); // --- INIT --- function init() { if (!window.AudioContext && !window.webkitAudioContext) { document.getElementById('ptg-intro').innerHTML = '

' + T.browserNotSupported + '

' + T.browserNotSupportedMsg + '

'; } updatePulseSliderDisplay(); updateClickSliderDisplay(); renderPresetRows(); initEventListeners(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }