Compare commits

...

17 Commits

Author SHA1 Message Date
7aacb29dbd update README 2025-12-29 21:08:07 +02:00
714fbd6c93 bugfixes, removals 2025-12-29 20:58:17 +02:00
ef3343cf62 remove dynamic updates since WX info is not updated 2025-12-29 19:23:24 +02:00
8bf5a4dacd bump volume up 2025-12-29 19:18:41 +02:00
e5101fd3c8 update README 2025-12-29 17:05:53 +02:00
644d6f0106 wakelock experiment 2025-12-29 17:04:08 +02:00
09c03ec780 update README 2025-12-29 16:55:37 +02:00
a28d09915c optimizations for mobile 2025-12-29 16:49:02 +02:00
d078706c50 update README 2025-12-29 16:19:42 +02:00
b9ed7de228 add ability to set manual coords 2025-12-29 16:17:38 +02:00
07a5cca3b3 test webhook 2025-12-29 14:31:15 +02:00
0746799c9b changes to prerendering 2025-12-29 14:05:49 +02:00
78d2f3139e change to static adapter 2025-12-29 14:02:51 +02:00
908f22faca update README 2025-12-29 13:48:32 +02:00
608af228c5 add favicon, major performance optimizations 2025-12-29 13:42:20 +02:00
0e1bff8259 change to fat synth, finetune parameters 2025-12-29 13:05:39 +02:00
df43d3b476 added readme 2025-12-29 03:40:29 +02:00
18 changed files with 4155 additions and 164 deletions

188
README.md
View File

