diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-26 11:47:55 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-26 11:47:55 +0300 |
| commit | e9bddae89614f12ce7d2d26e70c2c2de3b90590e (patch) | |
| tree | f74bccce3e0c7a4240a680deee82312acd286820 /internal/generator/templates/shared/shared.js | |
| parent | 44f82e79f0b1a65d9df62c3fed7271affcb0a673 (diff) | |
ambient: polyphonic songs, fix cascaded gain and resume bugs
Rewrite all 19 theme melodies into proper ~10-16 second looping songs
with bass drones, chord pads, and scalar melody lines using the new
'step' field for polyphonic overlap on a monophonic step sequencer.
Fix four bugs that caused total silence:
1. masterGain was faded to preset.gain while each note was ALSO scaled
by preset.gain*0.6, squaring volume to ~-68 dB.
2. fadeMasterTo ramped the gate for 0.5-1.5s, swallowing first notes.
3. startDrones set per-osc gain to 1.0/numDrones, clipping and burying
the melody.
4. AudioContext resume was not awaited; notes fired into a suspended
context and were lost.
Shared.js:
- startEngine() waits for ctx.resume() before scheduling anything.
- masterGain acts as a binary gate (fades to 1.0 over 50ms).
- playMelodyNote uses twin detuned oscillators for richer timbre.
- startDrones scales by preset.gain so drones match melody level.
Theme_sounds.go:
- Add 'step' field to melodyNote struct.
- Add chord-building helpers (major/minor triads).
- Add buildSong() / buildMeasure() for structured 4-bar loops.
- All 19 themes: audible bass (130-220Hz), chord pads (260-520Hz),
melodies (330-1046Hz), real chord progressions, proper BPM.
Diffstat (limited to 'internal/generator/templates/shared/shared.js')
| -rw-r--r-- | internal/generator/templates/shared/shared.js | 105 |
1 files changed, 83 insertions, 22 deletions
diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js index 2f4c260..7f6192a 100644 --- a/internal/generator/templates/shared/shared.js +++ b/internal/generator/templates/shared/shared.js @@ -438,7 +438,11 @@ if (freqs.length === 0) return; var wt = snonuxWaveType(preset.wave); var detune = preset.detuneCents || 0; - var perOscGain = 1.0 / Math.max(1, freqs.length); + // Drones must respect the theme's ambient gain so they don't + // drown the melody. Four drones at 0.25 each = 1.0 total, + // which clips and buries everything else. + var gBase = preset.gain != null ? preset.gain : 0.08; + var perOscGain = gBase / Math.max(1, freqs.length); freqs.forEach(function(freq) { var osc = c.createOscillator(); var g = c.createGain(); @@ -471,6 +475,7 @@ src.buffer = buffer; src.loop = true; noiseGainNode = c.createGain(); + // Keep noise subtle so it doesn't bury the melody. noiseGainNode.gain.value = preset.noiseGain; src.connect(noiseGainNode); noiseGainNode.connect(masterGain); @@ -489,15 +494,30 @@ var dur = note.dur || 0.3; if (freq <= 0 || dur <= 0) return; var wt = snonuxWaveType(preset.wave); + // masterGain is now a binary "gate" (fades to 1 when on), so per-note + // volume carries the real preset.gain. Without the old masterGain + // squaring, this makes music audible at normal levels. var g = note.gain != null ? note.gain : (preset.gain != null ? preset.gain : 0.08); - var pulseGain = Math.min(g * 0.6, 0.12); + var pulseGain = Math.min(g, 0.08); - var osc = c.createOscillator(); + // Build a richer timbre with two slightly detuned oscillators so + // melody lines sound like actual notes instead of sterile blips. + var osc1 = c.createOscillator(); + var osc2 = c.createOscillator(); + var mix = c.createGain(); var gain = c.createGain(); - osc.type = wt; - osc.frequency.value = freq; - var lastNode = osc; + osc1.type = wt; + osc1.frequency.value = freq; + osc2.type = wt; + osc2.frequency.value = freq; + osc2.detune.value = 5 + Math.random() * 3; // subtle chorus + + mix.gain.value = 0.5; + osc1.connect(mix); + osc2.connect(mix); + + var lastNode = mix; if (preset.filterFreq) { var filter = c.createBiquadFilter(); filter.type = 'lowpass'; @@ -513,11 +533,22 @@ lastNode.connect(gain); gain.connect(masterGain); var now = c.currentTime; + + // Proper ADSR-ish envelope: attack up, sustain, then release. + var attack = Math.min(0.04, dur * 0.15); + var release = Math.min(0.12, dur * 0.35); + var sustainTime = Math.max(0, dur - attack - release); + gain.gain.setValueAtTime(0, now); - gain.gain.linearRampToValueAtTime(pulseGain, now + Math.min(0.05, dur * 0.2)); + gain.gain.linearRampToValueAtTime(pulseGain, now + attack); + if (sustainTime > 0) { + gain.gain.setValueAtTime(pulseGain, now + attack + sustainTime); + } gain.gain.exponentialRampToValueAtTime(0.001, now + dur); - osc.start(now); - osc.stop(now + dur + 0.02); + osc1.start(now); + osc1.stop(now + dur + 0.02); + osc2.start(now); + osc2.stop(now + dur + 0.02); } function scheduleMelodyNote(preset) { @@ -525,13 +556,21 @@ var note = preset.melody[melodyIndex]; playMelodyNote(note, preset); melodyIndex = (melodyIndex + 1) % preset.melody.length; - var gap = preset.pulseInterval || (preset.bpm ? 60.0 / preset.bpm : 0.5); - var isEndOfPhrase = melodyIndex === 0; - var delay = note.dur * 1000 * 0.7 + (isEndOfPhrase ? gap * 1000 * 1.5 : gap * 1000 * 0.2); + // Timing to the next note: explicit 'step' on the current note + // controls exactly when the sequencer advances. If step is + // omitted the scheduler falls back to dur + a tiny BPM-scaled + // gap so the line stays intelligible. + var step; + if (note.step != null && note.step > 0) { + step = note.step; + } else { + var beat = preset.bpm ? 60.0 / preset.bpm : 0.5; + step = note.dur + beat * 0.15; + } melodyTimer = setTimeout(function() { if (!isPlaying) return; scheduleMelodyNote(currentPreset); - }, delay); + }, step * 1000); } function schedulePulse() { @@ -615,16 +654,38 @@ if (!preset) return; currentPreset = preset; ensureCtx(); - isPlaying = true; - melodyIndex = 0; - startDrones(preset); - startNoise(preset); - schedulePulse(); + // Begin scheduling only once the AudioContext is running. + // Modern browsers start AudioContext in 'suspended' state; firing + // note events before it is running schedules them into the void. + function begin() { + if (!isPlaying) return; // user toggled off while awaiting resume + // Audio must actually be running or all scheduled notes are lost + if (ctx.state !== 'running') { + isPlaying = false; + return; + } + melodyIndex = 0; + startDrones(preset); + startNoise(preset); + schedulePulse(); + // masterGain is a binary gate: open it quickly (50 ms) so the + // first scheduled notes are not swallowed by a long ramp. + // preset.attack controls per-note envelopes, not the gate. + fadeMasterTo(1.0, 0.05); + } - var targetGain = preset.gain != null ? preset.gain : 0.08; - var fadeIn = preset.attack != null ? preset.attack : 0.5; - fadeMasterTo(targetGain, fadeIn); + if (ctx.state === 'suspended') { + isPlaying = true; // flag the intent so begin() can run later + ctx.resume().then(begin).catch(function() { + // Resume failed (no autoplay permission). Reset state so + // the next keypress triggers a fresh start attempt. + isPlaying = false; + }); + } else { + isPlaying = true; + begin(); + } } function pauseEngine() { @@ -668,7 +729,7 @@ startDrones(newPreset); startNoise(newPreset); schedulePulse(); - var targetGain = newPreset.gain != null ? newPreset.gain : 0.08; + var targetGain = 1.0; fadeMasterTo(targetGain, 0.5); }, 350); } |
