Last episode we turned the lens inward -- personal data art, mood journals, sleep trackers, custom visual encodings, Dear Data postcards, the privacy tension between showing and concealing. The quantified self as creative material. Every technique from the data arc showed up: the map() function from episode 82, calendar layouts from episode 84, correlation scatter plots, circular portraits. Personal data turned out to be the most intimate form of data art because the subject is you.
But we've been working exclusively with vision. Every episode in this arc has turned data into something you see -- colors, shapes, positions, sizes, opacities. Our eyes are incredible pattern detectors, sure. But they're not the only sense we have. Your ears are pattern detectors too. And for certain kinds of data -- especially temporal patterns and anomalies -- sound actually works better than visuals. You can hear a rhythm shift faster than you can see one. You can detect a wrong note in a chord instantly. You can track multiple audio streams simultaneously (try following four visual streams at once -- it's a mess). Sound has properties that vision doesn't, and data art that ignores half your senses is leaving creative potential on the table.
This episode is about sonification: mapping data to auditory properties instead of visual ones. Pitch, rhythm, volume, timbre, stereo position, harmony. We'll use the Web Audio API and Tone.js to turn numbers into notes, data sequences into melodies, and multi-dimensional datasets into layered sonic textures. We touched on audio back in episode 19 (sound-reactive visuals) and episode 72 (3D audio visualization) -- but those went from sound to visuals. Now we're going the other direction: data to sound. And at the end, we'll combine both for an audiovisual data piece where the same data drives what you see AND what you hear simultaneously.
Let me give you a concrete example. Say you have a year of daily temperature data for your city. As a line chart (episode 84), you see the seasonal wave -- summer peak, winter dip. Nice. Now sonify it: one note per day, pitch mapped to temperature, played back in 30 seconds. Low notes in winter, high notes in summer. You hear the seasons. The ascending pitch in spring, the sustained high in July and August, the descent into autumn. And here's the thing -- you hear patterns the line chart doesn't show easily. A cold snap in May becomes a sudden drop in pitch that your ear catches instantly. A gradual warming trend over years becomes a barely perceptible upward drift in the baseline pitch that your ear tracks subconsiously even when your eye would need a trend line overlay to spot it.
Sound excels at temporal patterns because sound IS temporal. A visual pattern exists all at once on the canvas -- you can look at any part of it at any time. A sonic pattern unfolds over time, and your brain is wired to process it sequentially. That sequential processing makes sound ideal for data where order matters: time series, sequences, events.
Sound also excels at anomaly detection. Play a repeating pattern of notes and insert one wrong note -- you hear it immediately. Your auditory cortex is extremely sensitive to violations of expected patterns. The cocktail party effect proves we can track multiple audio streams simultaneously and switch attention between them. Try doing that with four overlapping line charts.
The Web Audio API is built into every modern browser. No libraries needed. You create an AudioContext, build a graph of audio nodes (oscillators, gains, filters), connect them together, and start them.
// basic oscillator: a single tone
const audioCtx = new AudioContext();
function playTone(frequency, duration) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, audioCtx.currentTime);
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + duration);
}
// play a 440 Hz tone for half a second
playTone(440, 0.5);
An oscillator generates a waveform. A gain node controls volume. Connect them together, connect to destination (your speakers), and you've got sound. The exponentialRampToValueAtTime fades the note out so it doesn't click at the end -- always fade out, never stop a sound abruptly or you'll get a harsh pop.
The four basic waveforms give you different timbres: 'sine' is pure and smooth, 'triangle' is softer, 'square' is hollow and digital, 'sawtooth' is buzzy and aggressive. Each one gives data a different emotional quality. Sine for calm, neutral data. Sawtooth for urgent, alarming data. The timbre choice is a creative decision, just like choosing a color palette.
Mapping data values to pitch is the sonic equivalent of mapping data to position on the Y axis. High value = high pitch, low value = low pitch. Humans are naturally good at perceiving relative pitch -- we can tell "this note is higher than that note" effortlessly.
The trick is quantizing to a musical scale. If you map linearly from data range to frequency range, the result sounds random and atonal -- which might be what you want artistically, but usually isn't. Quantizing to a pentatonic scale (5 notes per octave: C, D, E, G, A) makes almost everything sound musical, because the pentatonic scale has no dissonant intervals. You literally can't play a wrong note.
const audioCtx = new AudioContext();
// pentatonic scale frequencies (C4, D4, E4, G4, A4, C5, D5, E5, G5, A5, C6)
// spanning two octaves
const pentatonic = [
261.63, 293.66, 329.63, 392.00, 440.00,
523.25, 587.33, 659.25, 783.99, 880.00,
1046.50
];
function dataToNote(value, dataMin, dataMax) {
// map data value to index in scale
const normalized = (value - dataMin) / (dataMax - dataMin);
const index = Math.round(normalized * (pentatonic.length - 1));
return pentatonic[Math.max(0, Math.min(pentatonic.length - 1, index))];
}
function playNote(freq, startTime, duration) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, startTime);
gain.gain.setValueAtTime(0.2, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(startTime);
osc.stop(startTime + duration + 0.05);
}
// sonify a data array
const data = [3, 5, 7, 2, 8, 6, 9, 4, 7, 5, 3, 6, 8, 10, 7, 4, 2, 5, 6, 8];
const min = Math.min(...data);
const max = Math.max(...data);
let time = audioCtx.currentTime + 0.1;
const noteDuration = 0.25;
for (const value of data) {
const freq = dataToNote(value, min, max);
playNote(freq, time, noteDuration);
time += noteDuration;
}
Twenty data points become twenty notes in a pentatonic scale. Values of 10 hit the highest note (C6), values of 2 hit the lowest (C4). The result sounds like a melody -- not a composed melody, but a data-driven one where every pitch carries informaton. Run this with your actual data -- temperature readings, stock prices, step counts -- and you'll hear the shape of the data as a musical phrase.
The pentatonic scale is training wheels. A chromatic scale (all 12 notes per octave) gives finer resolution but sounds harsher. A major scale sounds happy. A minor scale sounds melancholic. The scale choice colors the emotional feel of the sonification, just like a color palette colors a visualization. Same data, different scale, different mood.
Pitch handles the value of each data point. Rhythm handles the timing. In a time series, the spacing between data points might be uniform (one reading per hour) or irregular (events that happen when they happen). Mapping those timings to note durations creates rhythmic patterns that reflect the temporal structure of the data.
const audioCtx = new AudioContext();
// events with timestamps (in seconds from start) and magnitudes
const events = [
{ time: 0.0, magnitude: 5 },
{ time: 0.3, magnitude: 7 },
{ time: 0.5, magnitude: 3 },
{ time: 1.2, magnitude: 8 },
{ time: 1.3, magnitude: 9 },
{ time: 1.35, magnitude: 6 },
{ time: 2.0, magnitude: 4 },
{ time: 3.5, magnitude: 7 },
{ time: 3.8, magnitude: 5 },
{ time: 4.0, magnitude: 8 }
];
const pentatonic = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25, 587.33, 659.25];
function playEvent(freq, startTime, duration, volume) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, startTime);
gain.gain.setValueAtTime(volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(startTime);
osc.stop(startTime + duration + 0.05);
}
const baseTime = audioCtx.currentTime + 0.1;
for (const evt of events) {
const pitchIndex = Math.round((evt.magnitude / 10) * (pentatonic.length - 1));
const freq = pentatonic[pitchIndex];
const vol = 0.1 + (evt.magnitude / 10) * 0.25;
playEvent(freq, baseTime + evt.time, 0.3, vol);
}
Notice the burst around 1.2-1.35 seconds -- three events in quick succession, high magnitude. You hear it as a rapid cluster of high, loud notes. The gap between 2.0 and 3.5 seconds is silence -- a rest in the rhythm. The temporal structure of the data becomes musical structure. Fast events = fast rhythm. Gaps = rests. Clusters = bursts. The rhythm tells you about the data's temporal density without you needing to look at anything.
Pitch and rhythm handle two data dimensions. Volume handles a third -- map magnitude or importance to loudness. Quiet notes for background data, loud notes for anomalies. Your ear naturally focuses on the loudest thing, so the spikes in the data literally grab your atention.
Timbre adds a fourth dimension. Different waveforms for different categories. Sine waves for one data source, sawtooth for another. You can distinguish them by ear the way you distinguish colors by eye.
const audioCtx = new AudioContext();
// two data streams: temperature and humidity
const temperature = [18, 20, 22, 25, 28, 30, 29, 27, 24, 21];
const humidity = [65, 60, 55, 50, 45, 42, 48, 55, 60, 68];
const scale = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25];
function playStream(data, waveType, panValue, dataMin, dataMax, baseTime) {
const panner = audioCtx.createStereoPanner();
panner.pan.setValueAtTime(panValue, baseTime);
panner.connect(audioCtx.destination);
for (let i = 0; i < data.length; i++) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = waveType;
const norm = (data[i] - dataMin) / (dataMax - dataMin);
const idx = Math.round(norm * (scale.length - 1));
osc.frequency.setValueAtTime(scale[idx], baseTime + i * 0.4);
gain.gain.setValueAtTime(0.15, baseTime + i * 0.4);
gain.gain.exponentialRampToValueAtTime(0.001, baseTime + i * 0.4 + 0.35);
osc.connect(gain);
gain.connect(panner);
osc.start(baseTime + i * 0.4);
osc.stop(baseTime + i * 0.4 + 0.4);
}
}
const t = audioCtx.currentTime + 0.1;
// temperature: triangle wave, panned left
playStream(temperature, 'triangle', -0.6, 15, 35, t);
// humidity: sine wave, panned right
playStream(humidity, 'sine', 0.6, 30, 80, t);
Two streams playing simultaneously. Temperature as triangle waves panned to the left ear. Humidity as sine waves panned to the right. You can follow each stream independently (cocktail party effect) and also hear how they relate -- when temperature rises, humidity drops. The inverse correlation becomes audible as contrary motion: one stream climbing in pitch while the other descends. That's the kind of pattern that's hard to see when two line charts overlap but easy to hear when two audio streams play together.
Stereo panning adds a spatial dimension too. You could map geographic east/west to left/right audio channels. Or map two competing metrics to opposite ears so you literally hear the tension between them.
The raw Web Audio API works but it's verbose. Tone.js wraps it with musical abstractions -- synths, sequences, transport controls, effects. For sonification projects, Tone.js lets you focus on the mapping instead of audio plumbing.
// using Tone.js (include via CDN or npm)
//
const synth = new Tone.Synth({
oscillator: { type: 'triangle' },
envelope: { attack: 0.02, decay: 0.2, sustain: 0.1, release: 0.3 }
}).toDestination();
// data as musical notes
const data = [3, 5, 8, 4, 7, 9, 6, 2, 5, 7, 8, 10, 6, 3];
// map data to note names in C major
const notes = ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5', 'D5', 'E5'];
function dataToNoteName(value, min, max) {
const norm = (value - min) / (max - min);
const idx = Math.round(norm * (notes.length - 1));
return notes[Math.max(0, Math.min(notes.length - 1, idx))];
}
const min = Math.min(...data);
const max = Math.max(...data);
const noteNames = data.map(v => dataToNoteName(v, min, max));
// play as a sequence
const seq = new Tone.Sequence(function(time, note) {
synth.triggerAttackRelease(note, '8n', time);
}, noteNames, '4n');
Tone.Transport.start();
seq.start(0);
Tone.Sequence handles timing. triggerAttackRelease plays a note with proper envelope. The note names ('C4', 'E5') are more readable than raw frequencies. And if you want to change the tempo, just set Tone.Transport.bpm.value = 120. The musical abstractions make sonification code read almost like a score.
Raw oscillators sound clinical. Real instruments have resonance, room acoustics, harmonic richness. Adding a reverb effect gives sonified data a sense of space, and a low-pass filter smooths harsh frequencies. Both improve the listening experience for sustained playback.
const audioCtx = new AudioContext();
// create a convolution reverb from white noise
function createReverb(duration, decay) {
const sampleRate = audioCtx.sampleRate;
const length = sampleRate * duration;
const impulse = audioCtx.createBuffer(2, length, sampleRate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
}
}
const reverb = audioCtx.createConvolver();
reverb.buffer = impulse;
return reverb;
}
// low-pass filter to soften the sound
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2000, audioCtx.currentTime);
const reverb = createReverb(2, 3);
const dryGain = audioCtx.createGain();
const wetGain = audioCtx.createGain();
dryGain.gain.value = 0.7;
wetGain.gain.value = 0.3;
// signal chain: oscillator -> filter -> dry/wet split -> destination
filter.connect(dryGain);
filter.connect(reverb);
reverb.connect(wetGain);
dryGain.connect(audioCtx.destination);
wetGain.connect(audioCtx.destination);
function playFilteredNote(freq, startTime, duration) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, startTime);
gain.gain.setValueAtTime(0.2, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(filter);
osc.start(startTime);
osc.stop(startTime + duration + 0.1);
}
// test: play a few notes with reverb
const testNotes = [261.63, 329.63, 392.00, 523.25, 659.25];
let t = audioCtx.currentTime + 0.1;
for (const freq of testNotes) {
playFilteredNote(freq, t, 0.4);
t += 0.5;
}
The difference is dramatic. Without reverb and filtering, sonified data sounds like a test tone generator. With them, it sounds like music in a room. The reverb tail lets notes bleed into each other slightly, creating continuity between data points. The low-pass filter removes harsh high frequencies that fatigue the ear during long playback sessions. These are small additions codewise but they transform the listening experience.
You can map the filter cutoff to a data dimension too. High data values open the filter (bright sound), low values close it (muffled sound). Now you've got pitch, rhythm, volume, AND timbral brightness all encoding different data channels. Four dimensions of sound from four dimensions of data.
When you have multiple simultaneous data values, you can play them as a chord. Values that are proportionally related create consonant harmonies. Values that diverge create dissonance. The quality of the chord -- pleasant vs tense -- becomes a direct encoding of how well your data dimensions align.
const audioCtx = new AudioContext();
// three metrics at five time points
const metrics = [
{ a: 5, b: 5, c: 5 }, // aligned
{ a: 6, b: 5, c: 7 }, // slightly divergent
{ a: 8, b: 3, c: 9 }, // very divergent
{ a: 6, b: 6, c: 5 }, // reconverging
{ a: 5, b: 5, c: 5 } // aligned again
];
// map values to frequencies in a harmonic series
function valueToFreq(v) {
return 200 + v * 40; // range: 200-600 Hz
}
function playChord(freqs, startTime, duration) {
for (const freq of freqs) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, startTime);
gain.gain.setValueAtTime(0.12, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(startTime);
osc.stop(startTime + duration + 0.05);
}
}
let time = audioCtx.currentTime + 0.1;
for (const m of metrics) {
const freqs = [valueToFreq(m.a), valueToFreq(m.b), valueToFreq(m.c)];
playChord(freqs, time, 0.8);
time += 1.0;
}
The first chord is consonant -- all three values are 5, so all three frequencies are the same (400 Hz). Boring but harmonious. The third chord is dissonant -- values of 8, 3, and 9 produce frequencies of 520, 320, and 560 Hz, a cluster with no clean harmonic relationship. It sounds tense. The tension in the chord IS the divergence in the data. You don't need to look at numbers. You hear it.
This is powerful for monitoring. Three server metrics (CPU, memory, network) normally move together -- the chord sounds clean. When one metric spikes while others drop, the chord turns dissonant. A sysadmin wearing headphones would hear the anomaly before their dashboard even refreshes.
Here's where it gets really interesting. Take the same data and drive both visuals and audio from it simultaneously. Each data point is both drawn and played. The two modalities reinforce each other -- you see the spike AND hear it at the same time. Pattern recognition improves when two senses agree.
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const audioCtx = new AudioContext();
const pentatonic = [261.63, 329.63, 392.00, 523.25, 659.25, 783.99];
// a year of daily temperature
const temps = [];
for (let d = 0; d < 365; d++) {
const seasonal = Math.sin((d / 365) * Math.PI * 2 - Math.PI / 2) * 12;
const noise = (Math.random() - 0.5) * 6;
temps.push(15 + seasonal + noise);
}
const tempMin = Math.min(...temps);
const tempMax = Math.max(...temps);
// draw all data as background
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);
for (let i = 0; i < temps.length; i++) {
const x = (i / 364) * 760 + 20;
const norm = (temps[i] - tempMin) / (tempMax - tempMin);
const y = 380 - norm * 340;
const hue = norm * 40 + (1 - norm) * 220;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 50%, 45%, 0.4)`;
ctx.fill();
}
// playback: step through data points over time
let playIndex = 0;
const playInterval = 80; // ms between notes -- 365 days in ~30 seconds
function playStep() {
if (playIndex >= temps.length) return;
const value = temps[playIndex];
const norm = (value - tempMin) / (tempMax - tempMin);
// sound: pitch from temperature
const noteIdx = Math.round(norm * (pentatonic.length - 1));
const freq = pentatonic[noteIdx];
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.2);
// visual: highlight current point
const x = (playIndex / 364) * 760 + 20;
const y = 380 - norm * 340;
const hue = norm * 40 + (1 - norm) * 220;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 70%, 60%, 0.9)`;
ctx.fill();
// playhead line
ctx.fillStyle = 'rgba(255, 255, 255, 0.03)';
ctx.fillRect(x, 0, 2, 400);
playIndex++;
setTimeout(playStep, playInterval);
}
// click to start (browsers require user gesture for audio)
canvas.addEventListener('click', function() {
audioCtx.resume().then(function() {
playStep();
});
});
A year of temperature data plotted as a scatter. Click, and a playhead sweeps left to right. Each day produces a note -- low pitch in winter (blue dots), high pitch in summer (warm dots). The moving highlight and the sweeping sound lock together. You see the seasonal curve AND hear it as a melody that rises through spring, sustains through summer, and descends through autumn. A cold snap in April would show as a sudden cluster of blue dots and you'd hear it as a jarring low note interrupting the ascending spring melody. Both senses catch it. Together they're stronger than either one alone.
This is the audiovisual sonification pattern. Same data, two outputs, synchronized. The visual gives overview (you can see the whole year at once). The sound gives sequential detail (you hear each day in order). They complement each other perfectly.
Sonification isn't just an artistic technique. It's an accessibility technique. Visually impaired users can't see your scatter plots and heatmaps. But they can hear a sonified dataset and extract the same patterns. A screen reader can describe "temperatures range from -3 to 32 degrees" but it can't convey the shape -- the seasonal wave, the variance, the outliers. Sonification can.
Even for sighted audiences, sonification has practical advantages. You can listen to data while looking at something else. A background audio stream monitoring server health frees your eyes for other work -- you'll hear the dissonant chord when something goes wrong. Airline pilots already use sonification: the stall warning horn, the altitude callout, the terrain alert. Critical patterns encoded in sound because eyes are busy flying the plane.
For creative coding, the accessibility angle adds meaning to the practice. You're not just making art -- you're making data accessible in a modality that most dataviz completely ignores.
Allez, time to build the complete thing. Take 365 days of temperature data. Sonify it: one note per day, pitch from temperature (cold = low, hot = high), note duration from daylight hours (short winter days = short notes, long summer days = long notes). Play the year in about 60 seconds. Add a synchronized visual -- a simple temperature line graph with a moving playhead.
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const audioCtx = new AudioContext();
// generate a year of temperature + daylight data
const year = [];
for (let d = 0; d < 365; d++) {
const t = d / 365;
const temp = 12 + Math.sin(t * Math.PI * 2 - Math.PI / 2) * 14 + (Math.random() - 0.5) * 5;
// daylight hours: ~8h winter, ~16h summer (northern hemisphere)
const daylight = 12 + Math.sin(t * Math.PI * 2 - Math.PI / 2) * 4;
year.push({ temp, daylight });
}
const tempMin = Math.min(...year.map(d => d.temp));
const tempMax = Math.max(...year.map(d => d.temp));
// pentatonic scale spanning 3 octaves
const scale = [
196.00, 220.00, 261.63, 293.66, 329.63,
392.00, 440.00, 523.25, 587.33, 659.25,
783.99, 880.00, 1046.50
];
// draw background graph
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);
ctx.beginPath();
for (let i = 0; i < 365; i++) {
const x = (i / 364) * 760 + 20;
const norm = (year[i].temp - tempMin) / (tempMax - tempMin);
const y = 370 - norm * 330;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = 'rgba(100, 150, 200, 0.3)';
ctx.lineWidth = 1;
ctx.stroke();
// month labels
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(100, 110, 130, 0.3)';
for (let m = 0; m < 12; m++) {
const x = 20 + (m / 12) * 760 + 30;
ctx.fillText(months[m], x, 395);
}
let idx = 0;
const noteGap = 160; // ms between notes, ~58 seconds for the year
function playDay() {
if (idx >= 365) return;
const d = year[idx];
const tempNorm = (d.temp - tempMin) / (tempMax - tempMin);
// pitch from temperature
const noteIdx = Math.round(tempNorm * (scale.length - 1));
const freq = scale[noteIdx];
// duration from daylight (shorter days = shorter notes)
const durNorm = (d.daylight - 8) / 8; // 0 to 1
const noteDur = 0.1 + durNorm * 0.2;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(0.18, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + noteDur);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + noteDur + 0.05);
// visual: highlight current point
const x = (idx / 364) * 760 + 20;
const y = 370 - tempNorm * 330;
const hue = tempNorm * 40 + (1 - tempNorm) * 220;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 65%, 55%, 0.8)`;
ctx.fill();
// faint playhead
ctx.fillStyle = 'rgba(200, 200, 220, 0.02)';
ctx.fillRect(x, 0, 1, 400);
idx++;
setTimeout(playDay, noteGap);
}
canvas.addEventListener('click', function() {
audioCtx.resume().then(function() {
playDay();
});
});
Click the canvas and listen. January starts low -- short, quiet, deep notes. As spring arrives the pitch climbs, notes get slightly longer. By July you're hearing sustained high notes with long durations, the sonic equivalent of bright warm days. Then the descent into autumn, notes shortening and deepening. The occasional cold snap interrupts the pattern -- you hear it as a sudden dip in the melody, a low note where you expected a high one. And that cold snap might correspond to a real weather event you remember. Data you lived through, turned into a melody that sounds like the year felt.
That's the creative exercise. Take YOUR data (download your city's weather from any public API), run it through this sonification, and hear your year. It's a different experience from looking at a chart. More intimate somehow. The data unfolds in time, through your ears, and your brain processes it as music rather than graphics. The same information, a completely different sensory experience.
Before we wrap up, here's a small utility function that encapsulates the pattern we've been using. Pass in a data array, a scale, and some options, and it schedules the entire sonification. You can use this as a starting point for any sonification project.
function sonifyData(audioCtx, data, options) {
const defaults = {
scale: [261.63, 293.66, 329.63, 392.00, 440.00, 523.25, 587.33, 659.25],
waveType: 'triangle',
noteGap: 0.2,
noteDuration: 0.25,
volume: 0.18,
startDelay: 0.1
};
const opts = Object.assign({}, defaults, options);
const dataMin = Math.min(...data);
const dataMax = Math.max(...data);
let time = audioCtx.currentTime + opts.startDelay;
for (const value of data) {
const norm = (value - dataMin) / (dataMax - dataMin);
const idx = Math.round(norm * (opts.scale.length - 1));
const freq = opts.scale[Math.max(0, Math.min(opts.scale.length - 1, idx))];
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = opts.waveType;
osc.frequency.setValueAtTime(freq, time);
gain.gain.setValueAtTime(opts.volume, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + opts.noteDuration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(time);
osc.stop(time + opts.noteDuration + 0.05);
time += opts.noteGap;
}
return time; // returns when the last note ends
}
// usage
const ctx = new AudioContext();
const myData = [3, 7, 2, 8, 5, 9, 4, 6, 8, 3, 7, 10, 5, 2];
sonifyData(ctx, myData, { waveType: 'sine', noteGap: 0.3 });
Nothing fancy. It just packages the pattern we've been writing by hand all episode into a function you can call with one line. Change the scale for different moods. Change the waveType for different textures. Change the noteGap for different speeds. The function returns the end time so you can chain sonifications or synchronize them with visual playback.
Sonification bridges two arcs in this series. The data arc (episodes 79-90) has been about treating data as creative material -- mapping numbers to visual properties. But we also had an audio arc earlier (episodes 19, 72) where sound was the input. Sonification inverts that relationship: data is the input, sound is the output. And when you combine sonification with visualization (the audiovisual approach we built today), you get a multi-sensory data experience that's richer than either modality alone.
The data art arc wraps up with the next episode -- a mini-project where we pull together geographic mapping, temporal layouts, personal data tracking, network visualization, and yes, sonification, into a single cohesive generative data portrait. Everything from episodes 79 through this one, combined.
exponentialRampToValueAtTime to avoid click artifactsSynth, Sequence, Transport. Note names ('C4', 'E5') instead of raw frequencies. Simpler code, same outputSallukes! Thanks for reading.
X