1import { RECIPES, isSoundName } from "../sounds/recipes.js"; 2 3const SOURCE_STOP_PADDING = 0.05; 4const CLEANUP_MARGIN = 0.05; 5const INAUDIBLE_GAIN = 0.001; 6 7function renderTone(context, destination, layer, startTime) { 8 const oscillator = context.createOscillator(); 9 oscillator.type = layer.waveform; 10 oscillator.frequency.setValueAtTime(layer.frequency, startTime); 11 if (layer.detune) oscillator.detune.value = layer.detune; 12 if (layer.glideTo !== undefined) { 13 const glideTime = layer.glideTime ?? layer.attack + layer.decay; 14 oscillator.frequency.exponentialRampToValueAtTime( 15 layer.glideTo, 16 startTime + glideTime, 17 ); 18 } 19 const gain = context.createGain(); 20 gain.gain.setValueAtTime(0.0001, startTime); 21 gain.gain.exponentialRampToValueAtTime( 22 layer.peak, 23 startTime + layer.attack, 24 ); 25 gain.gain.exponentialRampToValueAtTime( 26 0.0001, 27 startTime + layer.attack + layer.decay, 28 ); 29 oscillator.connect(gain).connect(destination); 30 oscillator.start(startTime); 31 oscillator.stop(startTime + layer.attack + layer.decay + SOURCE_STOP_PADDING); 32} 33 34function renderNoise(context, destination, layer, startTime) { 35 const duration = layer.attack + layer.decay + SOURCE_STOP_PADDING; 36 const length = Math.max(1, Math.floor(duration * context.sampleRate)); 37 const buffer = context.createBuffer(1, length, context.sampleRate); 38 const data = buffer.getChannelData(0); 39 for (let i = 0; i < length; i++) data[i] = 2 * Math.random() - 1; 40 const source = context.createBufferSource(); 41 source.buffer = buffer; 42 const filter = context.createBiquadFilter(); 43 filter.type = layer.filterType; 44 filter.frequency.value = layer.filterFrequency; 45 if (layer.filterQ !== undefined) filter.Q.value = layer.filterQ; 46 const gain = context.createGain(); 47 gain.gain.setValueAtTime(0.0001, startTime); 48 gain.gain.exponentialRampToValueAtTime( 49 layer.peak, 50 startTime + layer.attack, 51 ); 52 gain.gain.exponentialRampToValueAtTime( 53 0.0001, 54 startTime + layer.attack + layer.decay, 55 ); 56 source.connect(filter).connect(gain).connect(destination); 57 source.start(startTime); 58 source.stop(startTime + duration); 59} 60 61function attachShimmer(context, source, destination, shimmer) { 62 const delay = context.createDelay(1); 63 delay.delayTime.value = shimmer.delay; 64 const feedbackFilter = context.createBiquadFilter(); 65 feedbackFilter.type = "lowpass"; 66 feedbackFilter.frequency.value = shimmer.lowpass; 67 const feedbackGain = context.createGain(); 68 feedbackGain.gain.value = shimmer.feedback; 69 const wetGain = context.createGain(); 70 wetGain.gain.value = shimmer.wet; 71 source.connect(delay); 72 delay.connect(feedbackFilter); 73 feedbackFilter.connect(feedbackGain); 74 feedbackGain.connect(delay); 75 feedbackFilter.connect(wetGain); 76 wetGain.connect(destination); 77 return [delay, feedbackFilter, feedbackGain, wetGain]; 78} 79 80function sourceEnd(recipe) { 81 return Math.max( 82 ...recipe.layers.map( 83 (layer) => 84 (layer.offset ?? 0) + layer.attack + layer.decay + SOURCE_STOP_PADDING, 85 ), 86 ); 87} 88 89function shimmerTail(shimmer) { 90 if (!shimmer || shimmer.feedback <= 0) return 0; 91 if (shimmer.feedback >= 1) return shimmer.delay; 92 return ( 93 shimmer.delay * 94 (1 + Math.ceil(Math.log(INAUDIBLE_GAIN) / Math.log(shimmer.feedback))) 95 ); 96} 97 98function renderRecipe(context, recipe) { 99 const now = context.currentTime; 100 const master = context.createGain(); 101 master.gain.value = recipe.masterGain; 102 master.connect(context.destination); 103 const shimmerNodes = recipe.shimmer 104 ? attachShimmer(context, master, context.destination, recipe.shimmer) 105 : []; 106 for (const layer of recipe.layers) { 107 const startTime = now + (layer.offset ?? 0); 108 if (layer.kind === "tone") renderTone(context, master, layer, startTime); 109 else renderNoise(context, master, layer, startTime); 110 } 111 const cleanupAfterMs = 112 (sourceEnd(recipe) + shimmerTail(recipe.shimmer) + CLEANUP_MARGIN) * 1000; 113 setTimeout(() => { 114 master.disconnect(); 115 for (const node of shimmerNodes) node.disconnect(); 116 }, cleanupAfterMs); 117} 118 119let sharedContext = null; 120let enabled = true; 121 122export function setEnabled(value) { 123 if (typeof value === "boolean") enabled = value; 124} 125 126function getAudioContext() { 127 if (sharedContext) return sharedContext; 128 if (typeof window === "undefined") return null; 129 const Ctor = window.AudioContext ?? window.webkitAudioContext; 130 if (!Ctor) return null; 131 try { 132 sharedContext = new Ctor(); 133 } catch { 134 return null; 135 } 136 return sharedContext; 137} 138 139export function play(sound) { 140 if (!enabled || !isSoundName(sound)) return; 141 const context = getAudioContext(); 142 if (!context) return; 143 const recipe = RECIPES[sound]; 144 if (context.state === "running") { 145 renderRecipe(context, recipe); 146 } else { 147 try { 148 void context.resume().then( 149 () => { 150 if (enabled && context.state === "running") 151 renderRecipe(context, recipe); 152 }, 153 () => {}, 154 ); 155 } catch {} 156 } 157}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.