added generative audio, tailwind, improvements, adjustments

This commit is contained in:
2025-12-29 03:35:33 +02:00
parent 5259a1d1e4
commit 6a7101672c
19 changed files with 1597 additions and 250 deletions

View File

@@ -0,0 +1,35 @@
import * as Tone from 'tone';
export function createReverb(wetValue: number): Tone.Reverb {
const reverb = new Tone.Reverb({
decay: 16,
preDelay: 0.5
});
reverb.wet.value = wetValue;
return reverb;
}
export function createDelay(delayTime: string, feedback: number): Tone.FeedbackDelay {
const delay = new Tone.FeedbackDelay({
delayTime: delayTime,
feedback: feedback
});
delay.wet.value = 0.5;
return delay;
}
export function createFilter(frequency: number, resonance: number): Tone.Filter {
return new Tone.Filter({
type: 'lowpass',
frequency: frequency,
Q: resonance
});
}
export function createGain(volume: number): Tone.Gain {
return new Tone.Gain(Tone.dbToGain(volume));
}
export function createAnalyser(): Tone.Analyser {
return new Tone.Analyser('fft', 512);
}

View File

@@ -0,0 +1,61 @@
export type ChordProgression = Array<{
time: string;
notes: string[];
}>;
// Bright, uplifting - for pleasant daytime conditions
export const brightProgression: ChordProgression = [
{ time: '0:0:0', notes: ['C4', 'E4', 'G4', 'B4'] }, // Cmaj7
{ time: '0:1:0', notes: ['F4', 'A4', 'C5', 'E5'] }, // Fmaj7
{ time: '0:2:0', notes: ['G4', 'B4', 'D5', 'F5'] }, // G7
{ time: '0:3:0', notes: ['A3', 'C4', 'E4', 'G4'] } // Am7
];
// Dreamy, calm - for pleasant nighttime conditions
export const dreamyProgression: ChordProgression = [
{ time: '0:0:0', notes: ['D4', 'F#4', 'A4', 'C5'] }, // Dmaj7
{ time: '0:1:0', notes: ['G3', 'B3', 'D4', 'F#4'] }, // Gmaj7
{ time: '0:2:0', notes: ['E4', 'G4', 'B4', 'D5'] }, // Em7
{ time: '0:3:0', notes: ['A3', 'C#4', 'E4', 'G4'] } // A7
];
// Melancholic, introspective - for cold/rainy conditions
export const melancholicProgression: ChordProgression = [
{ time: '0:0:0', notes: ['A3', 'C4', 'E4', 'G4'] }, // Am7
{ time: '0:1:0', notes: ['D3', 'F3', 'A3', 'C4'] }, // Dm7
{ time: '0:2:0', notes: ['G3', 'Bb3', 'D4', 'F4'] }, // Gm7
{ time: '0:3:0', notes: ['C3', 'E3', 'G3', 'Bb3'] } // C7
];
// Tense, atmospheric - for stormy/extreme conditions
export const tenseProgression: ChordProgression = [
{ time: '0:0:0', notes: ['E3', 'G3', 'Bb3', 'D4'] }, // Em7b5
{ time: '0:1:0', notes: ['F3', 'Ab3', 'C4', 'Eb4'] }, // Fm7
{ time: '0:2:0', notes: ['Bb3', 'Db4', 'F4', 'Ab4'] }, // Bbm7
{ time: '0:3:0', notes: ['Eb3', 'Gb3', 'Bb3', 'Db4'] } // Ebm7
];
// Warm, intense - for hot conditions
export const warmProgression: ChordProgression = [
{ time: '0:0:0', notes: ['E4', 'G#4', 'B4', 'D5'] }, // E7
{ time: '0:1:0', notes: ['A3', 'C#4', 'E4', 'G4'] }, // A7
{ time: '0:2:0', notes: ['D4', 'F#4', 'A4', 'C5'] }, // D7
{ time: '0:3:0', notes: ['G3', 'B3', 'D4', 'F4'] } // G7
];
// Ethereal, floating - for foggy/misty conditions
export const etherealProgression: ChordProgression = [
{ time: '0:0:0', notes: ['F4', 'A4', 'C5', 'E5'] }, // Fmaj7
{ time: '0:1:0', notes: ['C4', 'E4', 'G4', 'B4'] }, // Cmaj7
{ time: '0:2:0', notes: ['G4', 'B4', 'D5', 'F#5'] }, // Gmaj7
{ time: '0:3:0', notes: ['D4', 'F#4', 'A4', 'C#5'] } // Dmaj7
];
export const allProgressions = [
brightProgression,
dreamyProgression,
melancholicProgression,
tenseProgression,
warmProgression,
etherealProgression
];

View File

@@ -0,0 +1,16 @@
import * as Tone from 'tone';
export function createArpSynth(volume: number): Tone.Synth {
return new Tone.Synth({
oscillator: {
type: 'triangle'
},
envelope: {
attack: 0.005,
decay: 0.2,
sustain: 0,
release: 0.3
},
volume: volume
});
}

View File

