@@ -46,7 +46,7 @@
let pingSynth : Tone.Synth | null = null ;
let bassSynth : Tone.Synth | null = null ;
let arpSequence : Tone.Sequence | null = null ;
let pingSequence : Tone.Sequence | null = null ;
let pingSequence : Tone.Loop | null = null ;
let bassSequence : Tone.Sequence | null = null ;
let reverb : Tone.Reverb | null = null ;
let delay : Tone.FeedbackDelay | null = null ;
@@ -54,6 +54,11 @@
let sequence : Tone.Sequence | null = null ;
let gain : Tone.Gain | null = null ;
let analyser : Tone.Analyser | null = null ;
let driftLoop : Tone.Loop | null = null ;
let dropoutLoop : Tone.Loop | null = null ;
let arpActive = true ;
let pingActive = true ;
let bassActive = true ;
// Select chord progression based on weather mood
const currentProgression : ChordProgression = $derived . by (() => {
@@ -142,6 +147,27 @@
return 1 - comfortScore ; // Invert: higher = more extreme
});
// Pad volume: louder/present in clear sky, quieter/receded in overcast
const padVolume = $derived . by (() => {
const clouds = cloudCover ?? 50 ;
return - 8 - ( Math . min ( 100 , Math . max ( 0 , clouds )) / 100 ) * 14 ;
});
// Pad spread: tight detune in clear sky, wide/diffuse in overcast
const padSpread = $derived . by (() => {
const clouds = cloudCover ?? 50 ;
return 10 + ( Math . min ( 100 , Math . max ( 0 , clouds )) / 100 ) * 50 ;
});
// Arp pattern shape: changes based on weather conditions
const arpPatternType = $derived . by (() => {
if ( precipitation > 5 || weatherExtremity > 0.8 ) return 'random' ;
if ( temperature2m < 5 ) return 'sparse' ;
if ( relativeHumidity2m > 80 && cloudCover > 60 ) return 'probabilistic' ;
if ( temperature2m > 25 ) return 'descending' ;
return 'ascending' ;
});
// Ping volume: quieter in pleasant weather, louder in extreme weather
const pingVolume = $derived . by (() => {
// Map extremity: 0 (pleasant) = -22dB (present), 1 (extreme) = -8dB (prominent)
@@ -239,6 +265,9 @@
sequence = null ;
}
// Shuffle chord order — different harmonic arrangement each playback
const shuffledProgression = [... currentProgression ]. sort (() => Math . random () - 0.5 );
// Set all audio parameters once (static until page refresh)
Tone . getTransport (). bpm . value = bpm ;
@@ -268,13 +297,18 @@
pingSynth . volume . value = pingVolume ;
}
if ( synth ) {
synth . volume . value = padVolume ;
synth . set ({ oscillator : { spread : padSpread } });
}
sequence = new Tone . Sequence (
( time : number , chord ) => {
if ( synth && chord ) {
synth ! . triggerAttackRelease ( chord . notes , '4n' , time );
}
},
current Progression,
shuffled Progression,
'4n'
);
@@ -286,56 +320,103 @@
arpSequence = null ;
}
// Arp burst/silence — phrase-level gaps independent of BPM
const arpT = temperature2m ?? 20 ;
const arpBurstMax = arpT <= 10 ? 6 : arpT <= 20 ? 8 : 12 ; // seconds
const arpSilenceMax = arpT <= 10 ? 5 : arpT <= 20 ? 4 : 3 ; // seconds
let inArpBurst = true ;
let arpStateUntil = Tone . now () + Math . random () * arpBurstMax + 2 ;
arpSequence = new Tone . Sequence (
( time : number , chord ) => {
const now = Tone . now ();
if ( now >= arpStateUntil ) {
inArpBurst = ! inArpBurst ;
arpStateUntil = now + ( inArpBurst
? Math . random () * arpBurstMax + 2
: Math.random () * arpSilenceMax + 1 );
}
if ( ! inArpBurst ) return ;
if ( arpSynth && chord && chord . notes ) {
// Capture synth reference for TypeScript
const synth = arpSynth ;
// Calculate time between notes based on 16th notes
const sixteenthNote = Tone . Time ( '16n' ). toSeconds ();
// Play arpeggio pattern through the chord notes
chord . notes . forEach (( note : string , index : number ) => {
let notes = [... chord . notes ];
if ( arpPatternType === 'sparse' ) {
// Root (index 0) and fifth (index 2) only — hollow, minimal
notes = [ chord . notes [ 0 ], chord . notes [ 2 ]]. filter ( Boolean );
} else if ( arpPatternType === 'descending' ) {
notes = [... chord . notes ]. reverse ();
} else if ( arpPatternType === 'random' ) {
// Fisher-Yates shuffle — chaotic, unsettled
for ( let i = notes . length - 1 ; i > 0 ; i -- ) {
const j = Math . floor ( Math . random () * ( i + 1 ));
[ notes [ i ], notes [ j ]] = [ notes [ j ], notes [ i ]];
}
}
notes . forEach (( note : string , index : number ) => {
if ( arpPatternType === 'probabilistic' && Math . random () > 0.5 ) return ;
synth . triggerAttackRelease ( note , '16n' , time + index * sixteenthNote );
});
}
},
current Progression,
shuffled Progression,
arpInterval
);
arpSequence . start ( 0 );
// Create ping sequence (reverse arpeggio - evenly spaced through chord duration)
// Create ping — free melodic random walk over progression's scale tones
if ( pingSequence ) {
pingSequence . dispose ();
pingSequence = null ;
}
// Build a flat array of notes: 4 notes per chord, in reverse order, transposed up 2 octaves
const pingNotes : string [] = [];
// Build sorted note pool from all unique pitch classes in the progression
const notePool : string [] = [];
const seenNames = new Set < string >();
currentProgression . forEach (( chord ) => {
if ( chord && chord . notes ) {
// Get 4 notes in reverse order (last to first, cycling if needed)
const reversedNotes = [... chord . notes ]. reverse ();
for ( let i = 0 ; i < 4 ; i ++ ) {
const note = reversedNotes [ i % reversedNotes . length ];
if ( chord ? . notes ) {
chord . notes . forEach (( note : string ) => {
const noteName = note . slice ( 0 , - 1 );
const octave = parseInt ( note . slice ( - 1 ));
const highNote = noteName + ( octave + 2 );
pingNot es. push ( highNot e);
}
if ( ! seenNames . has ( noteName )) {
seenNam es . add ( noteNam e);
if ( octave + 1 <= 6 ) notePool . push ( noteName + ( octave + 1 ));
if ( octave + 2 <= 6 ) notePool . push ( noteName + ( octave + 2 ));
}
});
}
});
// Captured once at sequence start — same pattern as all other params
// Burst/silence state tracked via Tone.now() inside the callback itself —
// completely independent of pingInterval and BPM.
const temp = temperature2m ?? 20 ;
const burstMax = temp <= 10 ? 5 : temp <= 20 ? 7 : 10 ; // seconds
const silenceMax = temp <= 10 ? 4 : temp <= 20 ? 3 : 2 ; // seconds — shorter for more presence
pingSequence = new Tone . Sequence (
( time : number , note : string ) => {
if ( pingSynth && note ) {
pingSynth . triggerAttackRelease ( note , '32n' , time );
}
},
pingNotes ,
pingInterval // Temperature-reactive: 1n (cold) to 16n (hot)
) ;
let inBurst = Math . random () < 0.5 ;
let stateUntil = Tone . now ( ) + ( inBurst
? Math . random () * burstMax + 1
: Math.random () * silenceMax + 1 );
pingSequence = new Tone . Loop (( time : number ) => {
const now = Tone . now ();
if ( now >= stateUntil ) {
inBurst = ! inBurst ;
stateUntil = now + ( inBurst
? Math . random () * burstMax + 1
: Math.random () * silenceMax + 1 );
}
if ( ! inBurst ) return ;
if ( pingSynth && notePool . length > 0 ) {
const note = notePool [ Math . floor ( Math . random () * notePool . length )];
pingSynth . volume . value = pingVolume + ( Math . random () * 12 - 6 );
pingSynth . triggerAttackRelease ( note , '32n' , time );
}
}, pingInterval );
pingSequence . start ( 0 );
@@ -347,27 +428,58 @@
bassSequence = new Tone . Sequence (
( time : number , chord ) => {
if ( bassSynth && chord && chord . notes && chord . notes . length > 0 ) {
// Get root note (first note of chord) and transpose down 2 octaves
const rootNote = chord . notes [ 0 ];
const noteName = rootNote . slice ( 0 , - 1 ); // e.g., 'C' from 'C4'
const octave = parseInt ( rootNote . slice ( - 1 )); // e.g., 4 from 'C4'
const bassNote = noteName + ( octave - 2 ); // e.g., 'C2'
if ( ! bassSynth || ! chord ? . notes ? . length ) return ;
// Randomize note duration: half to full chord duration (2n to 4n)
const randomDuration = 0.5 + Math . random () * 0.5 ;
const noteDuration = Tone . Time ( '4n' ). toSeconds () * randomDuration ;
// 20% chance to skip — rhythmic breathing without harmonic risk
if ( Math . random () < 0.2 ) return ;
// Trigger bass note at the scheduled time
bassSynth . triggerAttackRelease ( bassNote , noteDuration , time );
}
const noteName = chord . notes [ 0 ]. slice ( 0 , - 1 );
const octave = parseInt ( chord . notes [ 0 ]. slice ( - 1 ) );
const bassNote = noteName + ( octave - 2 );
// Random duration: 50– 100% of a quarter note
const noteDuration = Tone . Time ( '4n' ). toSeconds () * ( 0.5 + Math . random () * 0.5 );
bassSynth . triggerAttackRelease ( bassNote , noteDuration , time );
},
current Progression,
shuffled Progression,
'4n'
);
bassSequence . start ( 0 );
// Slow parameter drift — filter and reverb breathe around weather baselines
if ( driftLoop ) { driftLoop . dispose (); driftLoop = null ; }
driftLoop = new Tone . Loop (() => {
const filterRange = filterCutoff * 0.4 ;
const newFreq = filterCutoff + ( Math . random () * 2 - 1 ) * filterRange ;
filter ? . frequency . rampTo ( Math . max ( 200 , Math . min ( 12000 , newFreq )), 20 );
const newWet = reverbWet + ( Math . random () * 2 - 1 ) * 0.2 ;
reverb ? . wet . rampTo ( Math . max ( 0.1 , Math . min ( 1.0 , newWet )), 25 );
}, 20 ); // every 20 seconds — independent of BPM
driftLoop . start ( '+10' ); // first fire 10 seconds after playback starts
// Arp dropout — asymmetric: low chance to go silent, high chance to recover
// Ping has its own burst/silence mechanism — excluded here to avoid conflict
// Bass stays as harmonic foundation
arpActive = true ;
if ( dropoutLoop ) { dropoutLoop . dispose (); dropoutLoop = null ; }
dropoutLoop = new Tone . Loop (() => {
if ( arpActive ) {
// Only 15% chance to go silent — arp stays on most of the time
if ( Math . random () < 0.15 ) {
arpActive = false ;
arpSynth ? . volume . rampTo ( - 80 , 2 );
}
} else {
// 75% chance to come back — silences are short
if ( Math . random () < 0.75 ) {
arpActive = true ;
arpSynth ? . volume . rampTo ( arpVolume , 1.5 );
}
}
}, 25 );
dropoutLoop . start ( '+30' ); // wait 30s before first dropout decision
Tone . getTransport (). start ();
// Request wake lock to prevent screen sleep
@@ -401,6 +513,9 @@
bassSequence . dispose ();
bassSequence = null ;
}
if ( driftLoop ) { driftLoop . stop (); driftLoop . dispose (); driftLoop = null ; }
if ( dropoutLoop ) { dropoutLoop . stop (); dropoutLoop . dispose (); dropoutLoop = null ; }
arpActive = true ;
Tone . getTransport (). stop ();
Tone . getTransport (). cancel ();
isPlaying = false ;
@@ -492,6 +607,8 @@
if ( analyser ) {
analyser . dispose ();
}
if ( driftLoop ) { driftLoop . dispose (); }
if ( dropoutLoop ) { dropoutLoop . dispose (); }
});
</ script >