Turn geographic data into soundscapes. This project maps ESA WorldCover satellite land-cover data to ambient audio — pan across forests, cities, and oceans, and hear the landscape change in real-time, powered by your own Google Earth Engine exports.
- Frontend (Mapbox) visualizes landcover and streams viewport metrics to a Node.js server.
- The server computes audio parameters (7-bus fold-mapping, land-coverage ratio) and sends them back via WebSocket.
- The browser's Web Audio engine plays ambient soundscapes that reflect the land cover composition of the current viewport.
Production deployment lives at https://placeecho.com/geo-sonification/.
See docs/DEPLOYMENT.md for the topology
(Cloudflare Pages + Worker reverse proxy + R2 + Fly.io), redeploy
commands, credential map, and known production issues.
┌─────────────────┐ ┌─────────────────┐
│ Mapbox Map │ WS │ Node.js │
│ (Frontend) │ ───> │ Server │
│ │ │ │
│ viewport ──────┼──────┼─> calculate │
│ interaction │ │ stats + │
│ │ │ audioParams │
│ audio engine ◄─┼──────┼── busTargets, │
│ (Web Audio) │ WS │ coverage … │
└─────────────────┘ └─────────────────┘
One-click start (macOS): Double-click
start.commandto start the Node server and open the browser. Requires steps 1-4 below to be completed first.
- Node.js 18+
- Mapbox account (for access token)
- Tippecanoe CLI for PMTiles generation (
brew install tippecanoeon macOS) - Seven ambience loops in
frontend/audio/ambience/(gitignored; repository only includes.gitkeep):- Runtime files:
forest.opus,shrub.opus,grass.opus,crop.opus,urban.opus,bare.opus,water.opus— the audio engine fetches these directly viafrontend/audio/buffer-cache.js. - Source files: keep the matching
forest.wav,shrub.wav, … alongside as the editing master;scripts/encode-ambience-opus.shre-encodes them to 128 kbps Opus (~1/20th the size) whenever the loops change. Both*.wavand*.opusare gitignored. - Source format: WAV, 48 kHz, stereo recommended (mono works — Web Audio upmixes automatically).
- Duration: any length, but the last 1.875 s must be an exact copy of the first 1.875 s. The engine crossfades outgoing/incoming voices over this overlap window — identical head and tail content is what makes the loop seamless. Total duration = desired cycle length + 1.875 s (e.g., 120 s cycle → 121.875 s file).
- Source: record your own or obtain ambient loops from sites like Freesound. Trim to your desired cycle length in a DAW, then copy the first 1.875 s and append it to the end.
- Setup: drop the seven WAVs into
frontend/audio/ambience/, runscripts/encode-ambience-opus.shto produce the matching.opusfiles, then start the server. Missing.opusfiles leave their buses silent and surface as loading errors in the UI.
- Runtime files:
- Go to https://account.mapbox.com/access-tokens/
- Create a new token or copy your default public token
- Copy
frontend/config.local.js.exampletofrontend/config.local.jsand paste your token
npm install && cd server && npm installRun the scripts in gee-scripts/ and download CSVs to data/raw/. See gee-scripts/README_EXPORT.md.
Before starting the server: Confirm CSVs in data/raw/ match the schema in data/raw/SCHEMA.md. If you have old loss_* CSVs, re-export and replace them. Validate the CSVs, clear derived caches, then rebuild the gitignored PMTiles overlay + the hover-glow border-distance index:
npm run check:csv
npm run clean:cache
node scripts/download-natural-earth.js # idempotent — skips if files present
node scripts/compute-border-distance.js # ~3s, fingerprinted cache
npm --prefix server run build:tiles # rebuilds grids.pmtiles + grid_index.bindata/tiles/grids.pmtiles and data/tiles/grid_index.bin are generated locally and are not committed. Re-run the build steps whenever the raw CSVs or grid size change. The Natural Earth GeoJSONs in data/sources/natural-earth/ are also gitignored — download-natural-earth.js fetches them on first run.
When the cursor hovers over the map, grid dots near a country border or coastline brighten on a smooth radial falloff. The visual is fully data-driven:
- Build time:
compute-border-distance.jsflattens Natural Earth coastline + country-boundary GeoJSONs into ~476k segments and computes the minimum distance from each grid centroid to any segment using a 1° bbox-prefiltered point-to-segment scan. Distances are baked into PMTiles as aborder_dist_kmper-feature property and into a parallelgrid_index.binsidecar. - Run time:
frontend/hover-glow.jsloads the sidecar once, registers a Mapbox custom WebGL layer (frontend/hover-glow-layer.js) above the greygrid-dotscircle layer, and forwards the cursor's lng/lat into avec2uniform on everymousemove. The layer uploads all 67k cell positions + border distances as a single static VBO at init; per frame, only the cursor uniform plus a few zoom-derived scalars change. The fragment shader computes the per-cell glow (cursorFactor × min(1, borderFactor + cursorFloor)) and emits an additive premultiplied-white point sprite — the grey base layer underneath is untouched. - Live tunables in DevTools:
__hg.tune({ rByZoom, borderFalloff, cursorFloor, eps, haloScale })patches the runtime values in place; the layer triggers a repaint and the next frame picks them up — no reload. SettingcursorFloor: 0recovers the M6 P1 border-only baseline.
Terminal: Start Node.js Server
npm startBrowser: Open Frontend
- Navigate to http://localhost:3000
- Click the play button in the top-right control strip to start audio
- Pan and zoom the map
- Watch the info panel update (landcover UI)
- Listen to the ambient soundscape change as the viewport moves across different land cover types
Click to expand full directory tree
geo-sonification/
├── package.json # Root scripts: start, dev, check:csv, clean:cache
├── .env.example # All configurable env vars with defaults
├── start.command # macOS one-click launcher (double-click)
├── start.bat # Windows one-click launcher (double-click)
├── data/
│ ├── raw/ # GEE-exported CSVs (source data, do not delete)
│ │ ├── SCHEMA.md # Data contract (fields, types, ranges)
│ │ └── <continent>_grid.csv # One CSV per continent (exported from GEE)
│ ├── cities.json # City database (~555 entries, pop > 1M)
│ ├── cache/ # Derived data (safe to delete, auto-rebuilt)
│ │ ├── all_grids.json
│ │ ├── normalize.json
│ │ └── border-distance.v1.json # Per-cell border distance (M6, fingerprinted)
│ ├── sources/ # Third-party source data (gitignored)
│ │ └── natural-earth/ # Coastline + admin boundaries (M6)
│ └── tiles/ # PMTiles + sidecar (built by build-tiles.js)
│ ├── grids.pmtiles
│ └── grid_index.bin # Hover-glow sidecar (M6, ~1 MB)
├── docs/
│ ├── ARCHITECTURE.md # System architecture
│ ├── DEVLOG.md # Development log index + recording guide
│ ├── plans/ # Design proposals, milestone specs
│ └── devlog/ # Development logs and debugging records
├── gee-scripts/
│ ├── README_EXPORT.md # GEE export instructions
│ └── <continent>_grid.js # GEE export scripts (one per continent)
├── server/
│ ├── package.json
│ ├── index.js # Express routes, WebSocket, startup
│ ├── config.js # Env parsing, aggregation settings
│ ├── landcover.js # ESA WorldCover class metadata + normalization
│ ├── audio-metrics.js # Audio computation: bus fold-mapping, proximity, delta, ocean detection
│ ├── routes.js # HTTP route handlers (M4 P4-1)
│ ├── ws-handler.js # WebSocket message router (M4 P4-2)
│ ├── client-state.js # Per-client mode + delta state (M4 P4-3 merger)
│ ├── viewport-processor.js # Viewport processing orchestrator
│ ├── parse-bounds.js # Shared bounds parser
│ ├── data-loader.js # CSV parsing, caching, deduplication
│ ├── spatial.js # Spatial index, viewport stats, bounds validation
│ ├── normalize.js # p1/p99 percentile normalization
│ ├── load-env.js # Minimal .env loader
│ ├── types.js # JSDoc type definitions
│ └── __tests__/ # Jest test suite (server side)
├── frontend/
│ ├── index.html
│ ├── style.css
│ ├── main.js # Entry point — wires modules, DOMContentLoaded
│ ├── config.js # Shared state, server config loading, hover-glow tunables
│ ├── config.local.js.example # Mapbox token template (copy to config.local.js)
│ ├── config.runtime.example.js # Production deploy config template (used by build-pages.js)
│ ├── landcover.js # Landcover metadata lookups (name, color, XSS escape)
│ ├── map.js # Mapbox init, grid overlay, viewport tracking, HTTP fallback
│ ├── websocket.js # WebSocket connection with exponential-backoff reconnect
│ ├── ui.js # DOM updates: stats panel, connection status, toast
│ ├── popup.js # Per-dot click popup (M4 P3 extract from map.js)
│ ├── progress.js # Loop-cycle progress bar (M4 P3 extract from main.js)
│ ├── initial-viewport-push.js # Pre-`load` bounds push so audio starts at the visible viewport
│ ├── sheet-drag.js # Mobile bottom-sheet drag handler
│ ├── city-announcer.js # City name voice announcement with stereo panning
│ ├── hover-glow.js # M6: load grid_index.bin, register GPU layer, mousemove → uniform
│ ├── hover-glow-layer.js # M6: Mapbox custom WebGL layer, additive halo overlay
│ ├── hover-glow-shaders.js # M6: vertex + fragment shader sources, falloff packing
│ ├── audio/engine.js # Web Audio engine: 7-bus EMA crossfade + ocean detector
│ ├── audio/ # context, buffer-cache, raf-loop, utils, constants (M4 P3)
│ ├── audio/ambience/ # Loopable Opus assets (.opus runtime + .wav source masters)
│ ├── audio/cities/ # Pre-generated TTS M4A clips (one per city)
│ └── __tests__/ # vitest + happy-dom (frontend side; runs via npm run test:frontend)
├── scripts/
│ ├── check_csv_schema.js # CSV schema validator
│ ├── build-tiles.js # PMTiles builder (also chains build-grid-index.js)
│ ├── build-grid-index.js # M6: emit grid_index.bin sidecar (fid + lng/lat + border_dist_km)
│ ├── compute-border-distance.js # M6: per-cell distance to coastline + boundary, fingerprint-cached
│ ├── download-natural-earth.js # M6: idempotent fetch of Natural Earth GeoJSONs
│ ├── encode-ambience-opus.sh # ffmpeg WAV → 128 kbps Opus encoder for ambience loops
│ ├── measure-loudness.js # LUFS measurement to calibrate master makeup gain
│ ├── benchmark-viewport.js # Viewport processing benchmark
│ ├── smoke-worldcover.js # WorldCover smoke test
│ ├── smoke-wire-format.js # WS wire-format smoke (asserts against wire-format-baseline.json)
│ ├── wire-format-baseline.json # Frozen reference payloads for the wire-format smoke
│ ├── build-pages.js # Cloudflare Pages build (frontend/ + cities.json + runtime config)
│ ├── clean-cache.js # Cross-platform cache cleaner
│ ├── generate-city-audio.js # City TTS audio generator (macOS `say`)
│ ├── setup-git-hooks.js # Cross-platform git hooks installer
│ └── test_bounds_validation.sh # Bounds regression test (Unix, requires curl)
└── .gitattributes # Line ending rules (CRLF for .bat)
This project uses a single, "now-only" schema (no historical time series). Each continent CSV contains 0.5 x 0.5 degree grid cells. See data/raw/SCHEMA.md for the full field spec (types, units, allowed ranges).
- Landcover breakdown and dominant landcover are computed by land area (sum of
land_area_km2), not by grid count. - Forest and population are aggregated by land area: forest = area-weighted mean of per-cell
forest_pct, population density =sum(population_total) / sum(land_area_km2). - Nightlight uses
nightlight_p90for viewport display; viewport nightlight is an area-weighted mean of cell-level p90 (approximation, not the true viewport p90).
All server settings (ports, aggregation mode, coastal weighting, cache) are configurable via environment variables. See .env.example for a full list with defaults. Copy to .env and modify as needed.
Caches live in data/cache/ and include aggregation version in their keys. Changing aggregation or coastal settings triggers recalculation. Clear all caches with npm run clean:cache.
Seven ambience Opus loops represent different land cover types. Land cover channels are folded into 7 audio buses:
- Forest bus: classes 10, 95 (tree/forest, mangrove)
- Shrub bus: class 20 (shrubland)
- Grass bus: class 30 (grassland)
- Crop bus: class 40
- Urban bus: class 50
- Bare bus: classes 60, 100 (bare, moss/lichen)
- Water bus: classes 70, 80, 90 (snow/ice, water, wetland) + coverage-linear ocean mix
The audio engine uses coverage (fraction of viewport cells with land data) as a linear mix rule: coverage=0% maps to land:ocean = 0:100, coverage=40% maps to 100:0, and values in between interpolate linearly (land=coverage/0.4, ocean=1-land). Above 40%, playback stays pure land. Ocean rides the Water bus while land buses are attenuated in low-coverage mode. EMA smoothing provides gradual transitions.
Ambience loops are local assets and are not committed (frontend/audio/ambience/*.opus and *.wav are both ignored). If an .opus file is missing, the corresponding bus shows a loading error and remains silent.
- Play/Stop toggle in the top-right control strip
- Per-bus loading progress indicators
- Audio automatically suspends when the browser tab is hidden (
visibilitychange), resumes and snaps to current targets on return - When viewport updates pause (for example, map is stationary), audio keeps looping at the last targets; no idle auto-fade is applied.
- HTTP fallback (
POST /api/viewport) also updatesaudioParams, so audio keeps tracking map movement when WebSocket is unavailable.
- Modern browser with Web Audio API (Chrome 66+, Firefox 76+, Safari 14.1+)
- Sufficient bandwidth for initial WAV download
Drag-stop feedback latency on the grid overlay is dominated by VIEWPORT_DEBOUNCE (frontend/config.js, default 120 ms) plus the WebSocket round-trip; spatial-index queries themselves average 1–2 ms (visible in the server's [Stats] log every 30 s). Several layers reduce that loop without changing user-visible behavior:
- Server response compression —
compressionmiddleware (gzip on HTTP) andws perMessageDeflate(zlib level 1, threshold 256 B, no context takeover). Drops the ~1.5 KB stats frame to ~0.5 KB. Verify withcurl -sI --compressed http://localhost:3000/audio/engine.js. - Opus-encoded ambience — runtime fetches
*.opus(128 kbps, ~2 MB each) instead of the source*.wav(~46 MB each). Reduces first-load audio payload from ~328 MB to ~15 MB without audibly degrading the textures. - Static-asset cache headers — PMTiles 7 days, ambience 30 days. Repeat reloads skip the network entirely; in production, R2 + Cloudflare carries its own cache layer.
- Drag-state stroke suppression — the per-grid dot stroke is set to width 0 during
movestartand restored onmoveend. Halves fragment-shader cost at low zoom on the 67k-feature dot layer; the resting visual is unchanged.
- Re-export CSVs using
gee-scripts/*.jsand place them intodata/raw/ - Delete caches:
rm -rf data/cache
- The server sends a ping every 30 seconds; clients that don't respond are terminated automatically
- Make sure Node server is running:
npm start - Check console for errors
- Verify Mapbox token is set in
frontend/config.local.js - Check browser console for errors
- Run GEE export first for each continent
- Place CSV files in
data/raw/(e.g.,data/raw/africa_grid.csv,data/raw/asia_grid.csv, etc.) - Each file should match the schema in
data/raw/SCHEMA.md - Install
tippecanoe, then rebuild the local PMTiles file withnpm --prefix server run build:tiles - Confirm
data/tiles/grids.pmtilesexists after the build
- Drop the seven loopable WAVs (
forest.wav,shrub.wav,grass.wav,crop.wav,urban.wav,bare.wav,water.wav) intofrontend/audio/ambience/ - Run
scripts/encode-ambience-opus.shto produce the matchingforest.opus, … files the runtime actually loads - Missing
.opusfiles make the corresponding buses silent and surface as loading errors in the UI
GET /health— Health check (used bystart.commandto wait for readiness)GET /api/config— Server configuration (grid size, landcover metadata, proximity zoom thresholds)POST /api/viewport— Calculate stats for given bounds (HTTP fallback when WebSocket is unavailable)
Connect to ws://localhost:3000 — HTTP and WebSocket share a single port (configurable via PORT or HTTP_PORT). The server enables permessage-deflate so viewport stats frames are gzip-compressed on the wire.
Client → Server:
{ "type": "viewport", "bounds": [west, south, east, north], "zoom": 12.5 }zoom is recommended — it drives the proximity signal and low-pass filter cutoff frequency. If omitted, proximity defaults to 0 (fully distant).
Server → Client:
{ "type": "stats", "audioParams": { "busTargets": [...], "proximity": 0.8, "coverage": 0.95 }, "mode": "aggregated", ... }See docs/ARCHITECTURE.md for the full field reference.
Contributions are welcome! Please open an issue first to discuss what you would like to change before submitting a pull request.
This project uses Conventional Commits enforced by commitlint. Run npm run lint and npm run format:check before committing.
This project uses the following third-party datasets (obtained independently via Google Earth Engine export, not distributed with this repository):
- ESA WorldCover 2021 — CC BY 4.0 — https://esa-worldcover.org/
- WorldPop 2020 — CC BY 4.0 — https://www.worldpop.org/
- VIIRS Nighttime Lights (NASA/NOAA) — Public domain — https://eogdata.mines.edu/products/vnl/
See NOTICE for full attribution details.
