529 lines
15 KiB
Svelte
529 lines
15 KiB
Svelte
<script lang="ts">
|
|
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,
|
|
precipitation = 0,
|
|
volume = -10,
|
|
windSpeed10m = 0,
|
|
isDay
|
|
} = $props();
|
|
|
|
// Component state using runes
|
|
let isPlaying = $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
|
|
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 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
|
|
});
|
|
});
|
|
|
|
// Derived reactive values using runes with safe fallbacks
|
|
const bpm = $derived.by(() => {
|
|
const temp = temperature2m ?? 20;
|
|
// BPM: 5 at 0°C or below, 30 at 30°C or above
|
|
const normalizedTemp = Math.max(0, Math.min(30, temp));
|
|
return 5 + (normalizedTemp / 30) * 25;
|
|
});
|
|
|
|
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));
|
|
});
|
|
|
|
// Delay time: 8th note for calm, quarter note for windy
|
|
const delayTime = $derived.by(() => {
|
|
const speed = windSpeed10m ?? 0;
|
|
return speed > 5 ? '4n' : '8n';
|
|
});
|
|
|
|
// 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));
|
|
});
|
|
|
|
// Filter cutoff: colder = darker/lower frequency, warmer = brighter/higher frequency
|
|
const filterCutoff = $derived.by(() => {
|
|
const temp = temperature2m ?? 20;
|
|
// Map temperature: 0°C = 400Hz (dark), 30°C = 8000Hz (bright)
|
|
const normalizedTemp = Math.max(0, Math.min(30, temp));
|
|
return 400 + (normalizedTemp / 30) * 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));
|
|
});
|
|
|
|
// 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';
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
|
|
// 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
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
|
|
// 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';
|
|
});
|
|
|
|
// 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
|
|
const initializeAudio = async (): Promise<void> => {
|
|
try {
|
|
// Optimize audio scheduling for better stability
|
|
Tone.getContext().lookAhead = 0.1; // Keep default 100ms lookahead
|
|
|
|
// Create instruments
|
|
synth = createPadSynth(isDay);
|
|
arpSynth = createArpSynth(arpVolume);
|
|
pingSynth = createPingSynth(pingVolume);
|
|
bassSynth = createBassSynth();
|
|
|
|
// Create effects
|
|
reverb = createReverb(reverbWet);
|
|
delay = createDelay(delayTime, delayFeedback);
|
|
filter = createFilter(filterCutoff, filterResonance);
|
|
gain = createGain(volume);
|
|
analyser = createAnalyser();
|
|
|
|
// Connect audio chain using .chain() for clarity
|
|
// Synth gets filtered based on temperature
|
|
synth.chain(filter, delay, reverb, gain, analyser, Tone.Destination);
|
|
// Arpeggios bypass filter - only delay and reverb
|
|
arpSynth.chain(delay, reverb, gain);
|
|
pingSynth.chain(delay, reverb, gain);
|
|
bassSynth.chain(delay, reverb, gain);
|
|
|
|
// Generate reverb impulse
|
|
await reverb.generate();
|
|
|
|
isInitialized = true;
|
|
} catch (error) {
|
|
console.error('Failed to initialize audio:', error);
|
|
}
|
|
};
|
|
|
|
// Start the chord sequence
|
|
const startSequence = async (): Promise<void> => {
|
|
try {
|
|
if (!isInitialized) {
|
|
await initializeAudio();
|
|
}
|
|
|
|
await Tone.start();
|
|
|
|
if (sequence) {
|
|
sequence.dispose();
|
|
sequence = null;
|
|
}
|
|
|
|
// Set all audio parameters once (static until page refresh)
|
|
Tone.getTransport().bpm.value = bpm;
|
|
|
|
if (reverb) {
|
|
reverb.wet.value = reverbWet;
|
|
}
|
|
|
|
if (delay) {
|
|
const delayTimeSeconds = Tone.Time(delayTime).toSeconds();
|
|
delay.delayTime.value = delayTimeSeconds;
|
|
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(
|
|
(time: number, chord) => {
|
|
if (synth && chord) {
|
|
synth!.triggerAttackRelease(chord.notes, '4n', time);
|
|
}
|
|
},
|
|
currentProgression,
|
|
'4n'
|
|
);
|
|
|
|
sequence.start(0);
|
|
|
|
// Create arpeggio sequence
|
|
if (arpSequence) {
|
|
arpSequence.dispose();
|
|
arpSequence = null;
|
|
}
|
|
|
|
arpSequence = new Tone.Sequence(
|
|
(time: number, chord) => {
|
|
if (arpSynth && chord && chord.notes) {
|
|
// Capture synth reference for TypeScript
|
|
const synth = arpSynth;
|
|
// Calculate time between notes based on 16th notes
|
|
const sixteenthNote = Tone.Time('16n').toSeconds();
|
|
// Play arpeggio pattern through the chord notes
|
|
chord.notes.forEach((note: string, index: number) => {
|
|
synth.triggerAttackRelease(note, '16n', time + index * sixteenthNote);
|
|
});
|
|
}
|
|
},
|
|
currentProgression,
|
|
arpInterval
|
|
);
|
|
|
|
arpSequence.start(0);
|
|
|
|
// Create ping sequence (reverse arpeggio - evenly spaced through chord duration)
|
|
if (pingSequence) {
|
|
pingSequence.dispose();
|
|
pingSequence = null;
|
|
}
|
|
|
|
// Build a flat array of notes: 4 notes per chord, in reverse order, transposed up 2 octaves
|
|
const pingNotes: string[] = [];
|
|
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 note duration: half to full chord duration (2n to 4n)
|
|
const randomDuration = 0.5 + Math.random() * 0.5;
|
|
const noteDuration = Tone.Time('4n').toSeconds() * randomDuration;
|
|
|
|
// Trigger bass note at the scheduled time
|
|
bassSynth.triggerAttackRelease(bassNote, noteDuration, time);
|
|
}
|
|
},
|
|
currentProgression,
|
|
'4n'
|
|
);
|
|
|
|
bassSequence.start(0);
|
|
|
|
Tone.getTransport().start();
|
|
|
|
// Request wake lock to prevent screen sleep
|
|
await requestWakeLock();
|
|
|
|
isPlaying = true;
|
|
} catch (error) {
|
|
console.error('Error starting sequence:', error);
|
|
}
|
|
};
|
|
|
|
// Stop the sequence
|
|
const stopSequence = async (): Promise<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;
|
|
|
|
// Release wake lock when stopping
|
|
await releaseWakeLock();
|
|
};
|
|
|
|
// Toggle playback
|
|
const togglePlayback = async (): Promise<void> => {
|
|
if (isPlaying) {
|
|
stopSequence();
|
|
} else {
|
|
await startSequence();
|
|
}
|
|
};
|
|
|
|
// Audio parameters are set once on playback start and remain static
|
|
|
|
// Lifecycle
|
|
onMount(() => {
|
|
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(async () => {
|
|
// Release wake lock on component destroy
|
|
await releaseWakeLock();
|
|
|
|
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="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">
|
|
<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' : 'Play'} Weather Ambient
|
|
</button>
|
|
|
|
{#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="my-1 opacity-40">---</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">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={vizSize} height={vizSize} />
|
|
</div>
|
|
</div>
|