@@ -1,2 +1,188 @@
# hear-on-out # hear-on-out
Generative ambient noise affected by environment data for your location
A generative ambient music application that transforms real-time weather and air quality data into dynamic soundscapes.
## Overview
hear-on-out uses your geolocation to fetch current weather and air quality data, then generates unique ambient music that responds to environmental conditions. Audio parameters are calculated from weather conditions at page load and remain static during playback - refresh the page to get updated soundscapes based on new weather data.
## Features
### Weather-Reactive Generative Music
**Chord Progressions**
- 6 distinct mood-based progressions (bright, dreamy, melancholic, tense, warm, ethereal)
- Intelligent selection based on weather comfort scoring
- Smooth transitions between progressions
**Instruments**
- **Ambient Pad**: Lush detuned fat oscillator (3 voices, 30 cent spread) with day/night reactive envelopes for rich, warm soundscapes
- **Main Arpeggio**: Temperature-reactive note patterns with variable speed (1n → 8n) and volume, bypasses filter for clarity
- **Ping Arpeggio**: High-pitched reverse arpeggios that intensify with weather extremity, speed scales with temperature (1n → 16n)
- **Bass Synth**: Root note foundation with randomized release times for subtle variation
**Weather-Mapped Audio Parameters**
Audio parameters are calculated from weather data when the page loads and set once when playback starts:
- **BPM**: Scales linearly with temperature (5 BPM at 0°C → 30 BPM at 30°C)
- **Reverb**: Wet signal responds to humidity (more humid = more reverb, 0.3-1.0 range)
- **Delay**: Time responds to wind speed (0.1-0.8 seconds), feedback increases with wind (0.2-0.7 range)
- **Filter**: Applied only to ambient pad, cutoff frequency brightens with temperature (0°C = 400Hz dark, 30°C = 8000Hz bright)
- **Resonance**: Increases with wind speed for sharper filtering
- **Arpeggio Speed**: Temperature affects note intervals (cold = slow, hot = fast)
- **Ping Intensity**: Volume scales with weather extremity (pleasant = quiet, extreme = loud)
### Air Quality Monitor
- Geiger-counter style noise bursts that trigger based on pollution levels
- Higher pollution = more frequent bursts (6s intervals for clean air → 0.3s for heavy pollution)
- Spacious reverb (30s decay) for atmospheric depth
- Monitors PM2.5, PM10, and dust particles
### Audio Visualization
- Real-time particle system using p5.js
- **Desktop**: 40 particles at 30fps with connection lines
- **Mobile**: 15 particles at 20fps, no connections (performance optimized)
- FFT analysis for bass, mid, and treble frequency detection
- Particles react to audio levels with dynamic size and movement
- Adaptive sizing: 400x400px (desktop) or 280x280px (mobile)
- Automatic pause during scrolling to prevent audio interference on mobile
## Technologies
- **SvelteKit** - Web framework with Svelte 5 runes
- **Tone.js** - Web Audio API wrapper for synthesis and effects
- **p5.js** - Creative coding for visualizations
- **Tailwind CSS v4** - Styling with black/white monochrome theme
- **Open-Meteo API** - Weather and air quality data
## Performance Optimizations
The application includes several optimizations for smooth performance:
**Desktop Optimizations:**
- 40 particles with 30fps rendering
- Audio-reactive displacement updates every 3rd frame
- Particle connections drawn every other frame with limited neighbor checking
- Tab visibility detection: Visualization pauses when tab is not focused
- Efficient audio routing: Arpeggios bypass filter for cleaner CPU usage
**Mobile-Specific Optimizations:**
- **Reduced particle count**: 15 particles (vs 40 on desktop)
- **Lower framerate**: 20fps (vs 30fps on desktop)
- **Disabled connection lines**: Removes expensive O(n²) calculations
- **Smaller canvas**: 280x280px visualizations (vs 400x400px on desktop)
- **Scroll handling**: Visualization pauses during scrolling to prevent audio stuttering
- **Less frequent updates**: Displacement updates every 5th frame (vs 3rd on desktop)
- **Screen Wake Lock API**: Prevents screen sleep during audio playback
- **Screen sleep recovery**: Audio context automatically resumes after device wakes up
- **Visibility API**: Properly handles audio context suspension/resumption
**Mobile Performance Note:**
Running both generators simultaneously on mobile devices may cause performance issues. For optimal experience on mobile, consider running one generator at a time.
## Setup
```bash
# Install dependencies
npm install
# Run development server
npm run dev
# Build for production
npm run build
```
## How It Works
1. **Location Input**: User can either:
- Grant browser geolocation permissions (automatic location detection)
- Manually enter latitude/longitude coordinates
2. **Data Fetch**: Current weather and air quality data retrieved from Open-Meteo API
3. **Weather Comfort Scoring**: Algorithm calculates comfort based on temperature, humidity, cloud cover, wind, and precipitation
4. **Progression Selection**: Chooses appropriate chord progression based on weather mood
5. **Parameter Calculation**: Audio parameters (BPM, reverb, delay, filter, etc.) are derived from weather conditions
6. **Audio Generation**: When playback starts, all parameters are set and remain static until page refresh
7. **Static Playback**: Audio continues with the same settings - refresh the page to update based on new weather data
## Geolocation & Browser Compatibility
The app supports two methods for location input:
**Automatic Geolocation:**
- Requires HTTPS in production (localhost works without HTTPS)
- User must grant browser permission when prompted
- May not work on some mobile browsers or restrictive browser settings
**Manual Coordinate Entry:**
- Fallback option for when geolocation is unavailable
- Accepts latitude (-90 to 90) and longitude (-180 to 180)
- Works on all devices and browsers
- Useful for exploring weather from different locations
**Production Requirements:**
- Must be served over HTTPS for geolocation to work
- Development server uses `@vitejs/plugin-basic-ssl` for local HTTPS
## Mobile Experience
The application is optimized for mobile devices with several performance enhancements:
**Audio Playback:**
- **Screen Wake Lock**: Prevents screen from sleeping while audio is playing
- Automatically requested when playback starts
- Released when playback stops or app is closed
- Supported on Chrome/Edge 84+, Safari 16.4+, Opera 70+
- Gracefully degrades on unsupported browsers (Firefox)
- Automatic audio context recovery after screen sleep/wake
- Handles browser audio suspension gracefully
- Transport automatically restarts when app becomes visible
**Visual Performance:**
- Adaptive particle count and framerate based on device
- Visualization pauses during scrolling to prevent stuttering
- Smaller canvas size for reduced GPU load
**Known Limitations:**
- Running both generators simultaneously may impact performance on lower-end devices
- Some mobile browsers may require user interaction to start audio playback
- Audio may briefly pause during heavy scrolling or multitasking
- Wake Lock not supported on Firefox (audio may stop when screen locks)
## Project Structure
```
src/
├── lib/
│ ├── audio/
│ │ ├── instruments/ # Modular synth instruments
│ │ ├── audio-effects.ts # Shared audio effects
│ │ ├── chord-progressions.ts
│ │ └── weather-mood.ts # Comfort scoring & progression selection
│ ├── components/
│ │ └── AudioVisualization.svelte
│ └── generators/
│ ├── weather/WeatherGen.svelte
│ └── air-quality/AirQualityGen.svelte
└── routes/
├── +page.svelte # Geolocation entry point
└── on-out/+page.svelte # Audio generators
```
## Weather Mood Mapping
| Conditions | Progression | Characteristics |
|------------|-------------|-----------------|
| Stormy/Extreme | Tense | Dissonant, unsettling |
| Very Hot (30°C+) | Warm | Energetic, bright |
| Cold/Rainy | Melancholic | Somber, introspective |
| Foggy/Misty | Ethereal | Spacious, mysterious |
| Pleasant Day | Bright | Uplifting, major tonality |
| Pleasant Night | Dreamy | Calm, flowing |
## License
MIT

View File

@@ -18,7 +18,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",

3539
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <title>Hear on Out - Listen to the Weather</title>
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="%sveltekit.assets%/favicon-96x96.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -31,5 +31,5 @@ export function createGain(volume: number): Tone.Gain {
} }
export function createAnalyser(): Tone.Analyser { export function createAnalyser(): Tone.Analyser {
return new Tone.Analyser('fft', 512); return new Tone.Analyser('fft', 256); // Reduced from 512 for better performance
} }

View File

@@ -11,6 +11,6 @@ export function createBassSynth(): Tone.Synth {
sustain: 0.8, sustain: 0.8,
release: 2.0 release: 2.0
}, },
volume: -20 volume: -13
}); });
} }

View File

@@ -9,7 +9,7 @@ export function createNoiseSynth(volume: number): Tone.NoiseSynth {
attack: 0.005, attack: 0.005,
decay: 0.1, decay: 0.1,
sustain: 0, sustain: 0,
release: 0.1 release: 0.3
}, },
volume: volume volume: volume
}); });

View File

