added generative audio, tailwind, improvements, adjustments
This commit is contained in:
35
src/lib/audio/audio-effects.ts
Normal file
35
src/lib/audio/audio-effects.ts
Normal 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);
|
||||
}
|
||||
61
src/lib/audio/chord-progressions.ts
Normal file
61
src/lib/audio/chord-progressions.ts
Normal 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
|
||||
];
|
||||
16
src/lib/audio/instruments/arpSynth.ts
Normal file
16
src/lib/audio/instruments/arpSynth.ts
Normal 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
|
||||
});
|
||||
}
|
||||
16
src/lib/audio/instruments/bassSynth.ts
Normal file
16
src/lib/audio/instruments/bassSynth.ts
Normal 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
|
||||
});
|
||||
}
|
||||
16
src/lib/audio/instruments/noiseSynth.ts
Normal file
16
src/lib/audio/instruments/noiseSynth.ts
Normal 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
|
||||
});
|
||||
}
|
||||
16
src/lib/audio/instruments/padSynth.ts
Normal file
16
src/lib/audio/instruments/padSynth.ts
Normal 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
|
||||
});
|
||||
}
|
||||
21
src/lib/audio/instruments/pingSynth.ts
Normal file
21
src/lib/audio/instruments/pingSynth.ts
Normal 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
|
||||
});
|
||||
}
|
||||
97
src/lib/audio/weather-mood.ts
Normal file
97
src/lib/audio/weather-mood.ts
Normal 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;
|
||||
}
|
||||
196
src/lib/components/AudioVisualization.svelte
Normal file
196
src/lib/components/AudioVisualization.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user