@@ -0,0 +1,16 @@
import * as Tone from 'tone';
export function createBassSynth(): Tone.Synth {
return new Tone.Synth({
oscillator: {
type: 'sine'
},
envelope: {
attack: 0.1,
decay: 0.3,
sustain: 0.8,
release: 2.0
},
volume: -20
});
}

View File

@@ -0,0 +1,16 @@
import * as Tone from 'tone';
export function createNoiseSynth(volume: number): Tone.NoiseSynth {
return new Tone.NoiseSynth({
noise: {
type: 'pink'
},
envelope: {
attack: 0.005,
decay: 0.1,
sustain: 0,
release: 0.1
},
volume: volume
});
}

View File

@@ -0,0 +1,16 @@
import * as Tone from 'tone';
export function createPadSynth(isDay: boolean): Tone.PolySynth {
return new Tone.PolySynth(Tone.Synth, {
oscillator: {
type: isDay ? 'triangle' : 'sine'
},
envelope: {
attack: 1.5,
decay: 1,
sustain: 0.7,
release: 1.0
},
volume: -20
});
}

View File

@@ -0,0 +1,21 @@
import * as Tone from 'tone';
/**
* Creates a sharp, high-pitched ping synth for extreme weather indicators
* @param volume - Volume in dB
* @returns A configured Synth instance
*/
export function createPingSynth(volume: number): Tone.Synth {
return new Tone.Synth({
oscillator: {
type: 'triangle'
},
envelope: {
attack: 0.001, // Very sharp attack
decay: 0.05,
sustain: 0,
release: 0.1
},
volume: volume
});
}

View File