@@ -3,14 +3,16 @@ import * as Tone from 'tone';
export function createPadSynth(isDay: boolean): Tone.PolySynth { export function createPadSynth(isDay: boolean): Tone.PolySynth {
return new Tone.PolySynth(Tone.Synth, { return new Tone.PolySynth(Tone.Synth, {
oscillator: { oscillator: {
type: isDay ? 'triangle' : 'sine' type: 'fatsine', // Fat oscillator creates multiple detuned voices for lush sound
count: 3, // Number of detuned oscillators
spread: 30 // Amount of detune in cents for width and movement
}, },
envelope: { envelope: {
attack: 1.5, attack: isDay ? 2.0 : 3.0, // Longer, smoother attack
decay: 1, decay: 1.5,
sustain: 0.7, sustain: 0.85, // Higher sustain for consistent pad presence
release: 1.0 release: isDay ? 2.5 : 4.0 // Longer release for smooth tail-off
}, },
volume: -20 volume: -14 // Quieter to sit back in the mix and reduce mid heaviness
}); });
} }

View File

@@ -11,9 +11,22 @@
}>(); }>();
let particles: Particle[] = []; let particles: Particle[] = [];
const numParticles = 60; // Detect mobile for performance optimization
const isMobile = typeof window !== 'undefined' &&
(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth < 768);
const numParticles = isMobile ? 15 : 40; // Significantly reduced for mobile
let audioData: Float32Array | null = null; let audioData: Float32Array | null = null;
// Cache frequently used calculations
let bassRange = 0;
let midRange = 0;
let trebleStart = 0;
// Track if user is scrolling to reduce load
let isScrolling = false;
let scrollTimeout: number;
class Particle { class Particle {
x: number; x: number;
y: number; y: number;
@@ -37,19 +50,27 @@
this.alpha = p.random(100, 255); this.alpha = p.random(100, 255);
} }
update(p: p5, audioLevel: number, bass: number, mid: number) { update(p: p5, audioLevel: number, bass: number, mid: number, isMobile: boolean) {
// Base movement - gentle constant speed // Base movement - gentle constant speed
this.x += this.vx; this.x += this.vx;
this.y += this.vy; this.y += this.vy;
// Subtle audio reactive displacement // Guard against invalid values
const displacement = audioLevel * 20; const safeAudioLevel = isFinite(audioLevel) ? audioLevel : 0;
const safeBass = isFinite(bass) ? bass : 0;
const safeMid = isFinite(mid) ? mid : 0;
// Subtle audio reactive displacement (less frequent on mobile)
const updateFrequency = isMobile ? 5 : 3;
if (p.frameCount % updateFrequency === 0) {
const displacement = safeAudioLevel * 20;
const angle = p.noise(this.x * 0.01, this.y * 0.01, p.frameCount * 0.01) * p.TWO_PI; 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.x += p.cos(angle) * displacement * 0.05;
this.y += p.sin(angle) * displacement * 0.05; this.y += p.sin(angle) * displacement * 0.05;
}
// Subtle audio reactive size // Subtle audio reactive size
this.size = p.map(bass + mid, 0, 2, 3, 10); this.size = p.map(safeBass + safeMid, 0, 2, 3, 10);
// Wrap around edges // Wrap around edges
if (this.x < -50) this.x = p.width + 50; if (this.x < -50) this.x = p.width + 50;
@@ -60,7 +81,8 @@
display(p: p5, audioLevel: number) { display(p: p5, audioLevel: number) {
p.noStroke(); p.noStroke();
const dynamicAlpha = p.map(audioLevel, 0, 1, 120, 220); const safeAudioLevel = isFinite(audioLevel) ? audioLevel : 0;
const dynamicAlpha = p.map(safeAudioLevel, 0, 1, 120, 220);
p.fill(255, dynamicAlpha); p.fill(255, dynamicAlpha);
p.ellipse(this.x, this.y, this.size, this.size); p.ellipse(this.x, this.y, this.size, this.size);
} }
@@ -79,6 +101,8 @@
const sketch = (p: p5) => { const sketch = (p: p5) => {
p.setup = () => { p.setup = () => {
p.createCanvas(width, height); p.createCanvas(width, height);
// Lower framerate on mobile for better performance
p.frameRate(isMobile ? 20 : 30);
p.background(0); p.background(0);
// Initialize particles // Initialize particles
@@ -86,15 +110,37 @@
for (let i = 0; i < numParticles; i++) { for (let i = 0; i < numParticles; i++) {
particles.push(new Particle(p, i)); particles.push(new Particle(p, i));
} }
// Handle scroll events to pause visualization
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
isScrolling = true;
clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
isScrolling = false;
}, 150);
}, { passive: true });
}
}; };
p.draw = () => { p.draw = () => {
// Pause visualization when document is hidden, scrolling, or on mobile during heavy load
if (document.hidden || (isMobile && isScrolling)) {
return;
}
p.background(0, 30); // Fade effect p.background(0, 30); // Fade effect
if (isPlaying && analyser) { if (isPlaying && analyser) {
// Get FFT data (frequency analysis) // Get FFT data (frequency analysis)
audioData = analyser.getValue() as Float32Array; audioData = analyser.getValue() as Float32Array;
// Initialize range values on first run
if (bassRange === 0) {
bassRange = Math.floor(audioData.length * 0.15); // Low frequencies
midRange = Math.floor(audioData.length * 0.4); // Mid frequencies
trebleStart = midRange;
}
// Calculate audio metrics from FFT data // Calculate audio metrics from FFT data
// FFT values are in decibels (negative values, typically -100 to 0) // FFT values are in decibels (negative values, typically -100 to 0)
let sum = 0; let sum = 0;
@@ -102,13 +148,13 @@
let mid = 0; let mid = 0;
let treble = 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++) { for (let i = 0; i < audioData.length; i++) {
// Convert from decibels to linear scale (0-1) // Convert from decibels to linear scale (0-1)
// FFT returns values from -100 to 0 dB // FFT returns values from -100 to 0 dB
const normalized = p.map(audioData[i], -100, -30, 0, 1, true); const value = audioData[i];
// Guard against NaN/Infinity
if (!isFinite(value)) continue;
const normalized = p.map(value, -100, -30, 0, 1, true);
sum += normalized; sum += normalized;
if (i < bassRange) { if (i < bassRange) {
@@ -120,31 +166,36 @@
} }
} }
// Average and amplify moderately // Average and amplify moderately (with division by zero guards)
let audioLevel = (sum / audioData.length) * 2; let audioLevel = audioData.length > 0 ? (sum / audioData.length) * 2 : 0;
bass = (bass / bassRange) * 2.5; bass = bassRange > 0 ? (bass / bassRange) * 2.5 : 0;
mid = (mid / (midRange - bassRange)) * 2; mid = (midRange - bassRange) > 0 ? (mid / (midRange - bassRange)) * 2 : 0;
treble = (treble / (audioData.length - midRange)) * 1.5; treble = (audioData.length - trebleStart) > 0 ? (treble / (audioData.length - trebleStart)) * 1.5 : 0;
// Clamp values // Clamp values to prevent NaN
audioLevel = p.constrain(audioLevel, 0, 1); audioLevel = isNaN(audioLevel) ? 0 : p.constrain(audioLevel, 0, 1);
bass = p.constrain(bass, 0, 1); bass = isNaN(bass) ? 0 : p.constrain(bass, 0, 1);
mid = p.constrain(mid, 0, 1); mid = isNaN(mid) ? 0 : p.constrain(mid, 0, 1);
treble = p.constrain(treble, 0, 1); treble = isNaN(treble) ? 0 : p.constrain(treble, 0, 1);
// Fixed connection distance for consistency // Fixed connection distance for consistency
const connectionDist = 100; const connectionDist = 100;
// Update and display particles // Update and display particles
for (let i = 0; i < particles.length; i++) { for (let i = 0; i < particles.length; i++) {
particles[i].update(p, audioLevel, bass, mid); particles[i].update(p, audioLevel, bass, mid, isMobile);
particles[i].display(p, audioLevel); particles[i].display(p, audioLevel);
}
// Connect nearby particles (non-reactive distance) // Draw connections - skip on mobile for better performance
for (let j = i + 1; j < particles.length; j++) { if (!isMobile && p.frameCount % 2 === 0) {
for (let i = 0; i < particles.length; i++) {
// Only check next 5 particles instead of all, reduces O(n²) significantly
for (let j = i + 1; j < Math.min(i + 6, particles.length); j++) {
particles[i].connect(p, particles[j], connectionDist); particles[i].connect(p, particles[j], connectionDist);
} }
} }
}
} else if (!isPlaying) { } else if (!isPlaying) {
// Static state when not playing // Static state when not playing
p.fill(255, 50); p.fill(255, 50);

View File

@@ -12,13 +12,22 @@
pm25 = 0, pm25 = 0,
relativeHumidity2m = 50, relativeHumidity2m = 50,
windSpeed10m = 0, windSpeed10m = 0,
volume = -15 volume = -12
} = $props(); } = $props();
// Component state // Component state
let isPlaying = $state(false); let isPlaying = $state(false);
let isInitialized = $state(false); let isInitialized = $state(false);
// Detect mobile for visualization sizing
const isMobile = typeof window !== 'undefined' &&
(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth < 768);
const vizSize = isMobile ? 280 : 400;
// Screen Wake Lock to prevent screen from sleeping during playback
let wakeLock: WakeLockSentinel | null = null;
// Audio components // Audio components
let noiseSynth: Tone.NoiseSynth | null = null; let noiseSynth: Tone.NoiseSynth | null = null;
let reverb: Tone.Reverb | null = null; let reverb: Tone.Reverb | null = null;
@@ -54,7 +63,9 @@
const delayTime = $derived.by(() => { const delayTime = $derived.by(() => {
const speed = windSpeed10m ?? 0; const speed = windSpeed10m ?? 0;
return speed > 5 ? '4n' : '8n'; // Map 0-20 m/s wind to 0.1-0.8 second delay range
const normalized = Math.min(20, speed) / 20;
return 0.1 + normalized * 0.7;
}); });
const delayFeedback = $derived.by(() => { const delayFeedback = $derived.by(() => {
@@ -62,9 +73,42 @@
return Math.max(0.2, Math.min(0.7, (speed / 20) * 0.5 + 0.2)); return Math.max(0.2, Math.min(0.7, (speed / 20) * 0.5 + 0.2));
}); });
// Request wake lock to prevent screen from sleeping
const requestWakeLock = async (): Promise<void> => {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
console.log('Wake Lock is active (Air Quality)');
// Handle wake lock release (e.g., when tab is hidden)
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released (Air Quality)');
});
}
} catch (err) {
console.error('Failed to request wake lock:', err);
}
};
// Release wake lock
const releaseWakeLock = async (): Promise<void> => {
if (wakeLock !== null) {
try {
await wakeLock.release();
wakeLock = null;
console.log('Wake Lock released (Air Quality)');
} catch (err) {
console.error('Failed to release wake lock:', err);
}
}
};
// Initialize audio components // Initialize audio components
const initializeAudio = async (): Promise<void> => { const initializeAudio = async (): Promise<void> => {
try { try {
// Optimize audio scheduling for better stability
Tone.getContext().lookAhead = 0.1; // Keep default 100ms lookahead
// Create instruments // Create instruments
noiseSynth = createNoiseSynth(volume); noiseSynth = createNoiseSynth(volume);
@@ -105,20 +149,21 @@
loop = null; loop = null;
} }
// Create a loop that triggers at random intervals // Set all audio parameters once (static until page refresh)
if (delay) {
delay.delayTime.value = delayTime;
delay.feedback.value = delayFeedback;
}
if (gain) {
gain.gain.value = Tone.dbToGain(volume);
}
// Create a loop with static interval (no variation)
loop = new Tone.Loop((time) => { loop = new Tone.Loop((time) => {
if (noiseSynth) { if (noiseSynth) {
// Trigger noise burst // Trigger noise burst at the scheduled time
noiseSynth.triggerAttackRelease('16n', time); 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); }, burstInterval);
@@ -129,6 +174,9 @@
Tone.getTransport().start(); Tone.getTransport().start();
} }
// Request wake lock to prevent screen sleep
await requestWakeLock();
isPlaying = true; isPlaying = true;
} catch (error) { } catch (error) {
console.error('Error starting air quality loop:', error); console.error('Error starting air quality loop:', error);
@@ -136,7 +184,7 @@
}; };
// Stop the loop // Stop the loop
const stopLoop = (): void => { const stopLoop = async (): Promise<void> => {
if (loop) { if (loop) {
loop.stop(); loop.stop();
loop.dispose(); loop.dispose();
@@ -144,6 +192,9 @@
} }
// Don't stop transport - let WeatherGen control it // Don't stop transport - let WeatherGen control it
isPlaying = false; isPlaying = false;
// Release wake lock when stopping
await releaseWakeLock();
}; };
// Toggle playback // Toggle playback
@@ -155,36 +206,41 @@
} }
}; };
// Reactive updates for environmental parameters // Audio parameters are set once on playback start and remain static
// 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 // Lifecycle
onMount(() => { onMount(() => {
initializeAudio(); initializeAudio();
// Handle page visibility changes (screen sleep/wake, tab switching)
const handleVisibilityChange = async () => {
if (!document.hidden && isPlaying) {
// Page is visible again - resume audio context if suspended
if (Tone.getContext().state === 'suspended') {
await Tone.getContext().resume();
}
// Restart loop if it stopped
if (loop && loop.state !== 'started') {
loop.start(0);
}
// Re-request wake lock if it was released
if (wakeLock === null) {
await requestWakeLock();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}); });
onDestroy(() => { onDestroy(async () => {
// Release wake lock on component destroy
await releaseWakeLock();
if (loop) { if (loop) {
loop.dispose(); loop.dispose();
} }
@@ -217,7 +273,7 @@
onclick={togglePlayback} onclick={togglePlayback}
disabled={!isInitialized} disabled={!isInitialized}
> >
{isPlaying ? 'Stop' : 'Start'} Air Quality Monitor {isPlaying ? 'Stop' : 'Start'} Air Quality Noise
</button> </button>
{#if isPlaying} {#if isPlaying}
@@ -233,6 +289,6 @@
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<AudioVisualization {isPlaying} {analyser} width={400} height={400} /> <AudioVisualization {isPlaying} {analyser} width={vizSize} height={vizSize} />
</div> </div>
</div> </div>

View File

@@ -31,6 +31,15 @@
let isPlaying = $state(false); let isPlaying = $state(false);
let isInitialized = $state(false); let isInitialized = $state(false);
// Detect mobile for visualization sizing
const isMobile = typeof window !== 'undefined' &&
(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth < 768);
const vizSize = isMobile ? 280 : 400;
// Screen Wake Lock to prevent screen from sleeping during playback
let wakeLock: WakeLockSentinel | null = null;
// Audio components // Audio components
let synth: Tone.PolySynth | null = null; let synth: Tone.PolySynth | null = null;
let arpSynth: Tone.Synth | null = null; let arpSynth: Tone.Synth | null = null;
@@ -42,7 +51,6 @@
let reverb: Tone.Reverb | null = null; let reverb: Tone.Reverb | null = null;
let delay: Tone.FeedbackDelay | null = null; let delay: Tone.FeedbackDelay | null = null;
let filter: Tone.Filter | null = null; let filter: Tone.Filter | null = null;
let phaser: Tone.Phaser | null = null;
let sequence: Tone.Sequence | null = null; let sequence: Tone.Sequence | null = null;
let gain: Tone.Gain | null = null; let gain: Tone.Gain | null = null;
let analyser: Tone.Analyser | null = null; let analyser: Tone.Analyser | null = null;
@@ -62,11 +70,9 @@
// Derived reactive values using runes with safe fallbacks // Derived reactive values using runes with safe fallbacks
const bpm = $derived.by(() => { const bpm = $derived.by(() => {
const temp = temperature2m ?? 20; const temp = temperature2m ?? 20;
// BPM starts at 10 for 0°C and increases with temperature // BPM: 5 at 0°C or below, 30 at 30°C or above
// Day: more energetic (2x scaling), Night: calmer (1x scaling) const normalizedTemp = Math.max(0, Math.min(30, temp));
const tempAboveZero = Math.max(0, temp); return 5 + (normalizedTemp / 30) * 25;
const scaledBpm = isDay ? 10 + tempAboveZero * 2 : 10 + tempAboveZero;
return Math.max(10, Math.min(200, scaledBpm));
}); });
const reverbWet = $derived.by(() => { const reverbWet = $derived.by(() => {
@@ -75,10 +81,12 @@
return Math.max(0.3, Math.min(1, humidity / 100)); return Math.max(0.3, Math.min(1, humidity / 100));
}); });
// Delay time: 8th note for calm, quarter note for windy // Delay time: shorter delay for calm, longer for windy (scaled to 0.1-0.8 seconds)
const delayTime = $derived.by(() => { const delayTime = $derived.by(() => {
const speed = windSpeed10m ?? 0; const speed = windSpeed10m ?? 0;
return speed > 5 ? '4n' : '8n'; // Map 0-20 m/s wind to 0.1-0.8 second delay range
const normalized = Math.min(20, speed) / 20;
return 0.1 + normalized * 0.7;
}); });
// Delay feedback: stronger with more wind // Delay feedback: stronger with more wind
@@ -88,11 +96,12 @@
return Math.max(0.2, Math.min(0.7, (speed / 20) * 0.5 + 0.2)); return Math.max(0.2, Math.min(0.7, (speed / 20) * 0.5 + 0.2));
}); });
// Filter cutoff: more clouds = darker/lower frequency // Filter cutoff: colder = darker/lower frequency, warmer = brighter/higher frequency
const filterCutoff = $derived.by(() => { const filterCutoff = $derived.by(() => {
const cover = cloudCover ?? 30; const temp = temperature2m ?? 20;
// Map cloud cover: 0% clouds = 8000Hz (bright), 100% clouds = 400Hz (dark) // Map temperature: 0°C = 400Hz (dark), 30°C = 8000Hz (bright)
return Math.max(400, Math.min(8000, 8000 - (cover / 100) * 7600)); const normalizedTemp = Math.max(0, Math.min(30, temp));
return 400 + (normalizedTemp / 30) * 7600;
}); });
// Filter resonance: more wind = more resonant // Filter resonance: more wind = more resonant
@@ -150,9 +159,42 @@
return '16n'; return '16n';
}); });
// Request wake lock to prevent screen from sleeping
const requestWakeLock = async (): Promise<void> => {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
console.log('Wake Lock is active');
// Handle wake lock release (e.g., when tab is hidden)
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
}
} catch (err) {
console.error('Failed to request wake lock:', err);
}
};
// Release wake lock
const releaseWakeLock = async (): Promise<void> => {
if (wakeLock !== null) {
try {
await wakeLock.release();
wakeLock = null;
console.log('Wake Lock released');
} catch (err) {
console.error('Failed to release wake lock:', err);
}
}
};
// Initialize audio components // Initialize audio components
const initializeAudio = async (): Promise<void> => { const initializeAudio = async (): Promise<void> => {
try { try {
// Optimize audio scheduling for better stability
Tone.getContext().lookAhead = 0.1; // Keep default 100ms lookahead
// Create instruments // Create instruments
synth = createPadSynth(isDay); synth = createPadSynth(isDay);
arpSynth = createArpSynth(arpVolume); arpSynth = createArpSynth(arpVolume);
@@ -167,9 +209,11 @@
analyser = createAnalyser(); analyser = createAnalyser();
// Connect audio chain using .chain() for clarity // Connect audio chain using .chain() for clarity
// Synth gets filtered based on temperature
synth.chain(filter, delay, reverb, gain, analyser, Tone.Destination); synth.chain(filter, delay, reverb, gain, analyser, Tone.Destination);
arpSynth.chain(filter, delay, reverb, gain); // Arpeggios bypass filter - only delay and reverb
pingSynth.chain(filter, delay, reverb, gain); arpSynth.chain(delay, reverb, gain);
pingSynth.chain(delay, reverb, gain);
bassSynth.chain(delay, reverb, gain); bassSynth.chain(delay, reverb, gain);
// Generate reverb impulse // Generate reverb impulse
@@ -195,9 +239,35 @@
sequence = null; sequence = null;
} }
// Set transport BPM // Set all audio parameters once (static until page refresh)
Tone.getTransport().bpm.value = bpm; Tone.getTransport().bpm.value = bpm;
if (reverb) {
reverb.wet.value = reverbWet;
}
if (delay) {
delay.delayTime.value = delayTime;
delay.feedback.value = delayFeedback;
}
if (filter) {
filter.frequency.value = filterCutoff;
filter.Q.value = filterResonance;
}
if (gain) {
gain.gain.value = Tone.dbToGain(volume);
}
if (arpSynth) {
arpSynth.volume.value = arpVolume;
}
if (pingSynth) {
pingSynth.volume.value = pingVolume;
}
sequence = new Tone.Sequence( sequence = new Tone.Sequence(
(time: number, chord) => { (time: number, chord) => {
if (synth && chord) { if (synth && chord) {
@@ -219,10 +289,13 @@
arpSequence = new Tone.Sequence( arpSequence = new Tone.Sequence(
(time: number, chord) => { (time: number, chord) => {
if (arpSynth && chord && chord.notes) { 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 // Play arpeggio pattern through the chord notes
chord.notes.forEach((note: string, index: number) => { chord.notes.forEach((note: string, index: number) => {
const noteTime = time + index * 0.15; // 150ms between notes synth.triggerAttackRelease(note, '16n', time + index * sixteenthNote);
arpSynth!.triggerAttackRelease(note, '16n', noteTime);
}); });
} }
}, },
@@ -281,13 +354,12 @@
const octave = parseInt(rootNote.slice(-1)); // e.g., 4 from 'C4' const octave = parseInt(rootNote.slice(-1)); // e.g., 4 from 'C4'
const bassNote = noteName + (octave - 2); // e.g., 'C2' const bassNote = noteName + (octave - 2); // e.g., 'C2'
// Randomize release time: half to full chord duration // Randomize note duration: half to full chord duration (2n to 4n)
// Quarter note = 1 beat, so random between 0.5 and 1.0 beats const randomDuration = 0.5 + Math.random() * 0.5;
const randomRelease = 0.5 + Math.random() * 0.5; const noteDuration = Tone.Time('4n').toSeconds() * randomDuration;
bassSynth.envelope.release = randomRelease * (60 / bpm);
// Trigger bass note // Trigger bass note at the scheduled time
bassSynth.triggerAttackRelease(bassNote, '4n', time); bassSynth.triggerAttackRelease(bassNote, noteDuration, time);
} }
}, },
currentProgression, currentProgression,
@@ -297,6 +369,10 @@
bassSequence.start(0); bassSequence.start(0);
Tone.getTransport().start(); Tone.getTransport().start();
// Request wake lock to prevent screen sleep
await requestWakeLock();
isPlaying = true; isPlaying = true;
} catch (error) { } catch (error) {
console.error('Error starting sequence:', error); console.error('Error starting sequence:', error);
@@ -304,7 +380,7 @@
}; };
// Stop the sequence // Stop the sequence
const stopSequence = (): void => { const stopSequence = async (): Promise<void> => {
if (sequence) { if (sequence) {
sequence.stop(); sequence.stop();
sequence.dispose(); sequence.dispose();
@@ -328,6 +404,9 @@
Tone.getTransport().stop(); Tone.getTransport().stop();
Tone.getTransport().cancel(); Tone.getTransport().cancel();
isPlaying = false; isPlaying = false;
// Release wake lock when stopping
await releaseWakeLock();
}; };
// Toggle playback // Toggle playback
@@ -339,57 +418,41 @@
} }
}; };
// Reactive updates for environmental parameters using effects // Audio parameters are set once on playback start and remain static
$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 // Lifecycle
onMount(() => { onMount(() => {
initializeAudio(); initializeAudio();
// Handle page visibility changes (screen sleep/wake, tab switching)
const handleVisibilityChange = async () => {
if (!document.hidden && isPlaying) {
// Page is visible again - resume audio context if suspended
if (Tone.getContext().state === 'suspended') {
await Tone.getContext().resume();
}
// Restart transport if it stopped
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
// Re-request wake lock if it was released
if (wakeLock === null) {
await requestWakeLock();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}); });
onDestroy(() => { onDestroy(async () => {
// Release wake lock on component destroy
await releaseWakeLock();
if (sequence) { if (sequence) {
sequence.dispose(); sequence.dispose();
} }
@@ -432,10 +495,10 @@
}); });
</script> </script>
<div class="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 p-4 max-w-6xl mx-auto"> <div class="mx-auto grid max-w-6xl grid-cols-1 gap-8 p-4 md:grid-cols-[300px_1fr]">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<button <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 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-white text-black'
: 'bg-transparent text-white'}" : 'bg-transparent text-white'}"
onclick={togglePlayback} onclick={togglePlayback}
@@ -450,17 +513,17 @@
<p class="m-0">Humidity: {relativeHumidity2m}%</p> <p class="m-0">Humidity: {relativeHumidity2m}%</p>
<p class="m-0">Cloud Cover: {cloudCover}%</p> <p class="m-0">Cloud Cover: {cloudCover}%</p>
<p class="m-0">Wind Speed: {windSpeed10m.toFixed(1)} m/s</p> <p class="m-0">Wind Speed: {windSpeed10m.toFixed(1)} m/s</p>
<p class="opacity-40 my-1">---</p> <p class="my-1 opacity-40">---</p>
<p class="m-0">BPM: {bpm}</p> <p class="m-0">BPM: {bpm.toFixed(2)}</p>
<p class="m-0">Weather Extremity: {(weatherExtremity * 100).toFixed(0)}%</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">Reverb: {reverbWet.toFixed(2)}</p>
<p class="m-0">Delay: {delayTime} @ {delayFeedback.toFixed(2)} feedback</p> <p class="m-0">Delay: {delayTime.toFixed(2)}s @ {delayFeedback.toFixed(2)} feedback</p>
<p class="m-0">Filter: {Math.round(filterCutoff)}Hz Q:{filterResonance.toFixed(1)}</p> <p class="m-0">Filter: {Math.round(filterCutoff)}Hz Q:{filterResonance.toFixed(1)}</p>
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<AudioVisualization {isPlaying} {analyser} width={400} height={400} /> <AudioVisualization {isPlaying} {analyser} width={vizSize} height={vizSize} />
</div> </div>
</div> </div>

3
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,3 @@
export const prerender = true;
export const ssr = false;
export const csr = true;

View File

@@ -8,6 +8,11 @@
let position: GeolocationPosition | undefined = $state(undefined); let position: GeolocationPosition | undefined = $state(undefined);
let error: GeolocationError | undefined = $state(undefined); let error: GeolocationError | undefined = $state(undefined);
// Manual coordinate entry
let manualLat: string = $state('');
let manualLong: string = $state('');
let manualError: string = $state('');
let options = { let options = {
enableHighAccuracy: false, // Use WiFi/network location (faster than GPS) enableHighAccuracy: false, // Use WiFi/network location (faster than GPS)
timeout: 10000, // milliseconds - increased for better reliability timeout: 10000, // milliseconds - increased for better reliability
@@ -23,22 +28,101 @@
function goToPlayer(): void { function goToPlayer(): void {
goto(`/on-out?lat=${position?.coords.latitude}&long=${position?.coords.longitude}`); goto(`/on-out?lat=${position?.coords.latitude}&long=${position?.coords.longitude}`);
} }
function goWithManualCoords(): void {
// Validate coordinates
const lat = parseFloat(manualLat);
const long = parseFloat(manualLong);
if (isNaN(lat) || isNaN(long)) {
manualError = 'Please enter valid numbers';
return;
}
if (lat < -90 || lat > 90) {
manualError = 'Latitude must be between -90 and 90';
return;
}
if (long < -180 || long > 180) {
manualError = 'Longitude must be between -180 and 180';
return;
}
manualError = '';
goto(`/on-out?lat=${lat}&long=${long}`);
}
</script> </script>
<div class="flex min-h-screen flex-col items-center justify-center"> <div class="flex min-h-screen flex-col items-center justify-center gap-8 px-4">
{#if !getPosition} {#if !getPosition}
<button class="animate-pulse cursor-crosshair text-xl hover:text-blue-400 transition-colors" onclick={flipGetPosition}> <button class="animate-pulse cursor-crosshair text-xl hover:opacity-70 transition-opacity" onclick={flipGetPosition}>
Let me show you... Let me find you...
</button> </button>
<p class="text-base opacity-60">OR</p>
<div class="flex flex-col gap-4 w-full max-w-xs">
<input
type="text"
placeholder="Latitude (e.g., 40.7128)"
bind:value={manualLat}
class="px-4 py-2 bg-transparent border border-white/20 rounded-md text-center focus:border-white/40 focus:outline-none transition-colors"
/>
<input
type="text"
placeholder="Longitude (e.g., -74.0060)"
bind:value={manualLong}
class="px-4 py-2 bg-transparent border border-white/20 rounded-md text-center focus:border-white/40 focus:outline-none transition-colors"
/>
{#if manualError}
<p class="text-sm opacity-60 text-center">{manualError}</p>
{/if}
<button
class="px-6 py-2 cursor-crosshair border border-white/20 rounded-md hover:border-white/40 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onclick={goWithManualCoords}
disabled={!manualLat || !manualLong}
>
Go
</button>
</div>
{:else if loading} {:else if loading}
<p class="text-xl">Loading...</p> <p class="text-xl">Loading...</p>
{:else if error} {:else if error}
<p class="text-xl text-red-400">We can't seem to find you.</p> <div class="flex flex-col gap-6 items-center">
<p class="text-xl opacity-60">We can't seem to find you.</p>
<p class="text-base opacity-60">Try entering coordinates manually:</p>
<div class="flex flex-col gap-4 w-full max-w-xs">
<input
type="text"
placeholder="Latitude (e.g., 40.7128)"
bind:value={manualLat}
class="px-4 py-2 bg-transparent border border-white/20 rounded-md text-center focus:border-white/40 focus:outline-none transition-colors"
/>
<input
type="text"
placeholder="Longitude (e.g., -74.0060)"
bind:value={manualLong}
class="px-4 py-2 bg-transparent border border-white/20 rounded-md text-center focus:border-white/40 focus:outline-none transition-colors"
/>
{#if manualError}
<p class="text-sm opacity-60 text-center">{manualError}</p>
{/if}
<button
class="px-6 py-2 cursor-crosshair border border-white/20 rounded-md hover:border-white/40 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onclick={goWithManualCoords}
disabled={!manualLat || !manualLong}
>
Go
</button>
</div>
</div>
{:else} {:else}
<p class="mb-5 text-xl text-center px-4"> <p class="mb-5 text-xl text-center px-4">
Your Position is set as: {position?.coords?.latitude}, {position?.coords?.longitude} Your Position is set as: {position?.coords?.latitude}, {position?.coords?.longitude}
</p> </p>
<button class="animate-pulse cursor-crosshair text-lg hover:text-blue-400 transition-colors" onclick={goToPlayer} <button class="animate-pulse cursor-crosshair text-lg hover:opacity-70 transition-opacity" onclick={goToPlayer}
>Ok, on-out with it</button >Ok, on-out with it</button
> >
{/if} {/if}

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

BIN
static/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -1,12 +1,16 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = { const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() }, kit: {
adapter: adapter({
fallback: 'index.html'
})
},
optimizeDeps: { optimizeDeps: {
exclude: ['geolocation'] exclude: ['geolocation']
}, },
}; };
export default config; export default config;