From ab748bfb9d030c459211f5dfec5a9fb88ccb3cf2 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Tue, 8 Sep 2026 15:57:51 +1000 Subject: [PATCH] Sonora: local 3D audio visualizer (initial import) Three.js + Web Audio FFT visualizer with Constellation, Texture, Frequency and Modulation views. Vite build, node --test suite. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JABXBRLs9T6nqHKUVwoTHJ --- .gitignore | 2 + DESIGN.md | 20 + README.md | 39 + index.html | 53 ++ package-lock.json | 1173 ++++++++++++++++++++++++++++ package.json | 9 + src/analysis.js | 38 + src/color.js | 16 + src/constellation-data.js | 137 ++++ src/constellation.js | 161 ++++ src/demo.js | 7 + src/main.js | 230 ++++++ src/space.js | 158 ++++ src/style.css | 14 + tests/analysis.test.js | 30 + tests/color.test.js | 28 + tests/constellation-render.test.js | 76 ++ tests/constellation.test.js | 91 +++ 18 files changed, 2282 insertions(+) create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/analysis.js create mode 100644 src/color.js create mode 100644 src/constellation-data.js create mode 100644 src/constellation.js create mode 100644 src/demo.js create mode 100644 src/main.js create mode 100644 src/space.js create mode 100644 src/style.css create mode 100644 tests/analysis.test.js create mode 100644 tests/color.test.js create mode 100644 tests/constellation-render.test.js create mode 100644 tests/constellation.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..c716269 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,20 @@ +# Sonora audio space + +Standalone browser instrument inspired by Lucio Arese's “A Nightingale's Song Translated Into Data”: black plotting space, fine grids, luminous points, connected histories and three coordinated views. Built separately from BudgyAI. + +## Design and scope + +Large spread–entropy plot with two smaller tone and modulation plots. Each is real 3D with orbit/zoom. Quiet graphite controls, warm white text, spectral violet, coral and yellow reserved for data. System sans for controls; monospaced numerals for scientific readouts. Desktop has a narrow control rail, mobile stacks controls and plots. + +Files are selected or dropped locally. HTML media playback feeds Web Audio FFT analysis, then Three.js buffers. No file leaves the browser. Browser codec support determines playable formats. Generated demo is original synthesis, not the reference recording. Initial illustrative preview is explicitly labeled. + +Features: play/pause, seek, volume, file replacement, trail length, sensitivity, palette, auto rotation, reset camera, fullscreen, three selectable main plots. RMS amplitude, power-weighted spectral centroid/spread, normalized Shannon entropy and spectral flatness are computed from measured samples. Modulation view uses frame-to-frame centroid and amplitude changes, not an exact reconstruction of the artist's unpublished algorithm. + +## Implementation plan + +1. Write numerical analysis tests for silence, single tones, uniform noise and invalid values; observe failure, implement and pass. +2. Build a bounded 3D history renderer with labeled axes, point glow, trails, responsive sizing and orbit controls. +3. Build local playback and upload lifecycle; clean object URLs on replacement, display codec errors, clear history on seeks and replacements, sample only when playing. +4. Compose responsive controls and demo audio. Verify uploads, playback, seeking, replacements, controls and mobile layout in the browser. Run tests and production build. + +No backend changes, deployment or changes to the existing app are required. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0f8a1d --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Sonora — sound in space + +A standalone local audio visualizer inspired by [Lucio Arese’s sound study](https://www.youtube.com/shorts/jtNGnkscgds). Three interactive 3D plots show texture, tones and modulation. This is an interpretation, not the artist's original code or exact analysis algorithm. + +The default **Constellation** view follows the visual direction of [Seeing Birdsong](https://www.lucioarese.net/seeing-birdsong/) and [Visualizing Bird Songs at 4:42](https://www.youtube.com/watch?v=7lEiYqCV25s&t=282s): coloured square markers, delicate connections, floating numbers and a slowly orbiting camera on a large black stage. **Texture**, **Frequency** and **Modulation** retain the original scientific plots. + +Constellation uses an original nonlinear feature projection of spectral peaks, amplitude, entropy, spread and timestamp. Its visual coordinates are not physical units or the artist's original embedding. Each square represents a detected spectral peak. Labels show measured peak amplitude and frequency; Bright connections follow frequency-matched tones over time, falling back to the nearest point in the preceding audio frame when pitch changes quickly; softer connections indicate geometric proximity. These visual links are not a proven acoustic relationship. The top-right amplitude is waveform RMS. Rendering is bounded to 2,400 points, one sound-trail link and up to two neighbour links per point, and 24 labels. Automatic orbit defaults off when the system requests reduced motion. + +## Run + +```sh +cd sonora +npm install +npm run dev -- --port 5173 +``` + +Open the displayed localhost address. Choose **Open a song**, drop an audio file anywhere, or press play for a real blackbird field recording. Drag each plot to orbit; scroll/pinch to zoom. Space toggles playback when a control isn't focused. Use the fullscreen button for an immersive view. + +**Colour by → Position in space** paints a fixed three-dimensional colour field over the plots. Horizontal position is the strongest influence, while height and depth shift hue and shade. Camera movement does not change the colour at a given plot position; travelling points change colour as they move. Brightness still responds to loudness. Spectrum is the default palette. **Glow** controls the softness and size of the light around points; **Loudness** restores amplitude-based colouring. These controls do not affect audio or measurements. + +MP3, WAV, M4A, FLAC, OGG, AAC and MP4 audio are supported where your browser supports their codecs. Streaming-service URLs and DRM files are not supported. Files remain local and are streamed through a media element; no backend or file upload is used. The optional DM Sans font is fetched from Google Fonts, with a system fallback. + +Screen-width ribbons make the connections visible on high-density screens. Points, connections and labels fade progressively to zero over six seconds by default (adjustable with Trail length), including the final notes after playback ends. Pausing holds the view. Points and their connections retain stable identities as history rolls forward. Incoming positions are smoothed, and point, line and label fades run at display refresh rate. Labels stay attached to their original points. The camera settles around a stable centre and widens only as needed; Reset camera fits the current shape again. Connections use a cached spatial index, and hidden plots skip geometry updates. The live FPS readout measures actual rendering cadence; performance depends on your device and display. + +The default birdsong is [Common Blackbird song (Turdus merula)](https://commons.wikimedia.org/wiki/File:Common_Blackbird_song_(Turdus_merula).ogg), recorded by Diana Tudor and used unmodified under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It streams from Wikimedia Commons and requires an internet connection. The player requests anonymous CORS access so Web Audio can analyze the recording. + +## Measurements + +4096-point Web Audio FFT, sampled at up to 30 frames per second. Power-weighted spectral centroid and standard deviation, normalized Shannon entropy, geometric/arithmetic power ratio (flatness), waveform RMS and up to 12 spectral peaks. Modulation shows per-frame changes in centroid and RMS. Volume is applied after analysis. Sensitivity changes display scaling only. Trails are bounded to 30 seconds and reset on seeks and file changes. The initial preview is illustrative; live plots come from actual playback. + +## Validate and build + +```sh +npm test +npm run build +npm run preview +``` + +The production output is in `dist/`. Requires WebGL2 and Web Audio. The original BudgyAI app and backend are independent of this folder. diff --git a/index.html b/index.html new file mode 100644 index 0000000..2cb31db --- /dev/null +++ b/index.html @@ -0,0 +1,53 @@ + + +Sonora — Sound in space + +
+
+ sonora. + Sound, given dimension. +
Runs locally
+
+
+ +
+
Illustrative preview
Waiting for sound— fps
+
+
+

Acoustic constellation

A living architecture of sound

+
+
Amplitude —Time —
+
0 pointsLabels: amplitude / frequency
+
Drag to orbit · Scroll to zoom
+
+

Tone map

Frequency leaves a fingerprint

Hz

Modulation space

The movement between moments

Δ
+
+
Explore the structure inside your sound.
+
Amplitude— dB
Centroid— Hz
Spread— Hz
Entropy—
+
Blackbird songDiana Tudor · field recording · press play
0:001:10
+

+
+
+ +
+ +

Sound, translated into space.

Each point is a moment in your audio. Its position comes from the sound’s measured frequency content and amplitude. Older points form a trail through time.

Constellation places detected tones into an expressive 3D shape using frequency, amplitude and texture. Floating labels show peak amplitude and frequency. Brighter lines connect successive sound samples, following similar tones where possible; softer lines connect nearby points. These are visual links, not proof of an acoustic relationship. Points stay in place and fade smoothly to zero over the trail length (six seconds by default). The final notes fade out after playback ends. The camera settles around the shape and gently widens as needed until you drag or zoom; Reset camera restores automatic framing.

Texture maps spectral entropy (how dispersed the energy is) and spectral spread (frequency width). Frequency shows the strongest tones. Modulation shows frame-to-frame changes in frequency centroid and amplitude.

Drag any plot to orbit. Scroll or pinch to zoom. Sensitivity magnifies the display; it does not change your audio. Measurements use the original signal before the volume control.

This is an interpretation of the reference, not the artist’s original algorithm. The initial preview is illustrative; pressing play switches to real audio analysis. The default demo is a real blackbird field recording. It streams from Wikimedia Commons and needs an internet connection.

Birdsong: Common Blackbird song (Turdus merula) by Diana Tudor, used unmodified under CC BY 4.0.

MP3, WAV, M4A, FLAC and MP4 playback depends on your browser’s codecs. Protected streaming songs cannot be opened as local files.

+ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..da04b85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1173 @@ +{ + "name": "sonora-audio-space", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sonora-audio-space", + "version": "1.0.0", + "dependencies": { + "three": "^0.183.0" + }, + "devDependencies": { + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a9f909b --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "sonora-audio-space", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { "dev": "vite --host 127.0.0.1", "build": "vite build", "preview": "vite preview --host 127.0.0.1", "test": "node --test tests/*.test.js" }, + "dependencies": { "three": "^0.183.0" }, + "devDependencies": { "vite": "^7.3.1" } +} diff --git a/src/analysis.js b/src/analysis.js new file mode 100644 index 0000000..ddf5187 --- /dev/null +++ b/src/analysis.js @@ -0,0 +1,38 @@ +export function analyzeSpectrum(db, waveform, sampleRate) { + const n = db.length; + const step = sampleRate / (n * 2); + const power = new Float64Array(n); + let total = 0, weighted = 0, squares = 0; + for (const value of waveform) if (Number.isFinite(value)) squares += value * value; + const rms = Math.sqrt(squares / (waveform.length || 1)); + for (let i = 0; i < n; i++) { + power[i] = Number.isFinite(db[i]) ? 10 ** (Math.min(0, db[i]) / 10) : 0; + total += power[i]; weighted += power[i] * i * step; + } + if (total < 1e-12) return { rms, centroid: 0, spread: 0, entropy: 0, flatness: 0, peaks: [] }; + const centroid = weighted / total; + let variance = 0, entropy = 0, logSum = 0; + const peaks = []; + for (let i = 0; i < n; i++) { + const p = power[i] / total; + variance += p * (i * step - centroid) ** 2; + if (p > 0) entropy -= p * Math.log2(p); + logSum += Math.log(Math.max(power[i], 1e-30)); + if (i > 0 && power[i] > (power[i - 1] || 0) && power[i] >= (power[i + 1] || 0) && db[i] > -75) { + peaks.push({ frequency: i * step, amplitude: Math.sqrt(power[i]), power: power[i] }); + } + } + peaks.sort((a, b) => b.power - a.power); + return { rms, centroid, spread: Math.sqrt(variance), entropy: n > 1 ? entropy / Math.log2(n) : 0, + flatness: Math.min(1, Math.exp(logSum / n) / (total / n)), peaks: peaks.slice(0, 12) }; +} + +// A deterministic illustration for the empty state, never presented as measured audio. +export function previewFrame(t) { + const phrase = (Math.sin(t * 1.7) + 1) / 2; + const centroid = 1900 + Math.sin(t * 2.1) * 1200 + phrase * 1700; + const rms = 0.04 + phrase * 0.12 + Math.abs(Math.sin(t * 7)) * 0.07; + return { rms, centroid, spread: 300 + (Math.sin(t * 3.2) + 1) * 1000, + entropy: 0.25 + phrase * 0.26 + Math.sin(t * 4.5) * 0.12, flatness: phrase * 0.2, + peaks: Array.from({ length: 6 }, (_, i) => ({ frequency: centroid * (0.5 + i * 0.35), amplitude: rms * (1 - i / 7) })) }; +} diff --git a/src/color.js b/src/color.js new file mode 100644 index 0000000..201842f --- /dev/null +++ b/src/color.js @@ -0,0 +1,16 @@ +const clamp = (value) => Math.max(0, Math.min(1, value)); + +// A fixed color field in plot coordinates: orbiting the camera never recolors data. +export function colorStyle(x, y, z, intensity, palette, mode) { + const amplitude = clamp(intensity); + const spatial = mode === 'position'; + const height = clamp(y / 7), depth = clamp((z + 6) / 12); + const v = spatial ? clamp(0.5 + 0.75 * x / 6 + 0.32 * (height - 0.5) + 0.38 * z / 6) : amplitude; + let h, s, l; + if (palette === 'spectrum') { h = 0.76 - v * 0.76; s = 0.95; l = 0.58; } + else if (palette === 'glacier') { h = 0.64 - v * 0.17; s = 0.8; l = 0.4 + v * 0.4; } + else if (palette === 'orchid') { h = 0.7 + v * 0.23; s = 0.85; l = 0.48 + v * 0.25; } + else { h = 0.005 + v * 0.15; s = 0.95; l = 0.43 + v * 0.35; } + if (spatial) { s *= 0.88 + depth * 0.12; l = clamp(l + (height - 0.5) * 0.08); } + return { h, s, l, energy: spatial ? 0.55 + amplitude * 0.65 : 1 }; +} diff --git a/src/constellation-data.js b/src/constellation-data.js new file mode 100644 index 0000000..bee44ff --- /dev/null +++ b/src/constellation-data.js @@ -0,0 +1,137 @@ +const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); +export const NODE_LIMIT = 2400; +const CELL_SIZE = 2.8; +const distanceSquared = (a,b) => (a[0]-b[0])**2+(a[1]-b[1])**2+(a[2]-b[2])**2; +const cellKey = (x,y,z) => `${x},${y},${z}`; + +function projectPeak(frame, peak, sensitivity) { + const frequencyUnit=clamp(Math.log2(Math.max(30,peak.frequency)/30)/Math.log2(20000/30),0,1); + const level=clamp(Math.sqrt(peak.amplitude*sensitivity),0,1); + const entropy=clamp(frame.entropy||0,0,1), spread=clamp((frame.spread||0)/5000,0,1); + const angle=frequencyUnit*Math.PI*3.5+entropy*2.8+frame.time*.12; + const radius=1+level*7+entropy*2; + return {frequencyUnit,level,position:[ + Math.sin(angle)*radius+Math.sin(spread*5)*1.3, + 3.5+Math.cos(angle*1.23+spread*2)*radius*.66+(level-.3)*2.5, + Math.sin(angle*1.71+entropy*3.2)*radius*.8+(entropy-.5)*4, + ]}; +} + +class SpatialIndex { + constructor(){this.cells=new Map(); this.nodes=new Map();} + add(node){ + const cell=node.position.map(v=>Math.floor(v/CELL_SIZE)); + node.cell=cellKey(...cell); + if(!this.cells.has(node.cell))this.cells.set(node.cell,new Set()); + this.cells.get(node.cell).add(node.id); this.nodes.set(node.id,node); + } + remove(node){const cell=this.cells.get(node.cell);cell?.delete(node.id);if(!cell?.size)this.cells.delete(node.cell);this.nodes.delete(node.id);} + nearest(position,exclude,limit=2){ + const cell=position.map(v=>Math.floor(v/CELL_SIZE)); + // Keep only the few closest candidates; no per-node sorting or temporary bucket copies. + const best=[]; + for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++){ + const bucket=this.cells.get(cellKey(cell[0]+dx,cell[1]+dy,cell[2]+dz)); + if(!bucket)continue; + let examined=0; + for(const id of bucket){ + if(++examined>24)break; + if(id===exclude)continue; + const node=this.nodes.get(id), distance=distanceSquared(position,node.position); + if(distance<=.006||distance>=CELL_SIZE**2)continue; + let at=0;while(atlimit)best.pop();} + } + } + return best.map(candidate=>candidate.id); + } +} + +// Tracks detected tones by frequency, independently of their rank by loudness. +// Each historical point and its parent links are created once, then remain fixed. +export class ConstellationModel { + constructor(){this.revision=0;this.reset();} + reset(){ + this.history=null;this.geometryKey='';this.lastTime=-Infinity;this.lastFrameTime=-Infinity; + this.lastSampleTime=-Infinity;this.nextId=0;this.nextTrackId=0; + this.nodes=[];this.links=[];this.tracks=[];this.previousFrame=[];this.spatial=new SpatialIndex();this.revision++; + } + update(history,now,settings){ + const geometryKey=`${settings.sensitivity}:${settings.trail}`; + if(history!==this.history||geometryKey!==this.geometryKey||nownow)continue; + this.lastFrameTime=frame.time; + if(frame.time<=cutoff||frame.rms<.00001||frame.time-this.lastSampleTimeframe.time-track.time<.3); + const matched=new Set(); + const currentFrame=[]; + for(const peak of frame.peaks.slice(0,6)){ + if(!Number.isFinite(peak.frequency)||!Number.isFinite(peak.amplitude)||peak.frequency<=0||peak.amplitude<=0)continue; + let track=null,best=.38; + for(const candidate of this.tracks){ + if(matched.has(candidate.id))continue; + const difference=Math.abs(Math.log2(peak.frequency/candidate.frequency)); + if(differencetrack.position[i]+(v-track.position[i])*blend); + previousId=track.nodeId; + }else{track={id:this.nextTrackId++};this.tracks.push(track);} + matched.add(track.id); + const node={...projected,id:this.nextId++,trackId:track.id,frequency:peak.frequency,amplitude:peak.amplitude,time:frame.time,parents:[],trailParent:null}; + let previous=this.spatial.nodes.get(previousId); + // A call can jump farther in pitch than tone matching allows. Continue its + // visual path from the nearest sample in the preceding frame instead. + if(!previous){ + let nearest=Infinity; + for(const candidate of this.previousFrame){ + if(frame.time-candidate.time>.3||!this.spatial.nodes.has(candidate.id))continue; + const distance=distanceSquared(candidate.position,node.position); + if(distanceNODE_LIMIT){this.spatial.remove(this.nodes.shift());changed=true;} + if(changed){ + const indexes=new Map(this.nodes.map((node,i)=>[node.id,i])); + const links=[]; + for(let i=0;i{for(const id of index.nearest(node.position,null,3))links.push([id,i]);index.add({...node,id:i});}); + return links; +} diff --git a/src/constellation.js b/src/constellation.js new file mode 100644 index 0000000..d3264f2 --- /dev/null +++ b/src/constellation.js @@ -0,0 +1,161 @@ +import * as THREE from 'three'; +import { ConstellationModel, NODE_LIMIT } from './constellation-data.js'; +import { colorStyle } from './color.js'; + +const LABEL_LIMIT=24; +// Life is animated on the GPU at display refresh rate, independently of audio sampling. +const lifeShader=` + float lifeFade(float born,float expires,float now){ + float arrive=smoothstep(0.,.18,now-born); + float age=clamp((now-born)/max(.01,expires-born),0.,1.); + return arrive*pow(1.-age,1.6); + }`; + +export class ConstellationLayer extends THREE.Group { + constructor(){ + super();this.model=new ConstellationModel(); + this.geometry=new THREE.BufferGeometry(); + this.positions=new Float32Array(NODE_LIMIT*3);this.colors=new Float32Array(NODE_LIMIT*3); + this.sizes=new Float32Array(NODE_LIMIT);this.births=new Float32Array(NODE_LIMIT); + for(const [name,array,size] of [['position',this.positions,3],['color',this.colors,3],['pointSize',this.sizes,1],['born',this.births,1]]){ + this.geometry.setAttribute(name,new THREE.BufferAttribute(array,size).setUsage(THREE.DynamicDrawUsage)); + } + this.geometry.setDrawRange(0,0); + this.uniforms={resolution:{value:600},glow:{value:1.35},now:{value:0},trail:{value:12}}; + this.material=new THREE.ShaderMaterial({vertexColors:true,transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,uniforms:this.uniforms, + vertexShader:`attribute float pointSize; attribute float born; uniform float resolution; uniform float now; uniform float trail; varying vec3 vColor; varying float vFade; + ${lifeShader} + void main(){vColor=color;vFade=lifeFade(born,born+trail,now);vec4 p=modelViewMatrix*vec4(position,1.); + gl_PointSize=clamp(pointSize*resolution/-p.z,2.,32.);gl_Position=projectionMatrix*p;}`, + fragmentShader:`varying vec3 vColor;varying float vFade;uniform float glow; + void main(){vec2 uv=abs(gl_PointCoord-.5)*2.;float edge=max(uv.x,uv.y); + float ring=(1.-smoothstep(.65,.76,edge))*smoothstep(.44,.55,edge); + float core=1.-smoothstep(.08,.22,length(uv));float halo=exp(-dot(uv,uv)*4.)*(1.-smoothstep(.8,1.,edge)); + gl_FragColor=vec4(vColor,vFade*(ring*.85+core*.85+halo*.12*glow));}`, + }); + this.points=new THREE.Points(this.geometry,this.material);this.points.frustumCulled=false;this.add(this.points); + // Screen-width ribbons stay readable on high-density displays; WebGL native + // lines are only one physical pixel wide on many devices. + this.lineGeometry=new THREE.InstancedBufferGeometry(); + this.lineGeometry.setAttribute('position',new THREE.Float32BufferAttribute([0,-1,0,0,1,0,1,-1,0,1,1,0],3)); + this.lineGeometry.setIndex([0,2,1,2,3,1]); + this.linePositions=new Float32Array(NODE_LIMIT*18);this.lineColors=new Float32Array(this.linePositions.length);this.lineLives=new Float32Array(NODE_LIMIT*12); + const positions=new THREE.InstancedInterleavedBuffer(this.linePositions,6).setUsage(THREE.DynamicDrawUsage); + const colors=new THREE.InstancedInterleavedBuffer(this.lineColors,6).setUsage(THREE.DynamicDrawUsage); + const lives=new THREE.InstancedInterleavedBuffer(this.lineLives,4).setUsage(THREE.DynamicDrawUsage); + this.lineBuffers=[positions,colors,lives]; + for(const [name,buffer,size,offset] of [['start',positions,3,0],['end',positions,3,3],['startColor',colors,3,0],['endColor',colors,3,3],['life',lives,2,0]]){ + this.lineGeometry.setAttribute(name,new THREE.InterleavedBufferAttribute(buffer,size,offset)); + } + this.lineGeometry.instanceCount=0; + this.lines=new THREE.Mesh(this.lineGeometry,new THREE.ShaderMaterial({side:THREE.DoubleSide,transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,uniforms:{now:this.uniforms.now,resolution:this.uniforms.resolution}, + vertexShader:`attribute vec3 start;attribute vec3 end;attribute vec3 startColor;attribute vec3 endColor; + attribute vec2 life;uniform float now;uniform float resolution;varying vec3 vColor;varying float vFade;varying float vSide; + ${lifeShader} + void main(){ + vec4 a=projectionMatrix*modelViewMatrix*vec4(start,1.); + vec4 b=projectionMatrix*modelViewMatrix*vec4(end,1.); + float aspect=projectionMatrix[1][1]/projectionMatrix[0][0]; + vec2 direction=(b.xy/max(b.w,.001)-a.xy/max(a.w,.001))*vec2(aspect,1.); + vec2 normal=vec2(-direction.y,direction.x)/max(length(direction),.00001); + vec4 p=mix(a,b,position.x); + p.xy+=normal/vec2(aspect,1.)*position.y*3./resolution*p.w; + vColor=mix(startColor,endColor,position.x);vSide=position.y; + vFade=lifeFade(life.x,life.y,now)*step(.1,min(a.w,b.w));gl_Position=p; + }`, + fragmentShader:'varying vec3 vColor;varying float vFade;varying float vSide;void main(){float edge=1.-smoothstep(.55,1.,abs(vSide));gl_FragColor=vec4(vColor,vFade*edge*.9);}', + })); + this.lines.frustumCulled=false;this.add(this.lines); + this.annotations=new THREE.Group();this.add(this.annotations);this.labelPool=[]; + for(let i=0;i[node.id,node])); + for(const label of this.labelPool)if(label.node&&!byId.has(label.node.id)){label.node=null;label.sprite.visible=false;} + if(this.lastHistory!==history||this.lastGeometryKey!==this.model.geometryKey){ + for(const label of this.labelPool){label.node=null;label.sprite.visible=false;} + this.lastLabelTime=-Infinity; + } + this.lastHistory=history;this.lastGeometryKey=this.model.geometryKey; + } + if(settings.labels&&(geometryChanged||!this.lastLabels))this.updateLabels(nodes,now,settings.trail); + this.lastLabels=settings.labels;this.lastRevision=revision;this.nodeCount=nodes.length;this.linkCount=links.length; + this.animate(now);return nodes.length; + } + updateBounds(nodes){ + if(!nodes.length){this.frameBounds=null;return;} + const min=[Infinity,Infinity,Infinity],max=[-Infinity,-Infinity,-Infinity]; + for(const node of nodes)for(let axis=0;axis<3;axis++){min[axis]=Math.min(min[axis],node.position[axis]);max[axis]=Math.max(max[axis],node.position[axis]);} + this.frameBounds={center:new THREE.Vector3(...min.map((v,i)=>(v+max[i])/2)),radius:Math.max(1.4,Math.hypot(...min.map((v,i)=>(max[i]-v)/2)))}; + } + updateLabels(nodes,now,trail){ + if(now-this.lastLabelTime<.4)return; + this.lastLabelTime=now; + const used=new Set(this.labelPool.filter(label=>label.node).map(label=>label.node.id)); + const chosen=this.labelPool.filter(label=>label.node).map(label=>label.node.position); + const candidates=nodes.filter(node=>!used.has(node.id)&&now-node.timeb.level-a.level); + for(const label of this.labelPool){ + if(label.node)continue; + const at=candidates.findIndex(node=>chosen.every(p=>Math.hypot(...p.map((v,i)=>v-node.position[i]))>1.35)); + if(at<0)break; + const [node]=candidates.splice(at,1);label.node=node;chosen.push(node.position); + const ctx=label.canvas.getContext('2d');ctx.clearRect(0,0,256,96);ctx.textAlign='center'; + ctx.font='500 29px monospace';ctx.fillStyle='#d9e1e9';ctx.fillText(node.amplitude.toFixed(4),128,34); + ctx.font='22px monospace';ctx.fillStyle='#8d97a8';ctx.fillText(`${(node.frequency/1000).toFixed(2)} kHz`,128,66); + label.texture.needsUpdate=true;label.sprite.position.set(node.position[0],node.position[1]+.33,node.position[2]); + const size=1.5+node.level;label.sprite.scale.set(size,size*.375,1);label.sprite.visible=true; + } + } + animate(now){ + this.uniforms.now.value=now; + if(!this.annotations.visible)return; + const trail=this.uniforms.trail.value; + for(const label of this.labelPool){ + if(!label.node)continue; + const age=now-label.node.time; + const arrive=Math.min(1,Math.max(0,age/.18)); + label.sprite.material.opacity=.8*arrive*Math.max(0,1-age/trail)**1.6; + } + } +} diff --git a/src/demo.js b/src/demo.js new file mode 100644 index 0000000..487dec3 --- /dev/null +++ b/src/demo.js @@ -0,0 +1,7 @@ +// Common Blackbird song (Turdus merula), recorded by Diana Tudor. +// Unmodified recording, CC BY 4.0; attribution also appears in the help panel. +export const birdsong = { + url: 'https://upload.wikimedia.org/wikipedia/commons/3/30/Common_Blackbird_song_%28Turdus_merula%29.ogg', + name: 'Blackbird song', + detail: 'Field recording · Diana Tudor · CC BY 4.0', +}; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..fa10340 --- /dev/null +++ b/src/main.js @@ -0,0 +1,230 @@ +import './style.css'; +import { AudioSpace } from './space.js'; +import { analyzeSpectrum, previewFrame } from './analysis.js'; +import { birdsong } from './demo.js'; + +const $ = (id) => document.getElementById(id); +const defaultOrbit = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; +const settings = { sensitivity: 1, trail: 6, palette: 'spectrum', colorMode: 'position', glow: 1.35, rotate: defaultOrbit, connections: true, labels: true }; +const media = new Audio(); media.preload = 'metadata'; media.crossOrigin = 'anonymous'; +let context, analyser, gain, source, db, waveform, url, loaded = false; +let history = [], visualTime = 12, previousFeature = null, lastSample = 0, lastMetrics = 0; +let dirty = true, preview = true, loadVersion = 0, lastVolume = 0.65; +let spaces = []; +const message = (text) => { $('message').textContent = text; }; +const time = (seconds) => { const s = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }; + +function clearHistory() { history = []; previousFeature = null; dirty = true; } +function addFrame(f, t) { + f.time = t; + f.deltaFrequency = previousFeature ? f.centroid - previousFeature.centroid : 0; + f.deltaAmplitude = previousFeature ? f.rms - previousFeature.rms : 0; + previousFeature = f; history.push(f); + // Keep a bounded 30-second history even if a tab is left playing for hours. + while (history.length > 910 || (history.length && t - history[0].time > 30)) history.shift(); + dirty = true; +} +try { + spaces = [new AudioSpace($('main-canvas'), 'constellation'), new AudioSpace($('tone-canvas'), 'tone', true), new AudioSpace($('mod-canvas'), 'mod', true)]; + for (let i = 0; i <= 360; i++) addFrame(previewFrame(i / 30), i / 30); +} catch (error) { + message('3D graphics are unavailable. Enable hardware acceleration or try Chrome, Edge or Safari.'); + console.error(error); +} + +function initAudio() { + if (context) return; + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) throw new Error('This browser does not support audio analysis. Try a current browser.'); + context = new AudioContextClass(); analyser = context.createAnalyser(); gain = context.createGain(); + analyser.fftSize = 4096; analyser.smoothingTimeConstant = 0.55; + source = context.createMediaElementSource(media); + source.connect(analyser); analyser.connect(gain); gain.connect(context.destination); + gain.gain.value = Number($('volume').value); + db = new Float32Array(analyser.frequencyBinCount); waveform = new Float32Array(analyser.fftSize); +} +function syncPlayer() { + const playing = !media.paused && !media.ended; + document.body.classList.toggle('playing', playing); + $('play').textContent = playing ? 'Ⅱ' : '▶'; + $('play').setAttribute('aria-label', playing ? 'Pause' : loaded ? 'Play' : 'Play demo'); + if (loaded) $('status').textContent = playing ? 'Analyzing live audio' : media.ended ? 'Playback complete' : 'Paused'; +} +function loadBlob(blob, name, detail) { loadTrack(URL.createObjectURL(blob), name, detail, true); } +function loadTrack(audioUrl, name, detail, local = false) { + loadVersion++; + media.pause(); + if (url) URL.revokeObjectURL(url); + url = local ? audioUrl : null; media.src = audioUrl; + loaded = true; preview = false; + clearHistory(); visualTime = 0; lastSample = 0; + $('track-name').textContent = name; $('track-detail').textContent = detail; + $('status').textContent = 'Loading audio…'; $('seek').disabled = true; + $('seek').value = 0; $('current-time').textContent = '0:00'; $('duration').textContent = '—'; + $('sample-info').textContent = local ? 'Local audio' : 'Birdsong recording'; resetMetrics(); message(''); media.load(); +} +function loadDemo() { loadTrack(birdsong.url, birdsong.name, birdsong.detail); } +async function play() { + const version = loadVersion; + try { + initAudio(); await context.resume(); + if (version !== loadVersion) return; + if (media.ended) { media.currentTime = 0; clearHistory(); } + await media.play(); + if (version !== loadVersion) return; + message(''); + } catch (error) { + if (version !== loadVersion || error.name === 'AbortError') return; + message(error.name === 'NotAllowedError' ? 'Press play to allow audio playback.' : media.src === birdsong.url ? 'Birdsong could not load. Check your internet connection, or open a song.' : 'This file could not be played. Try an MP3 or WAV file.'); + syncPlayer(); + } +} +async function togglePlay() { + if (!loaded) loadDemo(); + if (media.paused) await play(); else media.pause(); +} +function loadFile(file) { + if (!file) return; + if (!file.size) { message('This file is empty. Choose another audio file.'); return; } + if (!/^(audio\/|video\/)/.test(file.type) && !/\.(mp3|wav|m4a|flac|ogg|aac|mp4|webm|aiff|aif)$/i.test(file.name)) { + message('Choose an audio file, such as MP3, WAV or M4A, or an MP4 video.'); return; + } + loadBlob(file, file.name.replace(/\.[^.]+$/, ''), `${file.name.split('.').pop().toUpperCase()} · ${(file.size / 1048576).toFixed(1)} MB · on your device`); + void play(); +} +$('upload').addEventListener('click', () => $('file').click()); +$('file').addEventListener('change', (event) => { loadFile(event.target.files[0]); event.target.value = ''; }); +$('demo').addEventListener('click', () => { loadDemo(); void play(); }); +$('play').addEventListener('click', togglePlay); +media.addEventListener('loadedmetadata', () => { + if (!Number.isFinite(media.duration) || media.duration <= 0) { message('No playable duration was found. Try another file.'); return; } + $('seek').max = media.duration; $('seek').disabled = false; $('duration').textContent = time(media.duration); + syncPlayer(); +}); +for (const event of ['play', 'pause', 'ended']) media.addEventListener(event, syncPlayer); +media.addEventListener('error', () => { + $('seek').disabled = true; + message(media.src === birdsong.url ? 'Birdsong could not load. Check your internet connection, or open a song.' : 'Your browser could not decode this file. Try an unprotected MP3 or WAV.'); $('status').textContent = 'Unable to play file'; +}); +media.addEventListener('seeking', () => { clearHistory(); visualTime = media.currentTime; }); +media.addEventListener('timeupdate', () => { + $('current-time').textContent = time(media.currentTime); $('seek').value = media.currentTime; +}); +$('seek').addEventListener('input', (event) => { if (loaded && Number.isFinite(media.duration)) { media.currentTime = Number(event.target.value); clearHistory(); $('current-time').textContent = time(media.currentTime); } }); +function setVolume(value) { + $('volume').value = value; + if (gain) gain.gain.setTargetAtTime(value, context.currentTime, 0.015); + $('mute').textContent = value === 0 ? '×' : '♪'; $('mute').setAttribute('aria-label', value === 0 ? 'Unmute' : 'Mute'); +} +$('volume').addEventListener('input', (event) => { const value = Number(event.target.value); if (value) lastVolume = value; setVolume(value); }); +$('mute').addEventListener('click', () => { const current = Number($('volume').value); if (current) lastVolume = current; setVolume(current ? 0 : lastVolume); }); +$('sensitivity').addEventListener('input', (event) => { settings.sensitivity = Number(event.target.value); $('sensitivity-value').textContent = `${settings.sensitivity.toFixed(1)}×`; dirty = true; }); +$('trail').addEventListener('input', (event) => { settings.trail = Number(event.target.value); $('trail-value').textContent = `${settings.trail} s`; dirty = true; }); +for (const id of ['rotate', 'connections', 'labels']) $(id).addEventListener('change', (event) => { settings[id] = event.target.checked; dirty = true; }); +function updateColorMode() { + settings.colorMode = $('color-mode').value; dirty = true; + const spatial = settings.colorMode === 'position'; + $('color-description').textContent = spatial ? 'Colour follows position. Brightness follows sound.' : settings.colorMode === 'frequency' ? 'Low tones are violet. High tones are warm.' : 'Colour and brightness follow the sound level.'; + document.querySelector('.legend').lastChild.textContent = spatial ? ' position → colour' : settings.colorMode === 'frequency' ? ' low → high frequency' : ' soft to loud'; +} +$('color-mode').addEventListener('change', updateColorMode); +$('glow').addEventListener('input', (event) => { settings.glow = Number(event.target.value); $('glow-value').textContent = `${settings.glow.toFixed(2)}×`; dirty = true; }); +function selectPalette(palette) { + settings.palette = palette; dirty = true; + $('palette-name').textContent = palette[0].toUpperCase() + palette.slice(1); + for (const button of document.querySelectorAll('[data-palette]')) { const selected = button.dataset.palette === palette; button.classList.toggle('selected', selected); button.setAttribute('aria-pressed', String(selected)); } + document.querySelector('.legend i').style.background = { ember: 'linear-gradient(90deg,#cb322c,#fff9b6)', spectrum: 'linear-gradient(90deg,#602cde,#3dc6c6,#fdb84a)', glacier: 'linear-gradient(90deg,#354bc1,#b6fbff)', orchid: 'linear-gradient(90deg,#6024c7,#f8c5ed)' }[palette]; +} +document.querySelectorAll('[data-palette]').forEach((button) => button.addEventListener('click', () => selectPalette(button.dataset.palette))); +$('reset-settings').addEventListener('click', () => { + Object.assign(settings, { sensitivity: 1, trail: 6, glow: 1.35, rotate: defaultOrbit, connections: true, labels: true }); + $('sensitivity').value = 1; $('sensitivity-value').textContent = '1.0×'; $('trail').value = 6; $('trail-value').textContent = '6 s'; + $('glow').value = 1.35; $('glow-value').textContent = '1.35×'; $('color-mode').value = 'position'; updateColorMode(); + $('rotate').checked = defaultOrbit; $('connections').checked = true; $('labels').checked = true; selectPalette('spectrum'); spaces.forEach(space => space.reset()); +}); +const modes = { constellation: ['Acoustic constellation', 'A living architecture of sound', 'Frequency, texture & amplitude · feature projection'], spread: ['Spread–entropy space', 'The evolving texture of a sound', 'Entropy × spectral spread × time'], tone: ['Frequency space', 'The strongest tones, suspended in time', 'Frequency × amplitude × time'], mod: ['Modulation space', 'The movement between moments', 'Frequency change × level change × time'] }; +document.querySelectorAll('[data-mode]').forEach((button) => button.addEventListener('click', () => { + const mode = button.dataset.mode; spaces[0]?.setMode(mode); dirty = true; + document.querySelector('.workspace').classList.toggle('constellation-view', mode === 'constellation'); + [$('main-title').textContent, $('main-description').textContent, $('main-axis').textContent] = modes[mode]; + document.querySelectorAll('[data-mode]').forEach(b => { const selected = b === button; b.classList.toggle('active', selected); b.setAttribute('aria-pressed', String(selected)); }); +})); +$('reset-camera').addEventListener('click', () => spaces.forEach(space => space.reset())); +$('fullscreen').addEventListener('click', async () => { + try { if (document.fullscreenElement) await document.exitFullscreen(); else if ($('main-plot').requestFullscreen) await $('main-plot').requestFullscreen(); else message('Fullscreen is unavailable in this browser.'); } catch { message('Fullscreen is unavailable in this browser window.'); } +}); +$('help').addEventListener('click', () => $('about').showModal()); $('close-help').addEventListener('click', () => $('about').close()); +document.addEventListener('keydown', (event) => { + if (event.code === 'Space' && !['INPUT', 'BUTTON', 'TEXTAREA', 'A'].includes(event.target.tagName) && !$('about').open) { event.preventDefault(); void togglePlay(); } +}); +let dragDepth = 0; +document.addEventListener('dragenter', (event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); dragDepth++; $('drop-overlay').hidden = false; } }); +document.addEventListener('dragover', (event) => { if (event.dataTransfer.types.includes('Files')) event.preventDefault(); }); +document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; $('drop-overlay').hidden = true; } }); +document.addEventListener('drop', (event) => { event.preventDefault(); dragDepth = 0; $('drop-overlay').hidden = true; loadFile(event.dataTransfer.files[0]); }); +window.addEventListener('blur', () => { dragDepth = 0; $('drop-overlay').hidden = true; }); + +function resetMetrics() { + $('hud-amplitude').textContent = '—'; $('hud-time').textContent = '—'; + $('metric-rms').innerHTML = '— dB'; $('metric-centroid').innerHTML = '— Hz'; + $('metric-spread').innerHTML = '— Hz'; $('metric-entropy').textContent = '—'; +} +function updateMetrics(f) { + $('hud-amplitude').textContent = f.rms.toFixed(4); $('hud-time').textContent = `${media.currentTime.toFixed(2)} s`; + $('metric-rms').innerHTML = `${f.rms > 1e-6 ? (20 * Math.log10(f.rms)).toFixed(1) : '−∞'} dB`; + $('metric-centroid').innerHTML = `${Math.round(f.centroid).toLocaleString()} Hz`; + $('metric-spread').innerHTML = `${Math.round(f.spread).toLocaleString()} Hz`; + $('metric-entropy').textContent = f.entropy.toFixed(3); + $('sample-info').textContent = `${(context.sampleRate / 1000).toFixed(1)} kHz · live analysis`; +} +const spectrum = $('spectrum'), spectrumCtx = spectrum.getContext('2d'); +function drawSpectrum() { + const width = spectrum.clientWidth, height = spectrum.clientHeight; + if (!width) return; + if (spectrum.width !== width * 2 || spectrum.height !== height * 2) { spectrum.width = width * 2; spectrum.height = height * 2; } + spectrumCtx.clearRect(0, 0, spectrum.width, spectrum.height); + for (let i = 0; i < 36; i++) { + const value = db && !preview ? Math.max(0, ((db[Math.floor(2 ** (i / 35 * 10))] || -100) + 85) / 70) : (Math.sin(i * 1.8) * 0.3 + 0.45) * Math.sin(i / 36 * Math.PI); + spectrumCtx.fillStyle = `rgba(211,177,133,${0.25 + value * 0.65})`; + spectrumCtx.fillRect(i * spectrum.width / 36, spectrum.height * (1 - value), Math.max(1, spectrum.width / 36 - 3), spectrum.height * value); + } +} +let lastRender = null, fpsStart = null, fpsFrames = 0; +function frame(timestamp) { + requestAnimationFrame(frame); + if (document.hidden) { lastRender = null; fpsStart = null; fpsFrames = 0; return; } + const delta = lastRender === null ? 1 / 60 : Math.min(.05, Math.max(.001, (timestamp - lastRender) / 1000)); + lastRender = timestamp; + if (loaded && !media.seeking) { + if (media.ended) { + // Let the last notes disappear after playback finishes, even with no new FFT frames. + if (visualTime < media.currentTime + settings.trail) { + visualTime = Math.min(media.currentTime + settings.trail, Math.max(visualTime, media.currentTime) + delta); + dirty = true; + } + } else visualTime = media.currentTime; + } + if (analyser && !media.paused && !media.ended && !media.seeking && media.readyState >= 3 && timestamp - lastSample >= 1000 / 30) { + lastSample = timestamp - ((timestamp - lastSample) % (1000 / 30)); + analyser.getFloatFrequencyData(db); analyser.getFloatTimeDomainData(waveform); + const f = analyzeSpectrum(db, waveform, context.sampleRate); + // Digital silence carries no texture; do not invent points at the origin. + if (f.rms > 0.00001) addFrame(f, visualTime); else dirty = true; + if (timestamp - lastMetrics > 100) { updateMetrics(f); drawSpectrum(); lastMetrics = timestamp; } + } + if (dirty) { + for (const space of spaces) if (space.host.clientWidth && space.host.clientHeight) space.update(history, visualTime, settings); + const counts = $('main-canvas').dataset; + $('node-count').textContent = `${counts.nodes || 0} points · ${settings.connections ? counts.links || 0 : 0} links`; + dirty = false; + } + for (const space of spaces) space.render(visualTime, delta); + if (fpsStart === null) fpsStart = timestamp; + else fpsFrames++; + if (timestamp - fpsStart >= 1000) { + $('fps').textContent = `${Math.round(fpsFrames * 1000 / (timestamp - fpsStart))} fps`; + fpsStart = timestamp; fpsFrames = 0; + } +} +$('rotate').checked = defaultOrbit; selectPalette(settings.palette); updateColorMode(); drawSpectrum(); requestAnimationFrame(frame); +window.addEventListener('pagehide', (event) => { media.pause(); if (event.persisted) return; if (url) URL.revokeObjectURL(url); context?.close(); spaces.forEach(space => space.dispose()); }); diff --git a/src/space.js b/src/space.js new file mode 100644 index 0000000..2d41b2d --- /dev/null +++ b/src/space.js @@ -0,0 +1,158 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { colorStyle } from './color.js'; +import { ConstellationLayer } from './constellation.js'; + +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +const MAX_POINTS = 12000; +export class AudioSpace { + constructor(host, mode, compact = false) { + this.host = host; this.mode = mode; this.compact = compact; + this.scene = new THREE.Scene(); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + this.renderer.setClearColor(0x000000, 0); + host.append(this.renderer.domElement); + this.camera = new THREE.PerspectiveCamera(40, 1, 0.1, 150); + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.enableDamping = true; this.controls.enablePan = false; + this.controls.minDistance = 10; this.controls.maxDistance = 65; + this.controls.autoRotateSpeed = 0.25; + this.controls.addEventListener('start', () => { this.autoFrame = false; }); + this.frameOffset = new THREE.Vector3(); + this.reset(); + this.gridGroup = new THREE.Group(); this.scene.add(this.gridGroup); + const grid = new THREE.GridHelper(12, 12, 0x34383c, 0x1b2025); + grid.position.y = -0.02; this.gridGroup.add(grid); + const box = new THREE.EdgesGeometry(new THREE.BoxGeometry(12, 7, 12)); + const outline = new THREE.LineSegments(box, new THREE.LineBasicMaterial({ color: 0x272b30, transparent: true, opacity: 0.65 })); + outline.position.y = 3.5; this.gridGroup.add(outline); + this.labels = new THREE.Group(); this.scene.add(this.labels); this.setLabels(); + this.geometry = new THREE.BufferGeometry(); + this.positions = new Float32Array(MAX_POINTS * 3); this.colors = new Float32Array(MAX_POINTS * 3); this.sizes = new Float32Array(MAX_POINTS); + this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage)); + this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colors, 3).setUsage(THREE.DynamicDrawUsage)); + this.geometry.setAttribute('pointSize', new THREE.BufferAttribute(this.sizes, 1).setUsage(THREE.DynamicDrawUsage)); + this.geometry.setDrawRange(0, 0); + this.material = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, vertexColors: true, blending: THREE.AdditiveBlending, + uniforms: { resolution: { value: 450 }, glow: { value: 1.35 } }, + vertexShader: 'attribute float pointSize; varying vec3 vColor; uniform float resolution; void main(){vColor=color; vec4 p=modelViewMatrix*vec4(position,1.); gl_PointSize=clamp(pointSize*resolution/-p.z,1.,38.); gl_Position=projectionMatrix*p;}', + fragmentShader: 'varying vec3 vColor; uniform float glow; void main(){float d=length(gl_PointCoord-0.5)*2.; if(d>1.) discard; float core=exp(-d*d*28.); float halo=exp(-d*d*5.)*(1.-smoothstep(.65,1.,d)); float a=core*.85+halo*.32*glow; vec3 c=mix(vColor,vColor+vec3(.22),core*.3); gl_FragColor=vec4(c,a);}' }); + this.points = new THREE.Points(this.geometry, this.material); this.points.frustumCulled = false; this.scene.add(this.points); + this.lineGeo = new THREE.BufferGeometry(); this.linePositions = new Float32Array(1800 * 3); this.lineColors = new Float32Array(1800 * 3); + this.lineGeo.setAttribute('position', new THREE.BufferAttribute(this.linePositions, 3).setUsage(THREE.DynamicDrawUsage)); + this.lineGeo.setAttribute('color', new THREE.BufferAttribute(this.lineColors, 3).setUsage(THREE.DynamicDrawUsage)); this.lineGeo.setDrawRange(0, 0); + this.lines = new THREE.LineSegments(this.lineGeo, new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.5, depthWrite: false, blending: THREE.AdditiveBlending })); + this.lines.frustumCulled = false; this.scene.add(this.lines); + this.color = new THREE.Color(); + this.constellation = mode === 'constellation' ? new ConstellationLayer() : null; + if (this.constellation) this.scene.add(this.constellation); + this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver.observe(host); this.resize(); + } + reset() { this.autoFrame = true; this.framingEnvelope = null; this.camera.position.set(this.mode === 'constellation' ? 10 : this.compact ? 14 : 15, this.mode === 'constellation' ? 7 : this.compact ? 11 : 10, this.compact ? 21 : 22); this.controls.target.set(0, this.mode === 'constellation' ? 3.5 : 2.4, 0); this.camera.position.sub(this.controls.target).multiplyScalar(this.fitFactor || 1).add(this.controls.target); this.controls.update(); } + resize() { + const { width, height } = this.host.getBoundingClientRect(); if (!width || !height) return; + this.renderer.setSize(width, height); this.camera.aspect = width / height; + const factor = Math.max(1, 1 / this.camera.aspect); + this.camera.position.sub(this.controls.target).multiplyScalar(factor / (this.fitFactor || 1)).add(this.controls.target); + this.fitFactor = factor; this.camera.updateProjectionMatrix(); + this.material && (this.material.uniforms.resolution.value = height * Math.min(devicePixelRatio, 2)); + if (this.constellation) this.constellation.uniforms.resolution.value = height * Math.min(devicePixelRatio, 2); + } + label(text, position, width = 4) { + const canvas = document.createElement('canvas'); canvas.width = 768; canvas.height = 96; + const ctx = canvas.getContext('2d'); ctx.font = '30px ui-monospace, monospace'; ctx.fillStyle = '#899099'; ctx.textAlign = 'center'; ctx.fillText(text, 384, 56); + const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })); + sprite.position.set(...position); sprite.scale.set(width, width / 8, 1); this.labels.add(sprite); + } + setLabels() { + for (const child of [...this.labels.children]) { child.material.map.dispose(); child.material.dispose(); this.labels.remove(child); } + if (this.mode === 'constellation') return; + const x = this.mode === 'spread' ? '0 entropy 1' : this.mode === 'tone' ? '20 Hz frequency (log) 20 kHz' : '−1 Δ frequency (kHz) +1'; + const y = this.mode === 'spread' ? 'spread · 8 kHz' : this.mode === 'tone' ? 'amplitude' : 'Δ amplitude'; + this.label(x, [0, -0.7, 6.8], this.compact ? 8 : 7); + this.label(y, [-6.6, 7.4, 0], 5); + this.label('past ← time → now', [7, -0.35, 0], 5); + } + setMode(mode) { this.mode = mode; this.setLabels(); } + update(history, now, settings) { + const constellation = this.mode === 'constellation'; + if (constellation && !this.constellation) { this.constellation = new ConstellationLayer(); this.scene.add(this.constellation); } + if (this.constellation) this.constellation.visible = constellation; + this.gridGroup.visible = !constellation; + this.points.visible = !constellation; this.lines.visible = !constellation && settings.connections && this.mode !== 'tone'; + this.controls.autoRotate = settings.rotate; + if (constellation) { + if (this.framingHistory !== history) { this.framingHistory = history; this.framingEnvelope = null; } + const count = this.constellation.update(history, now, settings, this.material.uniforms.resolution.value); + this.host.dataset.nodes = count; + this.host.dataset.links = this.constellation.linkCount; + return; + } + this.material.uniforms.glow.value = settings.glow; + let count = 0, lineCount = 0, previous = null; + const add = (x, y, z, value, age, size, frequency) => { + if (count >= MAX_POINTS) return; + this.positions.set([x, y, z], count * 3); + const colorValue = settings.colorMode === 'frequency' ? clamp(Math.log2(Math.max(30, frequency) / 30) / Math.log2(20000 / 30), 0, 1) : value; + const style = colorStyle(x, y, z, colorValue, settings.palette, settings.colorMode); + const c = this.color.setHSL(style.h, style.s, style.l).multiplyScalar(style.energy); + const fade = 0.16 + 0.84 * (1 - age) ** 0.7; + this.colors.set([c.r * fade, c.g * fade, c.b * fade], count * 3); + this.sizes[count] = size * (0.65 + fade * 0.35) * (1.15 + settings.glow * 0.25); count++; + }; + for (const f of history) { + const age = (now - f.time) / settings.trail; + if (age > 1 || age < 0) continue; + const z = 6 - age * 12, gain = settings.sensitivity; + if (this.mode === 'tone') { + for (const peak of f.peaks) { + const x = clamp(Math.log2(Math.max(20, peak.frequency) / 20) / Math.log2(1000), 0, 1) * 12 - 6; + add(x, clamp(Math.sqrt(peak.amplitude * gain) * 8, 0, 7), z, Math.sqrt(peak.amplitude * gain) * 2, age, 0.22 + Math.sqrt(peak.amplitude) * 0.3, peak.frequency); + } + } else { + const x = this.mode === 'spread' ? clamp(f.entropy, 0, 1) * 12 - 6 : clamp(f.deltaFrequency / 1000 * gain, -1, 1) * 6; + const y = this.mode === 'spread' ? clamp(f.spread / 8000 * gain, 0, 1) * 7 : 3.5 + clamp(f.deltaAmplitude * gain * 14, -3.5, 3.5); + add(x, y, z, Math.sqrt(f.rms * gain) * 1.85, age, 0.27 + Math.sqrt(f.rms * gain) * 0.5, f.centroid); + if (previous && f.time - previous.time < 0.2 && lineCount < 1798) { + this.linePositions.set([...previous.position, x, y, z], lineCount * 3); + const currentColor = this.colors.subarray((count - 1) * 3, count * 3); + this.lineColors.set(previous.color, lineCount * 3); + this.lineColors.set(currentColor, (lineCount + 1) * 3); lineCount += 2; + } + previous = { position: [x, y, z], time: f.time, color: this.colors.slice((count - 1) * 3, count * 3) }; + } + } + this.geometry.setDrawRange(0, count); this.lineGeo.setDrawRange(0, lineCount); + for (const attr of Object.values(this.geometry.attributes)) attr.needsUpdate = true; + for (const attr of Object.values(this.lineGeo.attributes)) attr.needsUpdate = true; + this.lines.visible = settings.connections && this.mode !== 'tone'; + this.controls.autoRotate = settings.rotate; + } + render(now, delta = 1 / 60) { + if (!this.host.clientWidth || !this.host.clientHeight) return; + if (this.mode === 'constellation') this.constellation?.animate(now); + const bounds = this.constellation?.frameBounds; + if (this.mode === 'constellation' && this.autoFrame && bounds) { + if (!this.framingEnvelope) this.framingEnvelope = { center: bounds.center.clone(), radius: bounds.radius, elapsed: 0 }; + const envelope = this.framingEnvelope; + envelope.elapsed += delta; + // Settle the centre once, then only widen the view when sound grows beyond it. + // A rolling trail must not continually pull the camera around or pump the zoom. + if (envelope.elapsed < 2) envelope.center.lerp(bounds.center, 1 - Math.exp(-delta / .7)); + envelope.radius = Math.max(envelope.radius, bounds.radius + bounds.center.distanceTo(envelope.center)); + const halfFov = this.camera.fov * Math.PI / 360; + const limitingFov = Math.min(halfFov, Math.atan(Math.tan(halfFov) * this.camera.aspect)); + const desiredDistance = clamp(envelope.radius / Math.sin(limitingFov) * 1.14, this.controls.minDistance, this.controls.maxDistance); + this.frameOffset.subVectors(this.camera.position, this.controls.target); + const blend = 1 - Math.exp(-delta / 1.2); + const distance = THREE.MathUtils.lerp(this.frameOffset.length(), desiredDistance, blend); + this.controls.target.lerp(envelope.center, blend); + this.camera.position.copy(this.controls.target).add(this.frameOffset.setLength(distance)); + } + this.controls.dampingFactor = 1 - Math.exp(-delta / .18); + this.controls.update(delta); this.renderer.render(this.scene, this.camera); + } + dispose() { this.resizeObserver.disconnect(); this.controls.dispose(); this.scene.traverse(o => { o.geometry?.dispose(); if (o.material) { o.material.map?.dispose(); o.material.dispose(); } }); this.renderer.dispose(); } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..d24d6f5 --- /dev/null +++ b/src/style.css @@ -0,0 +1,14 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&display=swap'); +:root{font-family:'DM Sans',Arial,sans-serif;color:#e9eaec;background:#070809;font-synthesis:none;--muted:#83868d;--border:#26282d;--accent:#f4c991;color-scheme:dark} +*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,a,input{touch-action:manipulation}button{font:inherit;color:inherit;cursor:pointer}button{background:none;border:1px solid var(--border);border-radius:8px}button:hover{border-color:#74777d;background-color:#ffffff07}button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:4px}button:disabled,input:disabled{opacity:.4;cursor:default}a{color:inherit;text-decoration:none}button,input{accent-color:var(--accent)} +#app{max-width:1800px;margin:auto;padding:0 36px}.topbar{height:94px;display:flex;align-items:center;border-bottom:1px solid var(--border);gap:40px}.brand{display:flex;align-items:center;gap:10px;font-size:32px;font-weight:650;letter-spacing:-1.5px}.brand svg{width:34px;fill:none;stroke:var(--accent);stroke-width:1.5}.brand-dot{color:var(--accent)}.tagline{font-size:13px;color:var(--muted)}.top-actions{margin-left:auto;display:flex;align-items:center;gap:24px}.local{font-size:12px;color:#aaacb0;display:flex;align-items:center;gap:8px}.local>span{width:5px;height:5px;border-radius:50%;background:#b0c8ab}.quiet{width:30px;height:30px;border-radius:50%;color:#a3a5ac}main{display:grid;grid-template-columns:244px minmax(0,1fr);gap:36px;padding-top:29px}.sidebar{padding-right:27px;border-right:1px solid var(--border)}.eyebrow{font-size:11px;letter-spacing:.05em;color:var(--accent);margin:6px 0 20px}.intro h1{font-size:40px;letter-spacing:-1.8px;line-height:1.12;font-weight:450;margin:0 0 19px}.intro>p:last-child{font-size:13px;line-height:1.8;color:var(--muted);margin-bottom:27px}.upload{width:100%;display:flex;flex-direction:column;align-items:center;border:1px dashed #4b4237;border-radius:10px;padding:20px 8px 17px;background:#c5996210;gap:7px}.upload-icon{font-size:28px;color:var(--accent);line-height:1;margin-bottom:3px}.upload strong{font-size:14px;font-weight:500}.upload>span:not(.upload-icon){font-size:11px;color:#a4a09b}.upload small{font-size:9px;color:#77766f;margin-top:6px}.demo-button{width:100%;border:0;font-size:11px;padding:16px 0;display:flex;gap:8px;align-items:center;text-align:left}.demo-button>span:first-child{font-size:18px;color:var(--accent)}.duration{margin-left:auto;color:#6f727a;font-size:10px}.settings{margin-top:23px;border-top:1px solid var(--border);padding-top:23px}.section-title{font-size:13px;display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}.text-button{font-size:10px;color:var(--muted);border:0;padding:4px}.range-label{display:flex;justify-content:space-between;font-size:11px;color:#aeb0b6;margin:22px 0 12px}.range-label output,.range-label>span{color:#747983;font-size:10px;font-variant-numeric:tabular-nums}input[type=range]{appearance:none;width:100%;height:3px;background:#37383c;border-radius:3px;margin:7px 0 9px;cursor:pointer;vertical-align:middle}input[type=range]::-webkit-slider-thumb{appearance:none;width:10px;height:10px;border-radius:50%;background:#e7c9a2}input[type=range]::-moz-range-thumb{width:10px;height:10px;border-radius:50%;border:none;background:#e7c9a2}.palettes{display:flex;gap:12px;margin:18px 0 26px}.swatch{height:23px;width:37px;border:3px solid #111216;border-radius:15px}.swatch.selected{outline:1px solid #a3a6af;outline-offset:3px}.ember{background:linear-gradient(90deg,#b92930,#ff974b,#fff6b0)}.spectrum{background:linear-gradient(90deg,#6935e5,#37aed1,#80d994,#ffba58)}.glacier{background:linear-gradient(90deg,#354bc1,#73e4ea)}.orchid{background:linear-gradient(90deg,#6024c7,#d47fb9,#f8c5ed)}.toggle-row{display:flex;align-items:center;position:relative;justify-content:space-between;font-size:11px;color:#aeb0b6;min-height:38px;cursor:pointer}.toggle-row input{position:absolute;right:0;opacity:0;width:29px;height:18px}.switch{width:27px;height:15px;border-radius:10px;background:#303238;pointer-events:none;position:relative}.switch:after{content:'';position:absolute;left:3px;top:3px;width:9px;height:9px;background:#898d93;border-radius:50%;transition:transform .15s}.toggle-row input:checked+.switch{background:#a18865}.toggle-row input:checked+.switch:after{background:#fff1db;transform:translateX(12px)}.toggle-row input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:3px}.privacy{font-size:10px;color:#626772;line-height:1.8;margin:29px 0 0}.workspace{min-width:0}.workspace-heading{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#8f929b;height:27px;margin-bottom:14px}.workspace-heading>div{display:flex;gap:8px;align-items:center}.status-dot{height:5px;width:5px;border-radius:50%;background:#b3946f}body.playing .status-dot{background:#b8d5ac}.plots{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(260px,1fr);gap:14px}.plot{border:1px solid var(--border);border-radius:12px;position:relative;overflow:hidden;background:radial-gradient(ellipse at 50% 55%,#13121850,transparent 70%),#090a0c}.main-plot{height:490px}.plot-heading{position:absolute;left:20px;right:20px;top:20px;display:flex;justify-content:space-between;align-items:flex-start;z-index:2;pointer-events:none}.plot-heading h2{font-size:13px;font-weight:500;margin:0 0 7px}.plot-heading p{font-size:10px;margin:0;color:#727782}.plot-actions{display:flex;gap:6px;pointer-events:auto}.plot-actions button{border:0;width:25px;height:25px;color:#9398a0;font-size:18px}.canvas-host{width:100%;height:100%;position:absolute;inset:0}.canvas-host canvas{display:block;touch-action:none}.plot-footer{position:absolute;bottom:16px;left:20px;right:20px;display:flex;align-items:center;justify-content:space-between;font-size:8px;color:#797f88;pointer-events:none;gap:10px}.legend{display:flex;align-items:center;gap:7px;white-space:nowrap}.legend i{display:inline-block;width:42px;height:3px;background:linear-gradient(90deg,#cb322c,#fff9b6)}.camera-hint{position:absolute;left:0;right:0;bottom:42px;text-align:center;font-size:9px;color:#515762;pointer-events:none}.camera-hint span{margin:0 7px}.secondary-plots{display:grid;grid-template-rows:1fr 1fr;gap:14px}.small-plot{min-height:238px}.small-plot .plot-heading{left:17px;right:17px;top:17px}.small-plot .plot-heading h2{font-size:12px}.small-plot .plot-heading p{font-size:9px}.small-plot .plot-footer{left:17px;right:17px;bottom:13px;font-size:7px}.mini-badge{font-size:11px;color:#6b717b}.spectrum-line{height:2px;width:42px;background:linear-gradient(90deg,#482dcd,#378cad,#72c884,#edb845)}.viewbar{display:flex;justify-content:space-between;align-items:center;margin:17px 0 22px;gap:12px}.view-tabs{display:flex;gap:3px;padding:3px;background:#101215;border:1px solid #22262c;border-radius:8px}.view-tabs button{border:0;font-size:11px;padding:8px 13px;color:#7b818c;border-radius:5px}.view-tabs button.active{color:#e7d6bf;background:#33302a}.view-note{font-size:10px;color:#626975}.metrics{display:flex;gap:0;align-items:center;padding:3px 0 22px;border-bottom:1px solid var(--border)}.metrics>div{flex:1;padding-left:22px;border-left:1px solid #252931}.metrics>div:first-child{padding-left:0;border:0}.metrics span{font-size:10px;color:#777d87;display:block;margin-bottom:10px}.metrics strong{font-size:21px;font-weight:400;letter-spacing:-.6px;font-variant-numeric:tabular-nums}.metrics small{font-size:10px;color:#6c737f;letter-spacing:0}.metrics canvas{width:19%;height:37px;margin-left:15px}.player{margin-top:21px;display:flex;align-items:center;gap:16px;padding:0 0 10px}.play{height:42px;width:42px;flex-shrink:0;border:0;border-radius:50%;color:#24221e;background:#e5c8a2;font-size:14px}.play:hover{background:#ffe0b8}.track{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1}.track strong{font-size:11px;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:240px}.track>span{font-size:9px;color:#6f7681}.timeline{display:flex;align-items:center;gap:10px;width:35%;font-size:9px;font-variant-numeric:tabular-nums;color:#989da5}.timeline input{margin:0}.volume{display:flex;width:92px;gap:8px;align-items:center}.volume button{border:0;color:#a8aeb7;font-size:17px;padding:0 3px}.volume input{margin:0}#message{min-height:17px;margin:0;font-size:11px;color:var(--accent)}.page-footer{display:flex;justify-content:space-between;font-size:10px;color:#575f6a;padding:24px 0 23px;margin-top:25px;border-top:1px solid var(--border)}.page-footer a:hover{color:#aaa}#drop-overlay{position:fixed;inset:0;z-index:100;background:#0b0d11ee;backdrop-filter:blur(10px);border:2px dashed var(--accent);place-items:center}#drop-overlay:not([hidden]){display:grid}#drop-overlay>div{font-size:40px;color:#e5c8a2;text-align:center}#drop-overlay span{display:block;font-size:16px;color:#a0a5ae;margin-top:15px}dialog{max-width:580px;background:#14171c;color:#c1c6cf;border:1px solid #353c46;border-radius:16px;padding:34px;line-height:1.7;font-size:13px}dialog h2{color:#eee;font-weight:500;font-size:24px}dialog::backdrop{background:#000a;backdrop-filter:blur(6px)}#close-help{float:right}.main-plot:fullscreen{height:100vh;width:100vw;background:#070809}.main-plot:fullscreen .plot-heading{top:30px;left:30px}.main-plot:fullscreen .plot-footer{bottom:30px} +@media(min-width:1500px){.main-plot{height:570px}.small-plot{min-height:278px}.sidebar{padding-right:32px}main{grid-template-columns:265px minmax(0,1fr)}} +@media(max-width:1150px){#app{padding:0 23px}main{gap:22px;grid-template-columns:213px minmax(0,1fr)}.sidebar{padding-right:20px}.plots{grid-template-columns:minmax(0,1fr)}.main-plot{height:390px}.secondary-plots{grid-template-columns:1fr 1fr;grid-template-rows:auto}.small-plot{height:220px;min-height:220px}.tagline{font-size:11px}.view-note{display:none}.metrics strong{font-size:19px}.metrics>div{padding-left:12px}.metrics canvas{width:15%}.timeline{width:31%}.volume{width:60px}.track strong{max-width:180px}.track>span{max-width:180px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}} +@media(max-width:760px){#app{padding:0 16px}.topbar{height:73px;gap:20px}.brand{font-size:27px}.brand svg{width:28px}.tagline{display:none}.top-actions{gap:14px}.local{font-size:10px}main{display:flex;flex-direction:column;padding-top:20px;gap:20px}.sidebar{border:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.intro{grid-column:1}.intro h1{font-size:31px;letter-spacing:-1.2px}.intro h1 br{display:none}.eyebrow{font-size:10px;margin:0 0 10px}.intro>p:last-child{font-size:11px;margin-bottom:10px}.upload{grid-column:2;grid-row:1;align-self:center;padding:12px 4px}.upload-icon{font-size:22px}.upload small{font-size:8px}.upload strong{font-size:12px}.upload>span:not(.upload-icon){font-size:9px}.demo-button{grid-column:2;grid-row:2;font-size:10px;padding:6px 0}.privacy{grid-row:2;grid-column:1;margin:4px 0;font-size:9px}.settings{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;column-gap:24px;padding-top:15px;margin-top:15px}.section-title{grid-column:1/-1;margin:0 0 8px}.range-label{margin:12px 0 5px}.range-label[for=sensitivity]{grid-row:2;grid-column:1}#sensitivity{grid-row:3;grid-column:1}.range-label[for=trail]{grid-row:2;grid-column:2}#trail{grid-row:3;grid-column:2}.palette-label{grid-row:4;grid-column:1}.palettes{grid-row:5;grid-column:1;margin:12px 0 5px;gap:9px}.swatch{width:30px;height:20px}.toggle-row{min-height:30px;font-size:10px}.toggle-row[for=rotate]{grid-column:2;grid-row:4}.toggle-row[for=connections]{grid-column:2;grid-row:5}.workspace-heading{margin:0 0 9px}.main-plot{height:390px}.plots{gap:10px}.secondary-plots{gap:10px}.small-plot{height:230px;min-height:230px}.small-plot .plot-heading{left:12px;right:12px;top:14px}.small-plot .plot-heading p{font-size:8px;max-width:115px;line-height:1.5}.small-plot .plot-footer{left:12px;right:12px;font-size:6px}.small-plot .mini-badge{display:none}.spectrum-line{display:none}.plot-heading{top:17px;left:16px;right:16px}.plot-heading h2{font-size:12px}.plot-footer{left:15px;right:15px;font-size:7px}.viewbar{margin:14px 0 20px}.metrics>div{padding-left:12px}.metrics strong{font-size:17px}.metrics span{font-size:9px}.metrics canvas{display:none}.player{flex-wrap:wrap;gap:12px}.track{flex:1}.track strong{max-width:220px}.track>span{max-width:220px}.timeline{order:4;width:100%;margin:8px 0}.volume{width:70px}.page-footer{font-size:8px;gap:20px;margin-top:10px;line-height:1.7}.page-footer a{max-width:190px;text-align:right}} +.appearance{margin:0 0 16px}.mapping-label{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:#aeb0b6}.mapping-label select{font:inherit;font-size:10px;color:#ded9ec;background:#18171f;border:1px solid #3a3545;border-radius:6px;padding:8px 4px;max-width:145px}.mapping-label select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}#color-description{font-size:9px;line-height:1.6;color:#82808e;margin:10px 0 0}.appearance .range-label{margin-top:15px} +@media(max-width:760px){.appearance{grid-column:1/-1;grid-row:6;margin:20px 0 0;display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.mapping-label{grid-column:1;grid-row:1;flex-direction:column;align-items:flex-start;gap:6px}.mapping-label select{width:100%;max-width:none}.appearance .range-label{grid-column:2;grid-row:1;margin:0;align-items:flex-start}.appearance #glow{grid-column:2;grid-row:1;align-self:end;margin-bottom:14px}#color-description{grid-column:1/-1;margin-top:9px}} +.constellation-view .plots{grid-template-columns:minmax(0,1fr)}.constellation-view .secondary-plots{display:none}.constellation-view .main-plot{height:clamp(520px,68vh,740px);background:#030405}.scene-hud,.scene-caption{display:none}.constellation-view .scene-hud{display:flex;flex-direction:column;gap:7px;position:absolute;top:67px;right:24px;font:10px monospace;color:#7c838d;pointer-events:none;text-align:right}.scene-hud b{font-weight:400;color:#c6c9cc;margin-left:10px}.constellation-view .scene-caption{display:flex;flex-direction:column;gap:6px;position:absolute;bottom:52px;left:24px;font:9px monospace;color:#6f7581;pointer-events:none}.constellation-view .plot-heading h2{font-size:15px;letter-spacing:.02em}.constellation-view .camera-hint{bottom:51px}.constellation-view .plot-footer{bottom:20px}.constellation-view .main-plot:fullscreen{height:100vh}.constellation-view .plot-heading p{color:#89828f} +@media(max-width:760px){.constellation-view .main-plot{height:520px;min-height:0}.constellation-view .scene-hud{right:17px;font-size:9px;top:70px}.constellation-view .scene-caption{left:16px;bottom:68px;font-size:8px}.constellation-view .camera-hint{bottom:40px}.constellation-view .plot-footer{font-size:6px;bottom:16px}.view-tabs{width:100%;justify-content:space-between}.view-tabs button{font-size:10px;padding:8px 9px}.toggle-row[for=labels]{grid-row:7;grid-column:1/-1;margin-top:10px}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}} + +.workspace-heading .performance-info{gap:14px;min-width:0}.performance-info #fps{font-variant-numeric:tabular-nums;white-space:nowrap;min-width:43px;text-align:right;color:#b8c7bc}.performance-info #sample-info{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:420px){.performance-info #sample-info{max-width:105px}} diff --git a/tests/analysis.test.js b/tests/analysis.test.js new file mode 100644 index 0000000..f15b74c --- /dev/null +++ b/tests/analysis.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyzeSpectrum } from '../src/analysis.js'; + +test('silence produces finite zero features and no peaks', () => { + const f = analyzeSpectrum(new Float32Array(32).fill(-Infinity), new Float32Array(64), 48000); + for (const key of ['rms', 'centroid', 'spread', 'entropy', 'flatness']) assert.equal(f[key], 0); + assert.deepEqual(f.peaks, []); +}); +test('a single spectral tone has its exact frequency and zero spread and entropy', () => { + const db = new Float32Array(32).fill(-Infinity); db[4] = -6; + const f = analyzeSpectrum(db, new Float32Array([0.5, -0.5]), 48000); + assert.equal(f.centroid, 3000); assert.equal(f.spread, 0); assert.equal(f.entropy, 0); + assert.equal(f.rms, 0.5); assert.equal(f.peaks[0].frequency, 3000); +}); +test('uniform spectral power has maximum normalized entropy and flatness', () => { + const f = analyzeSpectrum(new Float32Array(32).fill(-20), new Float32Array(64), 48000); + assert.ok(Math.abs(f.entropy - 1) < 1e-10); + assert.ok(Math.abs(f.flatness - 1) < 1e-10); + assert.ok(Math.abs(f.centroid - 11625) < 1e-8); +}); +test('centroid weights spectral power, not decibel values', () => { + const db = new Float32Array(4).fill(-Infinity); db[1] = 0; db[3] = -10; + const f = analyzeSpectrum(db, new Float32Array(8), 8000); + assert.ok(Math.abs(f.centroid - 1300 / 1.1) < 1e-7); +}); +test('nonfinite inputs cannot contaminate 3D coordinates', () => { + const f = analyzeSpectrum(new Float32Array([NaN, Infinity, -Infinity, -30]), new Float32Array([NaN, 0]), 48000); + for (const key of ['rms', 'centroid', 'spread', 'entropy', 'flatness']) assert.ok(Number.isFinite(f[key])); +}); diff --git a/tests/color.test.js b/tests/color.test.js new file mode 100644 index 0000000..c623cec --- /dev/null +++ b/tests/color.test.js @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { colorStyle } from '../src/color.js'; + +test('each spatial axis changes the color at equal loudness', () => { + const base = colorStyle(0, 3.5, 0, 0.5, 'spectrum', 'position'); + for (const position of [[3, 3.5, 0], [0, 6, 0], [0, 3.5, 4]]) { + const next = colorStyle(...position, 0.5, 'spectrum', 'position'); + assert.notEqual(next.h, base.h); + } +}); +test('loudness changes brightness without moving spatial hue', () => { + const quiet = colorStyle(0, 3.5, 0, 0.1, 'spectrum', 'position'); + const loud = colorStyle(0, 3.5, 0, 0.9, 'spectrum', 'position'); + assert.equal(quiet.h, loud.h); + assert.ok(loud.energy > quiet.energy); +}); +test('loudness mapping is independent of spatial position', () => { + assert.deepEqual(colorStyle(-6, 0, -6, 0.4, 'ember', 'amplitude'), colorStyle(6, 7, 6, 0.4, 'ember', 'amplitude')); +}); +test('all palette values remain finite and bounded at plot extremes', () => { + for (const palette of ['ember', 'spectrum', 'glacier', 'orchid']) { + for (const x of [-6, 0, 6]) for (const y of [0, 7]) for (const z of [-6, 6]) { + const c = colorStyle(x, y, z, 1, palette, 'position'); + for (const key of ['h', 's', 'l']) assert.ok(c[key] >= 0 && c[key] <= 1); + } + } +}); diff --git a/tests/constellation-render.test.js b/tests/constellation-render.test.js new file mode 100644 index 0000000..cba9c0d --- /dev/null +++ b/tests/constellation-render.test.js @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ConstellationLayer } from '../src/constellation.js'; + +test('rendered links keep correct endpoints after expiry, with labels fixed and fades independent of uploads', () => { + // Exercise real Three.js buffers without requiring a browser or GPU. + const originalDocument = globalThis.document; + globalThis.document = { createElement: () => ({ getContext: () => ({ clearRect() {}, fillText() {} }) }) }; + let layer; + try { + layer = new ConstellationLayer(); + const settings = { trail: 3, sensitivity: 1, palette: 'spectrum', colorMode: 'position', glow: 1.35, connections: true, labels: true }; + const frame = time => ({ time, rms: .2, entropy: .4, spread: 900, peaks: [{ frequency: 1500 + time * 10, amplitude: .2 }] }); + const history = Array.from({ length: 60 }, (_, i) => frame(i / 30)); + layer.update(history, 2, settings, 600); + const label = layer.labelPool.find(label => label.node); + assert.ok(label); + const labelNode = label.node; + const labelPosition = label.sprite.position.clone(); + const uploadVersion = layer.geometry.attributes.position.version; + layer.animate(2.01); + assert.equal(layer.uniforms.now.value, 2.01); + assert.equal(layer.lines.material.uniforms.now.value, 2.01); + assert.equal(layer.geometry.attributes.position.version, uploadVersion); + layer.update(history, 2.01, settings, 600); + assert.equal(layer.geometry.attributes.position.version, uploadVersion); + history.push(frame(2.05)); + layer.update(history, 2.05, settings, 600); + assert.equal(label.node, labelNode); + assert.deepEqual(label.sprite.position, labelPosition); + + // Several old points expire; line indices must still reference the surviving points. + history.push(frame(3.1)); + layer.update(history, 3.1, settings, 600); + assert.ok(layer.model.nodes.every(node => node.time > .1)); + assert.equal(layer.lineGeometry.instanceCount, layer.model.links.length); + assert.equal(layer.lineGeometry.index.count, 6); + assert.ok(layer.model.links.some(([, , temporal]) => temporal)); + for (const [i, [a, b]] of layer.model.links.entries()) { + assert.deepEqual(Array.from(layer.linePositions.slice(i * 6, i * 6 + 6)), Array.from(new Float32Array([...layer.model.nodes[a].position, ...layer.model.nodes[b].position]))); + } + settings.connections = false; + layer.update(history, 3.1, settings, 600); + assert.equal(layer.lines.visible, false); + layer.update([], 0, settings, 600); + assert.equal(layer.geometry.drawRange.count, 0); + assert.equal(layer.lineGeometry.instanceCount, 0); + assert.ok(layer.labelPool.every(label => !label.node && !label.sprite.visible)); + } finally { + layer?.traverse(object => { object.geometry?.dispose(); object.material?.map?.dispose(); object.material?.dispose(); }); + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); + +test('old annotations fade gradually to zero without another audio sample', () => { + const originalDocument=globalThis.document; + globalThis.document={createElement:()=>({getContext:()=>({clearRect(){},fillText(){}})})}; + let layer; + try { + layer=new ConstellationLayer(); + layer.update([{time:0,rms:.2,entropy:.4,spread:900,peaks:[{frequency:1500,amplitude:.2}]}],.2,{trail:6,sensitivity:1,palette:'spectrum',colorMode:'position',glow:1,connections:true,labels:true},600); + const label=layer.labelPool.find(label=>label.node); + const fresh=label.sprite.material.opacity; + layer.animate(3); + const halfway=label.sprite.material.opacity; + assert.ok(halfway{o.geometry?.dispose();o.material?.map?.dispose();o.material?.dispose();}); + if(originalDocument===undefined)delete globalThis.document;else globalThis.document=originalDocument; + } +}); diff --git a/tests/constellation.test.js b/tests/constellation.test.js new file mode 100644 index 0000000..eecab22 --- /dev/null +++ b/tests/constellation.test.js @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { makeNodes, connectNodes, ConstellationModel } from '../src/constellation-data.js'; + +const frame = (time, frequency = 1500) => ({time, rms: 0.2, entropy: 0.4, spread: 900, flatness: 0.03, peaks: [{frequency, amplitude: 0.2}]}); +const settings = {trail:12, sensitivity:1}; +test('constellation uses measured peak data and excludes expired or silent frames', () => { + const nodes = makeNodes([frame(-20), {...frame(0), rms:0}, frame(1)], 2, settings); + assert.equal(nodes.length, 1); + assert.equal(nodes[0].frequency, 1500); assert.equal(nodes[0].amplitude, 0.2); assert.equal(nodes[0].time, 1); +}); +test('node positions are stable as they age and respond to changed frequency', () => { + const node = makeNodes([frame(1)], 2, settings)[0]; + assert.deepEqual(node.position, makeNodes([frame(1)], 3, settings)[0].position); + assert.notDeepEqual(node.position, makeNodes([frame(1, 4000)], 2, settings)[0].position); +}); +test('dense tracks have a bounded point count and finite positions', () => { + const frames = Array.from({length:3000}, (_, i) => ({...frame(i / 100), peaks:Array.from({length:12}, (_, j) => ({frequency:200+j*400, amplitude:0.1}))})); + const nodes = makeNodes(frames, 30, {...settings, trail:30}); + assert.ok(nodes.length <= 2400); assert.ok(nodes.length > 100); + for (const node of nodes) assert.ok(node.position.every(Number.isFinite)); +}); +test('network connections are local, bounded and never self-connect', () => { + const nodes = [0, 1, 2, 50].map(x => ({position:[x,0,0]})); + const links = connectNodes(nodes); + assert.ok(links.length > 0); + for (const [a,b] of links) { assert.notEqual(a,b); assert.ok(Math.abs(nodes[a].position[0]-nodes[b].position[0]) < 3); } + assert.ok(links.length <= nodes.length * 3); +}); + +test('rolling history retains the identity, position and parents of surviving points', () => { + const model = new ConstellationModel(); + const history = Array.from({length:60}, (_,i) => frame(i/30,1500+i*2)); + const first = model.update(history,2,{...settings,trail:3}); + const survivor = first.nodes.find(n=>n.time>1); + const saved = {id:survivor.id,position:[...survivor.position],parents:[...survivor.parents]}; + history.splice(0,20); history.push(frame(2.1,1630)); + const next = model.update(history,2.1,{...settings,trail:3}); + const same = next.nodes.find(n=>n.id===saved.id); + assert.deepEqual(same.position,saved.position); assert.deepEqual(same.parents,saved.parents); +}); +test('frequency tracks survive peak loudness reordering and create continuous links', () => { + const model = new ConstellationModel(), history=[]; + history.push({...frame(0),peaks:[{frequency:1000,amplitude:.3},{frequency:2000,amplitude:.2}]}); + const first = model.update(history,0,settings); + const tone = first.nodes.find(n=>n.frequency===1000); + history.push({...frame(.05),peaks:[{frequency:2000,amplitude:.4},{frequency:1005,amplitude:.1}]}); + const next = model.update(history,.05,settings); + const continuation = next.nodes.find(n=>n.frequency===1005); + assert.equal(continuation.trackId,tone.trackId); + assert.ok(continuation.parents.includes(tone.id)); +}); +test('unchanged audio does not rebuild geometry or connections', () => { + const model = new ConstellationModel(), history=[frame(1)]; + const first=model.update(history,1,settings); + const next=model.update(history,1.01,settings); + assert.equal(next.revision,first.revision); + assert.equal(next.nodes,first.nodes); assert.equal(next.links,first.links); +}); +test('a new source resets tracks and never links back to the previous song', () => { + const model=new ConstellationModel(); model.update([frame(1)],1,settings); + const next=model.update([frame(2)],2,settings); + assert.equal(next.nodes.length,1); assert.deepEqual(next.nodes[0].parents,[]); +}); +test('new samples are smoothly connected through sudden amplitude changes', () => { + const model=new ConstellationModel(), history=[{...frame(0),peaks:[{frequency:1500,amplitude:.01}]}]; + const start=model.update(history,0,settings).nodes[0]; + history.push({...frame(.05),peaks:[{frequency:1500,amplitude:.8}]}); + const next=model.update(history,.05,settings).nodes.at(-1); + const raw=makeNodes([history[1]],.05,settings)[0]; + const distance = (a,b) => Math.hypot(...a.position.map((v,i)=>v-b.position[i])); + assert.ok(distance(start,next) { + const model=new ConstellationModel(), history=[frame(0,700)]; + const first=model.update(history,0,settings).nodes[0]; + history.push(frame(.1,6000)); + const next=model.update(history,.1,settings); + assert.ok(next.nodes.at(-1).parents.includes(first.id)); + assert.ok(next.links.some(([, , trail])=>trail)); +}); + +test('sound paths break across silence and all points and links expire', () => { + const model=new ConstellationModel(), history=[frame(0,700),frame(1,6000)]; + const result=model.update(history,1,settings); + assert.equal(result.nodes.at(-1).trailParent,null); + const expired=model.update(history,14,settings); + assert.equal(expired.nodes.length,0); + assert.equal(expired.links.length,0); +});