@@ -0,0 +1,97 @@
import {
brightProgression,
dreamyProgression,
melancholicProgression,
tenseProgression,
warmProgression,
etherealProgression,
type ChordProgression
} from './chord-progressions';
interface WeatherConditions {
temperature2m: number;
relativeHumidity2m: number;
cloudCover: number;
windSpeed10m: number;
precipitation: number;
isDay: boolean;
}
/**
* Calculate a weather mood score based on various conditions
* Returns values between 0 (harsh/extreme) and 1 (pleasant)
*/
export function calculateComfortScore(conditions: WeatherConditions): number {
const { temperature2m, relativeHumidity2m, cloudCover, windSpeed10m, precipitation } =
conditions;
// Temperature comfort: ideal 15-25°C, drops off outside this range
let tempScore = 1.0;
if (temperature2m < 15) {
tempScore = Math.max(0, 1 - Math.abs(15 - temperature2m) / 30);
} else if (temperature2m > 25) {
tempScore = Math.max(0, 1 - Math.abs(temperature2m - 25) / 20);
}
// Humidity comfort: ideal 40-60%, drops off outside
let humidityScore = 1.0;
if (relativeHumidity2m < 40) {
humidityScore = Math.max(0, relativeHumidity2m / 40);
} else if (relativeHumidity2m > 60) {
humidityScore = Math.max(0, 1 - (relativeHumidity2m - 60) / 40);
}
// Cloud cover: some clouds (30-70%) is pleasant, extremes less so
const cloudScore = 1 - Math.abs(cloudCover - 50) / 50;
// Wind: light breeze (0-15 km/h) is nice, strong wind less so
const windScore = Math.max(0, 1 - windSpeed10m / 40);
// Precipitation: any is somewhat unpleasant
const precipScore = Math.max(0, 1 - precipitation / 10);
// Weighted average
return (
tempScore * 0.35 +
humidityScore * 0.2 +
cloudScore * 0.15 +
windScore * 0.15 +
precipScore * 0.15
);
}
/**
* Select the appropriate chord progression based on weather conditions
*/
export function selectChordProgression(conditions: WeatherConditions): ChordProgression {
const comfortScore = calculateComfortScore(conditions);
const { temperature2m, precipitation, cloudCover, isDay } = conditions;
// Stormy/extreme conditions (heavy rain, very harsh)
if (precipitation > 5 || comfortScore < 0.2) {
return tenseProgression;
}
// Very hot conditions
if (temperature2m > 30) {
return warmProgression;
}
// Cold/rainy/gloomy conditions
if (temperature2m < 5 || (precipitation > 1 && cloudCover > 70)) {
return melancholicProgression;
}
// Foggy/misty conditions (high humidity + clouds, low wind)
if (conditions.relativeHumidity2m > 80 && cloudCover > 60 && temperature2m > 5) {
return etherealProgression;
}
// Pleasant conditions - choose based on day/night
if (comfortScore > 0.6) {
return isDay ? brightProgression : dreamyProgression;
}
// Default: slightly unpleasant but not extreme
return isDay ? dreamyProgression : melancholicProgression;
}

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import P5 from 'p5-svelte';
import type p5 from 'p5';
import type * as Tone from 'tone';
let { isPlaying = false, width = 400, height = 400, analyser = null } = $props<{
isPlaying: boolean;
width?: number;
height?: number;
analyser: Tone.Analyser | null;
}>();
let particles: Particle[] = [];
const numParticles = 60;
let audioData: Float32Array | null = null;
class Particle {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
alpha: number;
index: number;
constructor(p: p5, index: number) {
this.index = index;
this.x = p.random(p.width);
this.y = p.random(p.height);
this.baseX = this.x;
this.baseY = this.y;
this.vx = p.random(-0.5, 0.5);
this.vy = p.random(-0.5, 0.5);
this.size = p.random(2, 6);
this.alpha = p.random(100, 255);
}
update(p: p5, audioLevel: number, bass: number, mid: number) {
// Base movement - gentle constant speed
this.x += this.vx;
this.y += this.vy;
// Subtle audio reactive displacement
const displacement = audioLevel * 20;
const angle = p.noise(this.x * 0.01, this.y * 0.01, p.frameCount * 0.01) * p.TWO_PI;
this.x += p.cos(angle) * displacement * 0.05;
this.y += p.sin(angle) * displacement * 0.05;
// Subtle audio reactive size
this.size = p.map(bass + mid, 0, 2, 3, 10);
// Wrap around edges
if (this.x < -50) this.x = p.width + 50;
if (this.x > p.width + 50) this.x = -50;
if (this.y < -50) this.y = p.height + 50;
if (this.y > p.height + 50) this.y = -50;
}
display(p: p5, audioLevel: number) {
p.noStroke();
const dynamicAlpha = p.map(audioLevel, 0, 1, 120, 220);
p.fill(255, dynamicAlpha);
p.ellipse(this.x, this.y, this.size, this.size);
}
connect(p: p5, other: Particle, maxDist: number) {
const d = p.dist(this.x, this.y, other.x, other.y);
if (d < maxDist) {
const alpha = p.map(d, 0, maxDist, 100, 0);
p.stroke(255, alpha);
p.strokeWeight(1);
p.line(this.x, this.y, other.x, other.y);
}
}
}
const sketch = (p: p5) => {
p.setup = () => {
p.createCanvas(width, height);
p.background(0);
// Initialize particles
particles = [];
for (let i = 0; i < numParticles; i++) {
particles.push(new Particle(p, i));
}
};
p.draw = () => {
p.background(0, 30); // Fade effect
if (isPlaying && analyser) {
// Get FFT data (frequency analysis)
audioData = analyser.getValue() as Float32Array;
// Calculate audio metrics from FFT data
// FFT values are in decibels (negative values, typically -100 to 0)
let sum = 0;
let bass = 0;
let mid = 0;
let treble = 0;
const bassRange = Math.floor(audioData.length * 0.15); // Low frequencies
const midRange = Math.floor(audioData.length * 0.4); // Mid frequencies
for (let i = 0; i < audioData.length; i++) {
// Convert from decibels to linear scale (0-1)
// FFT returns values from -100 to 0 dB
const normalized = p.map(audioData[i], -100, -30, 0, 1, true);
sum += normalized;
if (i < bassRange) {
bass += normalized;
} else if (i < midRange) {
mid += normalized;
} else {
treble += normalized;
}
}
// Average and amplify moderately
let audioLevel = (sum / audioData.length) * 2;
bass = (bass / bassRange) * 2.5;
mid = (mid / (midRange - bassRange)) * 2;
treble = (treble / (audioData.length - midRange)) * 1.5;
// Clamp values
audioLevel = p.constrain(audioLevel, 0, 1);
bass = p.constrain(bass, 0, 1);
mid = p.constrain(mid, 0, 1);
treble = p.constrain(treble, 0, 1);
// Fixed connection distance for consistency
const connectionDist = 100;
// Update and display particles
for (let i = 0; i < particles.length; i++) {
particles[i].update(p, audioLevel, bass, mid);
particles[i].display(p, audioLevel);
// Connect nearby particles (non-reactive distance)
for (let j = i + 1; j < particles.length; j++) {
particles[i].connect(p, particles[j], connectionDist);
}
}
} else if (!isPlaying) {
// Static state when not playing
p.fill(255, 50);
p.noStroke();
p.textAlign(p.CENTER, p.CENTER);
p.textSize(16);
p.text('Press Play', p.width / 2, p.height / 2);
}
};
p.windowResized = () => {
p.resizeCanvas(width, height);
};
};
</script>
<div class="visualization-container">
{#if isPlaying}
<P5 {sketch} />
{:else}
<div class="placeholder" style="width: {width}px; height: {height}px;">
<p>Press Play</p>
</div>
{/if}
</div>
<style>
.visualization-container {
display: flex;
align-items: center;
justify-content: center;
background: #000;
border-radius: 4px;
overflow: hidden;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #000;
color: rgba(255, 255, 255, 0.3);
font-size: 1rem;
}
.placeholder p {
margin: 0;
}
</style>

View File

@@ -1,3 +1,238 @@
<!-- TODO: ADD TONEJS GENERATOR -->
<!-- https://www.npmjs.com/package/tonal -->
<!-- https://www.npmjs.com/package/tone -->
<script lang="ts">
import * as Tone from 'tone';
import { onMount, onDestroy } from 'svelte';
import AudioVisualization from '$lib/components/AudioVisualization.svelte';
import { createNoiseSynth } from '$lib/audio/instruments/noiseSynth';
import { createReverb, createDelay, createGain, createAnalyser } from '$lib/audio/audio-effects';
// Component props - air quality values and shared weather params for effects
let {
dust = 0,
pm10 = 0,
pm25 = 0,
relativeHumidity2m = 50,
windSpeed10m = 0,
volume = -15
} = $props();
// Component state
let isPlaying = $state(false);
let isInitialized = $state(false);
// Audio components
let noiseSynth: Tone.NoiseSynth | null = null;
let reverb: Tone.Reverb | null = null;
let delay: Tone.FeedbackDelay | null = null;
let gain: Tone.Gain | null = null;
let loop: Tone.Loop | null = null;
let analyser: Tone.Analyser | null = null;
// Derive air quality index from pollution values
// Higher values = dirtier air = more frequent bursts
const airQualityIndex = $derived.by(() => {
// Combine all pollution metrics (weighted average)
// PM2.5 is most harmful, so weight it higher
const pollutionScore = (pm25 * 2 + pm10 + dust) / 4;
return Math.max(0, pollutionScore);
});
// Burst interval: cleaner air = slower, dirtier air = faster (like geiger counter)
const burstInterval = $derived.by(() => {
// Map pollution: low pollution = 3-6s, high pollution = 0.3-1s
// Less intense than before
const minInterval = 0.3; // 300ms for very polluted
const maxInterval = 6.0; // 6s for clean air
const normalizedPollution = Math.min(airQualityIndex / 100, 1); // Normalize to 0-1
return maxInterval - normalizedPollution * (maxInterval - minInterval);
});
// Shared delay/reverb parameters (matching WeatherGen)
const reverbWet = $derived.by(() => {
const humidity = relativeHumidity2m ?? 50;
return Math.max(0.3, Math.min(1, humidity / 100));
});
const delayTime = $derived.by(() => {
const speed = windSpeed10m ?? 0;
return speed > 5 ? '4n' : '8n';
});
const delayFeedback = $derived.by(() => {
const speed = windSpeed10m ?? 0;
return Math.max(0.2, Math.min(0.7, (speed / 20) * 0.5 + 0.2));
});
// Initialize audio components
const initializeAudio = async (): Promise<void> => {
try {
// Create instruments
noiseSynth = createNoiseSynth(volume);
// Create effects with much more spacious reverb for air quality
reverb = new Tone.Reverb({
decay: 30, // Very long decay for spacious sound
preDelay: 0.1
});
reverb.wet.value = 0.8; // Higher wet signal for more reverb
delay = createDelay(delayTime, delayFeedback);
gain = createGain(volume);
analyser = createAnalyser();
// Connect audio chain
noiseSynth.chain(delay, reverb, gain, analyser, Tone.Destination);
// Generate reverb impulse
await reverb.generate();
isInitialized = true;
} catch (error) {
console.error('Failed to initialize air quality generator:', error);
}
};
// Start the geiger-counter-like loop
const startLoop = async (): Promise<void> => {
try {
if (!isInitialized) {
await initializeAudio();
}
await Tone.start();
if (loop) {
loop.dispose();
loop = null;
}
// Create a loop that triggers at random intervals
loop = new Tone.Loop((time) => {
if (noiseSynth) {
// Trigger noise burst
noiseSynth.triggerAttackRelease('16n', time);
// Schedule next burst with randomization
const baseInterval = burstInterval;
const randomFactor = 0.5 + Math.random(); // 0.5x to 1.5x variation
const nextBurstTime = baseInterval * randomFactor;
if (loop) {
loop.interval = nextBurstTime;
}
}
}, burstInterval);
loop.start(0);
// Start transport if not already running
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
isPlaying = true;
} catch (error) {
console.error('Error starting air quality loop:', error);
}
};
// Stop the loop
const stopLoop = (): void => {
if (loop) {
loop.stop();
loop.dispose();
loop = null;
}
// Don't stop transport - let WeatherGen control it
isPlaying = false;
};
// Toggle playback
const togglePlayback = async (): Promise<void> => {
if (isPlaying) {
stopLoop();
} else {
await startLoop();
}
};
// Reactive updates for environmental parameters
// Note: Reverb wet is fixed at 0.8 for spacious sound, not reactive to humidity
$effect(() => {
if (delay && isInitialized) {
delay.delayTime.value = delayTime;
delay.feedback.rampTo(delayFeedback, 0.5);
}
});
$effect(() => {
if (gain && isInitialized) {
gain.gain.rampTo(Tone.dbToGain(volume), 0.1);
}
});
$effect(() => {
if (loop && isPlaying) {
// Update loop interval dynamically
const baseInterval = burstInterval;
loop.interval = baseInterval;
}
});
// Lifecycle
onMount(() => {
initializeAudio();
});
onDestroy(() => {
if (loop) {
loop.dispose();
}
if (noiseSynth) {
noiseSynth.dispose();
}
if (reverb) {
reverb.dispose();
}
if (delay) {
delay.dispose();
}
if (gain) {
gain.dispose();
}
if (analyser) {
analyser.dispose();
}
});
</script>
<div
class="mx-auto mt-8 grid max-w-6xl grid-cols-1 gap-8 border-t border-white/20 p-4 pt-8 md:grid-cols-[300px_1fr]"
>
<div class="flex flex-col gap-4">
<button
class="cursor-crosshair rounded-md border border-white/20 px-6 py-3 text-base transition-all duration-300 hover:border-white/40 disabled:cursor-not-allowed disabled:opacity-50 {isPlaying
? 'bg-white text-black'
: 'bg-transparent text-white'}"
onclick={togglePlayback}
disabled={!isInitialized}
>
{isPlaying ? 'Stop' : 'Start'} Air Quality Monitor
</button>
{#if isPlaying}
<div class="flex flex-col gap-2 text-sm opacity-80">
<p class="m-0">PM2.5: {pm25.toFixed(1)} µg/m³</p>
<p class="m-0">PM10: {pm10.toFixed(1)} µg/m³</p>
<p class="m-0">Dust: {dust.toFixed(1)} µg/m³</p>
<p class="my-1 opacity-40">---</p>
<p class="m-0">Pollution Index: {airQualityIndex.toFixed(1)}</p>
<p class="m-0">Burst Interval: {burstInterval.toFixed(2)}s</p>
</div>
{/if}
</div>
<div class="flex items-center justify-center">
<AudioVisualization {isPlaying} {analyser} width={400} height={400} />
</div>
</div>

View File

@@ -1,238 +1,466 @@
<script lang="ts">
import * as Tone from 'tone';
import { onMount, onDestroy } from 'svelte';
import * as Tone from 'tone';
import { onMount, onDestroy } from 'svelte';
import AudioVisualization from '$lib/components/AudioVisualization.svelte';
import { createPadSynth } from '$lib/audio/instruments/padSynth';
import { createArpSynth } from '$lib/audio/instruments/arpSynth';
import { createBassSynth } from '$lib/audio/instruments/bassSynth';
import { createPingSynth } from '$lib/audio/instruments/pingSynth';
import {
createReverb,
createDelay,
createFilter,
createGain,
createAnalyser
} from '$lib/audio/audio-effects';
import { selectChordProgression, calculateComfortScore } from '$lib/audio/weather-mood';
import type { ChordProgression } from '$lib/audio/chord-progressions';
// Component props with default values
let {temperature2m = 20, relativeHumidity2m = 50, cloudCover = 30, volume = -10, windSpeed10m = 0, isDay} = $props();
// Component props with default values
let {
temperature2m = 20,
relativeHumidity2m = 50,
cloudCover = 30,
precipitation = 0,
volume = -10,
windSpeed10m = 0,
isDay
} = $props();
// Component state using runes
let isPlaying = $state(false);
let currentChordIndex = $state(0);
let isInitialized = $state(false);
// Component state using runes
let isPlaying = $state(false);
let isInitialized = $state(false);
// Audio components
let synth: Tone.PolySynth | null = null;
let reverb: Tone.Reverb | null = null;
let delay: Tone.FeedbackDelay | null = null;
let phaser: Tone.Phaser | null = null;
let sequence: Tone.Sequence | null = null;
let gain: Tone.Gain | null = null;
// Audio components
let synth: Tone.PolySynth | null = null;
let arpSynth: Tone.Synth | null = null;
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 bassSequence: Tone.Sequence | null = null;
let reverb: Tone.Reverb | null = null;
let delay: Tone.FeedbackDelay | null = null;
let filter: Tone.Filter | null = null;
let phaser: Tone.Phaser | null = null;
let sequence: Tone.Sequence | null = null;
let gain: Tone.Gain | null = null;
let analyser: Tone.Analyser | null = null;
// Select chord progression based on weather mood
const currentProgression: ChordProgression = $derived.by(() => {
return selectChordProgression({
temperature2m,
relativeHumidity2m,
cloudCover,
windSpeed10m,
precipitation,
isDay
});
});
//TODO - ADD DIFFERENT PROGRESSIONS
const chordProgressions = [
[
{ time: "0:0:0", notes: ['C4', 'E4', 'G4', 'B4'] },
{ time: "0:1:0", notes: ['A3', 'C4', 'E4', 'G4'] },
{ time: "0:2:0", notes: ['F3', 'A3', 'C4', 'E4'] },
{ time: "0:3:0", notes: ['G3', 'B3', 'D4', 'F4'] }
],
[
{ time: "0:0:0", notes: ['D4', 'F4', 'A4', 'C5'] },
{ time: "0:1:0", notes: ['G3', 'B3', 'D4', 'F4'] },
{ time: "0:2:0", notes: ['C4', 'E4', 'G4', 'B4'] },
{ time: "0:3:0", notes: ['A3', 'C4', 'E4', 'G4'] }
],
[
{ time: "0:0:0", notes: ['E4', 'G4', 'B4', 'D5'] },
{ time: "0:1:0", notes: ['A3', 'C4', 'E4', 'G4'] },
{ time: "0:2:0", notes: ['D4', 'F4', 'A4', 'C5'] },
{ time: "0:3:0", notes: ['G3', 'B3', 'D4', 'F4'] }
],
[
{ time: "0:0:0", notes: ['F3', 'A3', 'C4', 'E4'] },
{ time: "0:1:0", notes: ['E4', 'G4', 'B4', 'D5'] },
{ time: "0:2:0", notes: ['D4', 'F4', 'A4', 'C5'] },
{ time: "0:3:0", notes: ['C4', 'E4', 'G4', 'B4'] }
],
];
// Derived reactive values using runes with safe fallbacks
const bpm = $derived.by(() => {
const temp = temperature2m ?? 20;
// BPM starts at 10 for 0°C and increases with temperature
// Day: more energetic (2x scaling), Night: calmer (1x scaling)
const tempAboveZero = Math.max(0, temp);
const scaledBpm = isDay ? 10 + tempAboveZero * 2 : 10 + tempAboveZero;
return Math.max(10, Math.min(200, scaledBpm));
});
let currentProgression = $state(chordProgressions[0]);
const reverbWet = $derived.by(() => {
const humidity = relativeHumidity2m ?? 50;
// Ensure minimum 0.3 wet signal so reverb is always audible
return Math.max(0.3, Math.min(1, humidity / 100));
});
// Derived reactive values using runes
const bpm = $derived((isDay? temperature2m * 2 : temperature2m));
const reverbWet = $derived(relativeHumidity2m/100);
const delayWet = $derived(Math.round(windSpeed10m)/10);
const phaserBase = $derived((1 / cloudCover) * 100);
// Delay time: 8th note for calm, quarter note for windy
const delayTime = $derived.by(() => {
const speed = windSpeed10m ?? 0;
return speed > 5 ? '4n' : '8n';
});
// Initialize audio components
const initializeAudio = async (): Promise<void> => {
try {
// Create dreamy synth
synth = new Tone.PolySynth(Tone.Synth, {
oscillator: {
type: isDay? 'triangle' : 'sine',
},
envelope: {
attack: 1.5,
decay: 1,
sustain: 0.7,
release: 1.0,
},
volume: -20,
});
// Delay feedback: stronger with more wind
const delayFeedback = $derived.by(() => {
const speed = windSpeed10m ?? 0;
// Map 0-20 m/s wind to 0.2-0.7 feedback range
return Math.max(0.2, Math.min(0.7, (speed / 20) * 0.5 + 0.2));
});
// Create reverb with long, dreamy tail
reverb = new Tone.Reverb({
decay: 16,
wet: reverbWet,
preDelay: 0.5,
});
// Filter cutoff: more clouds = darker/lower frequency
const filterCutoff = $derived.by(() => {
const cover = cloudCover ?? 30;
// Map cloud cover: 0% clouds = 8000Hz (bright), 100% clouds = 400Hz (dark)
return Math.max(400, Math.min(8000, 8000 - (cover / 100) * 7600));
});
// Filter resonance: more wind = more resonant
const filterResonance = $derived.by(() => {
const speed = windSpeed10m ?? 0;
// Map 0-30 m/s wind to 1-18 resonance (Q factor)
return Math.max(1, Math.min(18, 1 + (speed / 30) * 17));
});
delay = new Tone.FeedbackDelay({
delayTime: '0.5',
feedback: delayWet
})
// Arpeggio interval: slower in cold, faster in heat
const arpInterval = $derived.by(() => {
const temp = temperature2m ?? 20;
// Map temperature: <0°C = 1n (whole note), 30°C+ = 8n (eighth note)
if (temp < 0) return '1n';
if (temp < 10) return '2n';
if (temp < 20) return '4n';
return '8n';
});
// Create a phaser
phaser = new Tone.Phaser({
frequency : phaserBase,
octaves : 5,
baseFrequency : 350
})
// Create gain node
gain = new Tone.Gain(Tone.dbToGain(volume));
// Arpeggio volume: quieter in cold, louder in heat
const arpVolume = $derived.by(() => {
const temp = temperature2m ?? 20;
// Map temperature: <0°C = -22dB, 30°C+ = -10dB
const normalizedTemp = Math.max(0, Math.min(30, temp));
return -22 + (normalizedTemp / 30) * 12;
});
// Connect audio chain
synth.connect(phaser).connect(delay).connect(reverb).connect(gain).toDestination();
// Weather extremity: 0 = pleasant, 1 = extreme conditions
const weatherExtremity = $derived.by(() => {
const comfortScore = calculateComfortScore({
temperature2m,
relativeHumidity2m,
cloudCover,
windSpeed10m,
precipitation,
isDay
});
return 1 - comfortScore; // Invert: higher = more extreme
});
// Generate reverb impulse
await reverb.generate();
// Ping volume: quieter in pleasant weather, louder in extreme weather
const pingVolume = $derived.by(() => {
// Map extremity: 0 (pleasant) = -22dB (present), 1 (extreme) = -8dB (prominent)
return -22 + weatherExtremity * 14;
});
isInitialized = true;
console.log('Audio initialized successfully');
} catch (error) {
console.error('Failed to initialize audio:', error);
}
};
// Ping interval: slower in cold, faster in heat
const pingInterval = $derived.by(() => {
const temp = temperature2m ?? 20;
// Map temperature: 0°C = 1n (whole note, slow), 30°C = 16n (16th note, fast)
if (temp <= 0) return '1n';
if (temp <= 10) return '2n';
if (temp <= 20) return '4n';
if (temp <= 25) return '8n';
return '16n';
});
// Start the chord sequence
const startSequence = async (): Promise<void> => {
try {
if (!isInitialized) {
await initializeAudio();
}
// Initialize audio components
const initializeAudio = async (): Promise<void> => {
try {
// Create instruments
synth = createPadSynth(isDay);
arpSynth = createArpSynth(arpVolume);
pingSynth = createPingSynth(pingVolume);
bassSynth = createBassSynth();
await Tone.start();
// Create effects
reverb = createReverb(reverbWet);
delay = createDelay(delayTime, delayFeedback);
filter = createFilter(filterCutoff, filterResonance);
gain = createGain(volume);
analyser = createAnalyser();
if (sequence) {
sequence.dispose();
sequence = null;
}
// Connect audio chain using .chain() for clarity
synth.chain(filter, delay, reverb, gain, analyser, Tone.Destination);
arpSynth.chain(filter, delay, reverb, gain);
pingSynth.chain(filter, delay, reverb, gain);
bassSynth.chain(delay, reverb, gain);
// Set transport BPM
Tone.getTransport().bpm.value = bpm;
// Generate reverb impulse
await reverb.generate();
let progressionChangeCounter = 0;
isInitialized = true;
} catch (error) {
console.error('Failed to initialize audio:', error);
}
};
sequence = new Tone.Sequence((time: number, chord) => {
if (synth && chord) {
synth!.triggerAttackRelease(chord.notes, '4n', time);
}
// Start the chord sequence
const startSequence = async (): Promise<void> => {
try {
if (!isInitialized) {
await initializeAudio();
}
progressionChangeCounter++;
//Change progression every full cycle (4 chords) for variation
if (progressionChangeCounter >= currentProgression.length) {
currentChordIndex = (currentChordIndex + 1) % chordProgressions.length;
currentProgression = chordProgressions[currentChordIndex];
sequence!.events = currentProgression;
progressionChangeCounter = 0;
}
}, currentProgression, "4n");
await Tone.start();
sequence.start(0);
Tone.getTransport().start();
isPlaying = true;
} catch (error) {
console.error('Error starting sequence:', error);
}
};
if (sequence) {
sequence.dispose();
sequence = null;
}
// Stop the sequence
const stopSequence = (): void => {
if (sequence) {
sequence.stop();
sequence.dispose();
sequence = null;
}
Tone.getTransport().stop();
Tone.getTransport().cancel();
isPlaying = false;
};
// Set transport BPM
Tone.getTransport().bpm.value = bpm;
// Toggle playback
const togglePlayback = async (): Promise<void> => {
if (isPlaying) {
stopSequence();
} else {
await startSequence();
}
};
sequence = new Tone.Sequence(
(time: number, chord) => {
if (synth && chord) {
synth!.triggerAttackRelease(chord.notes, '4n', time);
}
},
currentProgression,
'4n'
);
// Reactive updates for environmental parameters using effects
$effect(() => {
if (reverb && isInitialized) {
reverb.wet.rampTo(reverbWet, 0.5);
}
});
sequence.start(0);
$effect(() => {
if (phaser && isInitialized) {
phaser.frequency.rampTo(phaserBase, 0.5);
}
});
// Create arpeggio sequence
if (arpSequence) {
arpSequence.dispose();
arpSequence = null;
}
$effect(() => {
if (gain && isInitialized) {
gain.gain.rampTo(Tone.dbToGain(volume), 0.1);
}
});
arpSequence = new Tone.Sequence(
(time: number, chord) => {
if (arpSynth && chord && chord.notes) {
// Play arpeggio pattern through the chord notes
chord.notes.forEach((note: string, index: number) => {
const noteTime = time + index * 0.15; // 150ms between notes
arpSynth!.triggerAttackRelease(note, '16n', noteTime);
});
}
},
currentProgression,
arpInterval
);
$effect(() => {
if (isPlaying && isInitialized) {
Tone.getTransport().bpm.rampTo(bpm, 1.0);
}
});
arpSequence.start(0);
// Lifecycle
onMount(() => {
initializeAudio();
});
// Create ping sequence (reverse arpeggio - evenly spaced through chord duration)
if (pingSequence) {
pingSequence.dispose();
pingSequence = null;
}
onDestroy(() => {
if (sequence) {
sequence.dispose();
}
if (synth) {
synth.dispose();
}
if (reverb) {
reverb.dispose();
}
if (phaser) {
phaser.dispose();
}
if (gain) {
gain.dispose();
}
});
// Build a flat array of notes: 4 notes per chord, in reverse order, transposed up 2 octaves
const pingNotes: 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];
const noteName = note.slice(0, -1);
const octave = parseInt(note.slice(-1));
const highNote = noteName + (octave + 2);
pingNotes.push(highNote);
}
}
});
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)
);
pingSequence.start(0);
// Create bass sequence
if (bassSequence) {
bassSequence.dispose();
bassSequence = null;
}
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'
// Randomize release time: half to full chord duration
// Quarter note = 1 beat, so random between 0.5 and 1.0 beats
const randomRelease = 0.5 + Math.random() * 0.5;
bassSynth.envelope.release = randomRelease * (60 / bpm);
// Trigger bass note
bassSynth.triggerAttackRelease(bassNote, '4n', time);
}
},
currentProgression,
'4n'
);
bassSequence.start(0);
Tone.getTransport().start();
isPlaying = true;
} catch (error) {
console.error('Error starting sequence:', error);
}
};
// Stop the sequence
const stopSequence = (): void => {
if (sequence) {
sequence.stop();
sequence.dispose();
sequence = null;
}
if (arpSequence) {
arpSequence.stop();
arpSequence.dispose();
arpSequence = null;
}
if (pingSequence) {
pingSequence.stop();
pingSequence.dispose();
pingSequence = null;
}
if (bassSequence) {
bassSequence.stop();
bassSequence.dispose();
bassSequence = null;
}
Tone.getTransport().stop();
Tone.getTransport().cancel();
isPlaying = false;
};
// Toggle playback
const togglePlayback = async (): Promise<void> => {
if (isPlaying) {
stopSequence();
} else {
await startSequence();
}
};
// Reactive updates for environmental parameters using effects
$effect(() => {
if (reverb && isInitialized) {
reverb.wet.rampTo(reverbWet, 0.5);
}
});
$effect(() => {
if (delay && isInitialized) {
delay.delayTime.value = delayTime;
delay.feedback.rampTo(delayFeedback, 0.5);
}
});
$effect(() => {
if (filter && isInitialized) {
filter.frequency.rampTo(filterCutoff, 1.0);
filter.Q.rampTo(filterResonance, 1.0);
}
});
$effect(() => {
if (gain && isInitialized) {
gain.gain.rampTo(Tone.dbToGain(volume), 0.1);
}
});
$effect(() => {
if (isPlaying && isInitialized) {
Tone.getTransport().bpm.rampTo(bpm, 1.0);
}
});
$effect(() => {
if (arpSynth && isInitialized) {
arpSynth.volume.rampTo(arpVolume, 0.5);
}
});
$effect(() => {
if (pingSynth && isInitialized) {
pingSynth.volume.rampTo(pingVolume, 0.5);
}
});
// Lifecycle
onMount(() => {
initializeAudio();
});
onDestroy(() => {
if (sequence) {
sequence.dispose();
}
if (arpSequence) {
arpSequence.dispose();
}
if (pingSequence) {
pingSequence.dispose();
}
if (bassSequence) {
bassSequence.dispose();
}
if (synth) {
synth.dispose();
}
if (arpSynth) {
arpSynth.dispose();
}
if (pingSynth) {
pingSynth.dispose();
}
if (bassSynth) {
bassSynth.dispose();
}
if (reverb) {
reverb.dispose();
}
if (delay) {
delay.dispose();
}
if (filter) {
filter.dispose();
}
if (gain) {
gain.dispose();
}
if (analyser) {
analyser.dispose();
}
});
</script>
<div class="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 p-4 max-w-6xl mx-auto">
<div class="flex flex-col gap-4">
<button
class="px-6 py-3 text-base cursor-crosshair transition-all duration-300 rounded-md border border-white/20 hover:border-white/40 disabled:opacity-50 disabled:cursor-not-allowed {isPlaying
? 'bg-white text-black'
: 'bg-transparent text-white'}"
onclick={togglePlayback}
disabled={!isInitialized}
>
{isPlaying ? 'Stop' : 'Play'} Weather Ambient
</button>
<!-- TODO: ADD VIZUALZ https://www.npmjs.com/package/p5-svelte -->
<!-- https://jsfiddle.net/aqilahmisuary/ztf5a72h/#base -->
<div class="controls-container">
<!-- Playback Control -->
<div class="playback-section">
<button
class="play-button {isPlaying ? 'playing' : ''}"
onclick = {togglePlayback}
disabled={!isInitialized}
>
{isPlaying ? 'Stop' : 'Play'}
</button>
</div>
{#if isPlaying}
<div class="flex flex-col gap-2 text-sm opacity-80">
<p class="m-0">Temperature: {temperature2m.toFixed(1)}°C</p>
<p class="m-0">Humidity: {relativeHumidity2m}%</p>
<p class="m-0">Cloud Cover: {cloudCover}%</p>
<p class="m-0">Wind Speed: {windSpeed10m.toFixed(1)} m/s</p>
<p class="opacity-40 my-1">---</p>
<p class="m-0">BPM: {bpm}</p>
<p class="m-0">Weather Extremity: {(weatherExtremity * 100).toFixed(0)}%</p>
<p class="m-0">Reverb: {reverbWet.toFixed(2)}</p>
<p class="m-0">Delay: {delayTime} @ {delayFeedback.toFixed(2)} feedback</p>
<p class="m-0">Filter: {Math.round(filterCutoff)}Hz Q:{filterResonance.toFixed(1)}</p>
</div>
{/if}
</div>
<div class="flex items-center justify-center">
<AudioVisualization {isPlaying} {analyser} width={400} height={400} />
</div>
</div>