diff --git a/.gitignore b/.gitignore index 15348aa..fd242ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ npm-debug.log* !.env.example *.tsbuildinfo coverage + +# Generated local previews only (not for the repo). Regenerate: npm run generate:ambient-demos +public/audio/ambient-demos/*.mp3 diff --git a/package-lock.json b/package-lock.json index 0616590..20e1be6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,27 @@ "d3-scale": "^4.0.2" }, "devDependencies": { + "ffmpeg-static": "^5.3.0", "typescript": "~5.7.2", "vite": "^6.0.3" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -855,6 +872,49 @@ "dev": true, "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -937,6 +997,34 @@ "node": ">=12" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -997,6 +1085,23 @@ } } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "dev": true, + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1012,6 +1117,44 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -1021,6 +1164,13 @@ "node": ">=12" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1040,6 +1190,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1089,6 +1245,31 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -1134,6 +1315,27 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1144,6 +1346,16 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1161,6 +1373,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -1175,6 +1394,13 @@ "node": ">=14.17" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 4c93499..9053e8d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "generate:ambient-demos": "node scripts/generate-ambient-example-mp3s.mjs" }, "dependencies": { "d3-color": "^3.1.0", @@ -15,6 +16,7 @@ "d3-scale": "^4.0.2" }, "devDependencies": { + "ffmpeg-static": "^5.3.0", "typescript": "~5.7.2", "vite": "^6.0.3" } diff --git a/scripts/generate-ambient-example-mp3s.mjs b/scripts/generate-ambient-example-mp3s.mjs new file mode 100644 index 0000000..f1adbb4 --- /dev/null +++ b/scripts/generate-ambient-example-mp3s.mjs @@ -0,0 +1,190 @@ +/** + * Offline approximations of the in-app ambient (see src/ambient-sound.ts): + * deep drifting triad ~65 Hz, optional phrase motion, brown-noise “fluid”. + * Not a bit-accurate capture of the Web Audio graph — for quick A/B listening only. + * + * Requires devDependency `ffmpeg-static` (bundled ffmpeg with libmp3lame). + */ +import { spawnSync } from "node:child_process"; +import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import ffmpegPath from "ffmpeg-static"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = join(__dirname, "..", "public", "audio", "ambient-demos"); + +const SR = 44100; +const DURATION_S = 28; + +/** C2 root from ambient-sound.ts */ +const ROOT_HZ = 65.41; + +function hzFromSemitones(root, semi) { + return root * 2 ** (semi / 12); +} + +function floatTo16(samples) { + const out = new Int16Array(samples.length); + for (let i = 0; i < samples.length; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); + out[i] = s < 0 ? Math.round(s * 32768) : Math.round(s * 32767); + } + return out; +} + +function normalizePeak(floats, peak = 0.92) { + let m = 0; + for (let i = 0; i < floats.length; i++) { + const a = Math.abs(floats[i]); + if (a > m) m = a; + } + if (m < 1e-8) return floats; + const g = peak / m; + const out = new Float32Array(floats.length); + for (let i = 0; i < floats.length; i++) out[i] = floats[i] * g; + return out; +} + +function encodePcmToMp3(pcmS16le, outFile) { + const wavPath = `${outFile}.tmp.wav`; + writeWavFile(wavPath, pcmS16le); + const r = spawnSync( + ffmpegPath, + [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + wavPath, + "-c:a", + "libmp3lame", + "-q:a", + "2", + outFile, + ], + { encoding: "utf8" }, + ); + unlinkSync(wavPath); + if (r.status !== 0) { + throw new Error(r.stderr || `ffmpeg exited ${r.status}`); + } +} + +function writeWavFile(path, samples) { + const dataSize = samples.length * 2; + const buf = Buffer.alloc(44 + dataSize); + buf.write("RIFF", 0); + buf.writeUInt32LE(36 + dataSize, 4); + buf.write("WAVE", 8); + buf.write("fmt ", 12); + buf.writeUInt32LE(16, 16); + buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); + buf.writeUInt32LE(SR, 24); + buf.writeUInt32LE(SR * 2, 28); + buf.writeUInt16LE(2, 32); + buf.writeUInt16LE(16, 34); + buf.write("data", 36); + buf.writeUInt32LE(dataSize, 40); + for (let i = 0; i < samples.length; i++) { + buf.writeInt16LE(samples[i], 44 + i * 2); + } + writeFileSync(path, buf); +} + +function renderTriadDrift() { + const n = Math.floor(SR * DURATION_S); + const out = new Float32Array(n); + const f0 = ROOT_HZ; + const f1 = hzFromSemitones(ROOT_HZ, 3); + const f2 = hzFromSemitones(ROOT_HZ, 7); + for (let i = 0; i < n; i++) { + const t = i / SR; + const w0 = 0.14 + 0.1 * Math.sin(2 * Math.PI * 0.055 * t); + const w1 = 0.12 + 0.09 * Math.sin(2 * Math.PI * 0.062 * t + 1.7); + const w2 = 0.1 + 0.08 * Math.sin(2 * Math.PI * 0.048 * t + 3.1); + const d0 = 1 + 0.004 * Math.sin(2 * Math.PI * 0.11 * t); + const d1 = 1 + 0.005 * Math.sin(2 * Math.PI * 0.13 * t + 0.8); + const d2 = 1 + 0.0035 * Math.sin(2 * Math.PI * 0.09 * t + 2.2); + let s = 0; + s += w0 * Math.sin(2 * Math.PI * f0 * d0 * t); + s += w1 * Math.sin(2 * Math.PI * f1 * d1 * t + 0.4); + s += w2 * Math.sin(2 * Math.PI * f2 * d2 * t + 1.1); + out[i] = s * 0.45; + } + return normalizePeak(out); +} + +function renderPhraseAndDelay() { + const n = Math.floor(SR * DURATION_S); + const out = new Float32Array(n); + const phraseSemi = [0, 3, 7, 12, 7, 3, 5, 0]; + const stepS = 1.35; + const delaySamples = Math.round(0.42 * SR); + const dry = new Float32Array(n); + const f0 = ROOT_HZ; + const f1 = hzFromSemitones(ROOT_HZ, 3); + const f2 = hzFromSemitones(ROOT_HZ, 7); + for (let i = 0; i < n; i++) { + const t = i / SR; + const step = Math.floor(t / stepS) % phraseSemi.length; + const lead = hzFromSemitones(ROOT_HZ, phraseSemi[step]); + const glide = 0.02 * Math.sin(2 * Math.PI * 2.2 * t); + const wTri = 0.1 + 0.06 * Math.sin(2 * Math.PI * 0.05 * t); + let s = 0; + s += wTri * 0.35 * Math.sin(2 * Math.PI * f0 * t); + s += wTri * 0.32 * Math.sin(2 * Math.PI * f1 * t + 0.5); + s += wTri * 0.28 * Math.sin(2 * Math.PI * f2 * t + 1.0); + s += 0.14 * Math.sin(2 * Math.PI * lead * (1 + glide) * t + 2.2); + dry[i] = s; + } + for (let i = 0; i < n; i++) { + const wet = i >= delaySamples ? dry[i - delaySamples] * 0.42 : 0; + const wet2 = i >= delaySamples * 2 ? dry[i - delaySamples * 2] * 0.22 : 0; + out[i] = dry[i] * 0.72 + wet + wet2; + } + return normalizePeak(out); +} + +function renderFluidBed() { + const n = Math.floor(SR * DURATION_S); + const out = new Float32Array(n); + let brown = 0; + let lp = 0; + const f0 = ROOT_HZ; + const f1 = hzFromSemitones(ROOT_HZ, 3); + for (let i = 0; i < n; i++) { + const t = i / SR; + brown += (Math.random() * 2 - 1) * 0.045; + brown *= 0.9985; + lp = lp * 0.985 + brown * 0.015; + const chop = 0.08 + 0.06 * Math.sin(2 * Math.PI * 0.31 * t) * Math.sin(2 * Math.PI * 0.07 * t); + const fluid = lp * chop; + const sub = + 0.1 * Math.sin(2 * Math.PI * f0 * t) + 0.07 * Math.sin(2 * Math.PI * f1 * t + 0.6); + out[i] = fluid * 0.55 + sub * 0.35; + } + return normalizePeak(out); +} + +if (!ffmpegPath) { + console.error("ffmpeg-static: binary path missing (re-run npm install)."); + process.exit(1); +} + +mkdirSync(OUT_DIR, { recursive: true }); + +const variants = [ + { name: "ambient-demo-01-triad-drift.mp3", render: renderTriadDrift }, + { name: "ambient-demo-02-phrase-delay.mp3", render: renderPhraseAndDelay }, + { name: "ambient-demo-03-fluid-bed.mp3", render: renderFluidBed }, +]; + +for (const { name, render } of variants) { + const pcm = floatTo16(normalizePeak(render())); + const path = join(OUT_DIR, name); + encodePcmToMp3(pcm, path); + console.log(`Wrote ${path}`); +} diff --git a/src/ambient-harmony.ts b/src/ambient-harmony.ts new file mode 100644 index 0000000..b9b9614 --- /dev/null +++ b/src/ambient-harmony.ts @@ -0,0 +1,164 @@ +/** + * Diatonic harmony for ambient flow + melody ripple (movable tonic). + * **Major:** I, ii, iii, IV, V, vi. **Natural minor:** i, iv, v, VI, III, VII (no ii° in the sub register). + * Picks chords that include the melody pitch when possible, smooth root motion, phrase/cadence bias. + */ + +import type { ScaleMode } from "./color-music"; + +export const AMBIENT_ROOT_HZ = 65.41; + +export type DiatonicTriad = { + readonly roman: string; + readonly rootPc: number; + readonly thirdPc: number; + readonly fifthPc: number; + readonly quality: "major" | "minor"; +}; + +function mod12(n: number): number { + return ((n % 12) + 12) % 12; +} + +/** Major-key diatonic triads; pitch classes are absolute (0–11). */ +export function triadsInMajorKey(tonicPc: number): readonly DiatonicTriad[] { + const t = mod12(tonicPc); + return [ + { roman: "I", rootPc: t, thirdPc: mod12(t + 4), fifthPc: mod12(t + 7), quality: "major" }, + { roman: "ii", rootPc: mod12(t + 2), thirdPc: mod12(t + 5), fifthPc: mod12(t + 9), quality: "minor" }, + { roman: "iii", rootPc: mod12(t + 4), thirdPc: mod12(t + 7), fifthPc: mod12(t + 11), quality: "minor" }, + { roman: "IV", rootPc: mod12(t + 5), thirdPc: mod12(t + 9), fifthPc: t, quality: "major" }, + { roman: "V", rootPc: mod12(t + 7), thirdPc: mod12(t + 11), fifthPc: mod12(t + 2), quality: "major" }, + { roman: "vi", rootPc: mod12(t + 9), thirdPc: t, fifthPc: mod12(t + 4), quality: "minor" }, + ] as const; +} + +/** Natural-minor diatonic triads (Aeolian); omits diminished ii° for a smoother low pad. */ +export function triadsInNaturalMinorKey(tonicPc: number): readonly DiatonicTriad[] { + const t = mod12(tonicPc); + return [ + { roman: "i", rootPc: t, thirdPc: mod12(t + 3), fifthPc: mod12(t + 7), quality: "minor" }, + { roman: "iv", rootPc: mod12(t + 5), thirdPc: mod12(t + 8), fifthPc: t, quality: "minor" }, + { roman: "v", rootPc: mod12(t + 7), thirdPc: mod12(t + 10), fifthPc: mod12(t + 2), quality: "minor" }, + { roman: "VI", rootPc: mod12(t + 8), thirdPc: t, fifthPc: mod12(t + 3), quality: "major" }, + { roman: "III", rootPc: mod12(t + 3), thirdPc: mod12(t + 7), fifthPc: mod12(t + 10), quality: "major" }, + { roman: "VII", rootPc: mod12(t + 10), thirdPc: mod12(t + 2), fifthPc: mod12(t + 5), quality: "major" }, + ] as const; +} + +export function triadsForKey(tonicPc: number, mode: ScaleMode): readonly DiatonicTriad[] { + return mode === "naturalMinor" ? triadsInNaturalMinorKey(tonicPc) : triadsInMajorKey(tonicPc); +} + +export function triadContainsMelody(t: DiatonicTriad, melodyPc: number): boolean { + const m = mod12(melodyPc); + return m === mod12(t.rootPc) || m === mod12(t.thirdPc) || m === mod12(t.fifthPc); +} + +function circleDist(a: number, b: number): number { + const d = Math.abs(mod12(a) - mod12(b)); + return Math.min(d, 12 - d); +} + +export type PickChordContext = { + tonicPc: number; + melodyPc: number; + previous: DiatonicTriad | null; + chordAgeNotes: number; + minHoldNotes: number; + /** True when this step is the first note of a phrase (after advance). */ + phraseJustStarted: boolean; + /** True when this step is the last note of the phrase (before advance). */ + phraseClosing: boolean; + scaleMode: ScaleMode; +}; + +function cadenceScoreMajor(c: DiatonicTriad, ctx: PickChordContext): number { + let score = 0; + if (ctx.phraseJustStarted && c.roman === "I") score += 2.4; + if (ctx.phraseClosing && (c.roman === "V" || c.roman === "IV")) score += 1.6; + if (ctx.phraseClosing && c.roman === "I") score -= 1.2; + if (c.roman === "I" || c.roman === "IV") score += 0.35; + if (c.roman === "iii") score -= 0.25; + return score; +} + +function cadenceScoreMinor(c: DiatonicTriad, ctx: PickChordContext): number { + let score = 0; + if (ctx.phraseJustStarted && c.roman === "i") score += 2.4; + if (ctx.phraseClosing && (c.roman === "v" || c.roman === "iv" || c.roman === "VII")) score += 1.6; + if (ctx.phraseClosing && c.roman === "i") score -= 1.2; + if (c.roman === "i" || c.roman === "iv") score += 0.35; + if (c.roman === "III") score -= 0.2; + return score; +} + +/** + * Pick a triad that includes the melody pitch class when possible, with voice-leading + * and light cadential bias (mode-aware). + */ +export function pickDiatonicTriad(ctx: PickChordContext): DiatonicTriad { + const bank = triadsForKey(ctx.tonicPc, ctx.scaleMode); + const prevRoot = ctx.previous?.rootPc ?? null; + + const fits = bank.filter((c) => triadContainsMelody(c, ctx.melodyPc)); + const candidates = fits.length > 0 ? fits : [...bank]; + + let best: DiatonicTriad = candidates[0]!; + let bestScore = -Infinity; + + for (const c of candidates) { + let score = 0; + if (triadContainsMelody(c, ctx.melodyPc)) score += 6; + + if (prevRoot != null) { + score -= 1.15 * circleDist(prevRoot, c.rootPc); + } + + score += + ctx.scaleMode === "naturalMinor" + ? cadenceScoreMinor(c, ctx) + : cadenceScoreMajor(c, ctx); + + if (ctx.previous && ctx.chordAgeNotes < ctx.minHoldNotes) { + if (c.rootPc === ctx.previous.rootPc && triadContainsMelody(c, ctx.melodyPc)) { + score += 5; + } + } + + if (score > bestScore) { + bestScore = score; + best = c; + } + } + + return best; +} + +/** Hz for a pitch class in the same deep register as `AMBIENT_ROOT_HZ` (pc 0 = C). */ +export function hzFromPitchClass(pc: number): number { + return AMBIENT_ROOT_HZ * Math.pow(2, mod12(pc) / 12); +} + +/** Interval in semitones from chord root to the upper chord tone (major or minor third). */ +export function triadThirdSemitonesFromRoot(quality: "major" | "minor"): number { + return quality === "major" ? 4 : 3; +} + +/** + * Next chord tone strictly above `melodyPc` within an octave class (for ripple consonance). + */ +export function rippleSemitonesToNextChordTone(melodyPc: number, triad: DiatonicTriad): number { + const m = mod12(melodyPc); + const tones = [ + mod12(triad.rootPc), + mod12(triad.thirdPc), + mod12(triad.fifthPc), + ].sort((a, b) => a - b); + + for (const t of tones) { + if (t > m) return t - m; + } + const lowest = tones[0]!; + return lowest + 12 - m; +} diff --git a/src/ambient-sound.ts b/src/ambient-sound.ts index 8dcd742..e133b33 100644 --- a/src/ambient-sound.ts +++ b/src/ambient-sound.ts @@ -8,10 +8,24 @@ * No high-band oscillators; master low-pass keeps everything dark and smooth. */ +import { + hzFromPitchClass, + pickDiatonicTriad, + rippleSemitonesToNextChordTone, + triadContainsMelody, + triadThirdSemitonesFromRoot, + triadsForKey, + type DiatonicTriad, + AMBIENT_ROOT_HZ, +} from "./ambient-harmony"; import { getPhraseDegrees, getPhraseCount } from "./ambient-phrases"; import { centroidSemitoneFromWeights, + inferScaleModeFromVisuals, semitonesFromWeightsAndMask, + tonicPitchClassFromAnalysis, + topWeightedPitchClasses, + type ScaleMode, } from "./color-music"; import { sampleStandingWaveField, @@ -47,10 +61,21 @@ export type AmbientHints = { plateRms?: number; /** Share of canvas samples with ink (strided) */ plateCoverage?: number; + /** + * `phrase` — stepped melody from `ambient-phrases` through the color pool. + * `blend` (default) — **continuous** pitch from weighted color centroid + up to three + * quiet partials for the strongest presets (literal “combination” tones). + */ + colorMelodyMode?: "phrase" | "blend"; + /** Override diatonic collection: major vs natural minor (default: inferred from canvas). */ + scaleMode?: ScaleMode; }; /** C2 — deep register; melody and flow stay sub / low-mid only */ -const ROOT_HZ = 65.41; +const ROOT_HZ = AMBIENT_ROOT_HZ; + +/** Default: centroid + multi-partial “blend”; set hints.colorMelodyMode to `"phrase"` for the old line. */ +const DEFAULT_COLOR_MELODY_MODE: "phrase" | "blend" = "blend"; function getAudioContextCtor(): typeof AudioContext | null { const w = window as typeof window & { webkitAudioContext?: typeof AudioContext }; @@ -91,6 +116,13 @@ export function createAmbientSound(): { let fluidLp: BiquadFilterNode | null = null; let fluidGain: GainNode | null = null; + let comboOsc0: OscillatorNode | null = null; + let comboGain0: GainNode | null = null; + let comboOsc1: OscillatorNode | null = null; + let comboGain1: GainNode | null = null; + let comboOsc2: OscillatorNode | null = null; + let comboGain2: GainNode | null = null; + const toStop: AudioScheduledSourceNode[] = []; let enabled = false; @@ -111,6 +143,11 @@ export function createAmbientSound(): { let noteInPhrase = 0; let lastNoteMs = 0; let lastMelodyHz = ROOT_HZ; + let lastMelodyPc = 0; + let harmonyTriad: DiatonicTriad = triadsForKey(0, "major")[0]!; + let chordAgeNotes = 0; + let lastChordPickMs = 0; + let lastFlowRootHz = ROOT_HZ; let lastDriveNow = performance.now(); let flowPhaseAccum = 0; @@ -164,7 +201,7 @@ export function createAmbientSound(): { flowOsc2 = ctx.createOscillator(); flowOsc2.type = "sine"; - flowOsc2.frequency.value = ROOT_HZ * 1.498; + flowOsc2.frequency.value = ROOT_HZ * Math.pow(2, 4 / 12); flowGain2 = ctx.createGain(); flowGain2.gain.value = 0.028; flowOsc2.connect(flowGain2); @@ -207,6 +244,36 @@ export function createAmbientSound(): { melodyRippleOsc.start(); toStop.push(melodyRippleOsc); + comboOsc0 = ctx.createOscillator(); + comboOsc0.type = "sine"; + comboOsc0.frequency.value = ROOT_HZ; + comboGain0 = ctx.createGain(); + comboGain0.gain.value = 0; + comboOsc0.connect(comboGain0); + comboGain0.connect(mix); + comboOsc0.start(); + toStop.push(comboOsc0); + + comboOsc1 = ctx.createOscillator(); + comboOsc1.type = "sine"; + comboOsc1.frequency.value = ROOT_HZ; + comboGain1 = ctx.createGain(); + comboGain1.gain.value = 0; + comboOsc1.connect(comboGain1); + comboGain1.connect(mix); + comboOsc1.start(); + toStop.push(comboOsc1); + + comboOsc2 = ctx.createOscillator(); + comboOsc2.type = "sine"; + comboOsc2.frequency.value = ROOT_HZ; + comboGain2 = ctx.createGain(); + comboGain2.gain.value = 0; + comboOsc2.connect(comboGain2); + comboGain2.connect(mix); + comboOsc2.start(); + toStop.push(comboOsc2); + const nSamp = Math.max(2048, Math.floor(ctx.sampleRate * 1.25)); const nb = ctx.createBuffer(1, nSamp, ctx.sampleRate); const nd = nb.getChannelData(0); @@ -253,6 +320,11 @@ export function createAmbientSound(): { lastMelodyHz = ROOT_HZ; lastDriveNow = performance.now(); flowPhaseAccum = 0; + harmonyTriad = triadsForKey(0, "major")[0]!; + chordAgeNotes = 0; + lastChordPickMs = performance.now(); + lastFlowRootHz = ROOT_HZ; + lastMelodyPc = 0; const t = ctx.currentTime; melodyOsc.frequency.setValueAtTime(ROOT_HZ, t); if (melodyRippleOsc) { @@ -295,6 +367,12 @@ export function createAmbientSound(): { !melodyDelayFb || !melodyRippleOsc || !melodyRippleGain || + !comboOsc0 || + !comboGain0 || + !comboOsc1 || + !comboGain1 || + !comboOsc2 || + !comboGain2 || !fluidLp || !fluidGain ) { @@ -312,13 +390,16 @@ export function createAmbientSound(): { const mask = hints.colorPresetMask ?? 0; const weights = hints.colorPresetWeights; - const pool = semitonesFromWeightsAndMask(weights, mask); + const scaleMode: ScaleMode = + hints.scaleMode ?? + inferScaleModeFromVisuals(hints.canvasBrightness, hints.centerGraySalt); + const pool = semitonesFromWeightsAndMask(weights, mask, 0.052, scaleMode); const k = Math.max(1, Math.min(7, hints.colorDistinctCount ?? pool.length)); const distinctForWave = Math.max(1, hints.colorDistinctCount ?? pool.length); const centroidRaw = weights && weights.length >= 7 - ? centroidSemitoneFromWeights(weights, mask) + ? centroidSemitoneFromWeights(weights, mask, scaleMode) : pool.reduce((a, b) => a + b, 0) / Math.max(1, pool.length); smoothCentroidSemi = smoothCentroidSemi * 0.92 + centroidRaw * 0.08; @@ -373,45 +454,6 @@ export function createAmbientSound(): { smoothWaveVel = smoothWaveVel * 0.8 + Math.min(2.5, Math.abs(velListen)) * 0.2; - /** Flow: slow water-like drift, all energy sub ~200 Hz before filter */ - const drift0 = 3 * Math.sin(now * 0.00022); - const drift1 = 3.5 * Math.sin(now * 0.00027 + 1.1); - const drift2 = 2.5 * Math.sin(now * 0.00024 + 2.2); - const baseHz = ROOT_HZ * Math.pow(2, smoothCentroidSemi / 12); - flowPhaseAccum += - 2 * - Math.PI * - baseHz * - dtSec * - (1 + 0.13 * smoothWaveField + 0.09 * smoothPlateRms) + - 2 * Math.PI * lastMelodyHz * dtSec * 0.065; - if (flowPhaseAccum > 12_000) { - flowPhaseAccum -= Math.floor(flowPhaseAccum / (2 * Math.PI)) * 2 * Math.PI; - } - - flowOsc0.frequency.setTargetAtTime(baseHz * 0.5, t, 0.55); - flowOsc1.frequency.setTargetAtTime(baseHz, t, 0.5); - flowOsc2.frequency.setTargetAtTime(baseHz * 1.498307077, t, 0.48); - const plateDetune = smoothPlateMean * 4.2 + smoothPlateRms * 2.8; - flowOsc0.detune.setTargetAtTime(drift0 + smoothMotion * 4 + plateDetune * 0.35, t, 0.25); - flowOsc1.detune.setTargetAtTime( - drift1 - smoothBright * 3 + plateDetune * 0.55, - t, - 0.25, - ); - flowOsc2.detune.setTargetAtTime(drift2 + smoothEnergy * 3.5 + plateDetune * 0.4, t, 0.25); - - const flowBed = (0.28 + 0.72 * presence) * 0.95; - const grayFlowBoost = hints.centerGraySalt ? 1.07 : 1; - /** Standing-wave nodal/antinodal breathing + stroke–plate overlap */ - const plateGain = 1 + smoothPlateCov * 0.14 + smoothPlateRms * 0.11; - const platePhase = 1 + smoothPlateMean * 0.1; - const wMod = (0.86 + 0.22 * (0.5 + 0.5 * fCenter)) * platePhase; - const wMod2 = (0.9 + 0.18 * (0.5 + 0.5 * smoothWaveField)) * plateGain; - flowGain0.gain.setTargetAtTime(0.038 * flowBed * wMod, t, 0.18); - flowGain1.gain.setTargetAtTime(0.032 * flowBed * grayFlowBoost * wMod2, t, 0.18); - flowGain2.gain.setTargetAtTime(0.026 * flowBed * wMod * (0.97 + smoothPlateRms * 0.06), t, 0.18); - /** Filtered noise: “fluid” agitation — stronger where strokes ride the mode */ const plateFluid = 0.42 + @@ -450,26 +492,153 @@ export function createAmbientSound(): { 1100 - activity * 260 - (k - 1) * 20 - smoothPlateRms * 70, ); - if (now - lastNoteMs >= stepMs) { - lastNoteMs = now; - const phrase = getPhraseDegrees(phraseIndex); - const deg = phrase[noteInPhrase] ?? 0; - noteInPhrase++; - if (noteInPhrase >= phrase.length) { - noteInPhrase = 0; - phraseIndex = (phraseIndex + 1) % getPhraseCount(); + const melodyMode = hints.colorMelodyMode ?? DEFAULT_COLOR_MELODY_MODE; + const tonicPc = tonicPitchClassFromAnalysis(mask, weights, scaleMode); + + if (melodyMode === "phrase") { + if (now - lastNoteMs >= stepMs) { + lastNoteMs = now; + const phrase = getPhraseDegrees(phraseIndex); + const phraseLen = phrase.length; + const wasAtPhraseStart = noteInPhrase === 0; + const phraseClosing = phraseLen > 0 && noteInPhrase >= phraseLen - 1; + const deg = phrase[noteInPhrase] ?? 0; + noteInPhrase++; + if (noteInPhrase >= phrase.length) { + noteInPhrase = 0; + phraseIndex = (phraseIndex + 1) % getPhraseCount(); + } + const phraseJustStarted = wasAtPhraseStart; + + const pl = pool.length; + const poolIdx = pl > 0 ? deg % pl : 0; + const melodyPc = ((((pool[poolIdx] ?? 0) % 12) + 12) % 12) as number; + lastMelodyPc = melodyPc; + + const minHold = 2; + const mustRepick = + chordAgeNotes >= minHold || !triadContainsMelody(harmonyTriad, melodyPc); + if (mustRepick) { + const prev = harmonyTriad; + harmonyTriad = pickDiatonicTriad({ + tonicPc, + melodyPc, + previous: prev, + chordAgeNotes, + minHoldNotes: minHold, + phraseJustStarted, + phraseClosing, + scaleMode, + }); + if ( + harmonyTriad.rootPc !== prev.rootPc || + harmonyTriad.roman !== prev.roman + ) { + chordAgeNotes = 0; + } + } + chordAgeNotes++; + + let semi = pool[poolIdx] ?? 0; + const frac = smoothCentroidSemi - Math.floor(smoothCentroidSemi); + semi += frac * 0.08 + smoothPlateMean * 0.045; + /** No octave up — keep phrase entirely in the deep register */ + const hz = ROOT_HZ * Math.pow(2, semi / 12); + lastMelodyHz = hz; + melodyOsc.frequency.setTargetAtTime(hz, t, 0.18); + } + } else { + const melodyPc = ((Math.round(smoothCentroidSemi) % 12) + 12) % 12; + lastMelodyPc = melodyPc; + + const minChordMs = 1320; + const mustRepick = + !triadContainsMelody(harmonyTriad, melodyPc) || + now - lastChordPickMs >= minChordMs; + if (mustRepick) { + const prev = harmonyTriad; + harmonyTriad = pickDiatonicTriad({ + tonicPc, + melodyPc, + previous: prev, + chordAgeNotes: Math.min(12, Math.floor((now - lastChordPickMs) / 200)), + minHoldNotes: 2, + phraseJustStarted: false, + phraseClosing: false, + scaleMode, + }); + if ( + harmonyTriad.rootPc !== prev.rootPc || + harmonyTriad.roman !== prev.roman + ) { + chordAgeNotes = 0; + } + lastChordPickMs = now; } - const pl = pool.length; - const poolIdx = pl > 0 ? deg % pl : 0; - let semi = pool[poolIdx] ?? 0; - const frac = smoothCentroidSemi - Math.floor(smoothCentroidSemi); - semi += frac * 0.35 + smoothPlateMean * 0.14; - /** No octave up — keep phrase entirely in the deep register */ - const hz = ROOT_HZ * Math.pow(2, semi / 12); - lastMelodyHz = hz; - melodyOsc.frequency.setTargetAtTime(hz, t, 0.18); + + const semi = + smoothCentroidSemi + + smoothPlateMean * 0.055 + + smoothWaveField * 0.035; + lastMelodyHz = ROOT_HZ * Math.pow(2, semi / 12); + melodyOsc.frequency.setTargetAtTime(lastMelodyHz, t, 0.42); + } + + /** Flow: diatonic triad (sub, root, third) + smooth centroid lean; phase tracks chord root */ + const drift0 = 3 * Math.sin(now * 0.00022); + const drift1 = 3.5 * Math.sin(now * 0.00027 + 1.1); + const drift2 = 2.5 * Math.sin(now * 0.00024 + 2.2); + const rootHz = hzFromPitchClass(harmonyTriad.rootPc); + const thirdMul = Math.pow( + 2, + triadThirdSemitonesFromRoot(harmonyTriad.quality) / 12, + ); + lastFlowRootHz = rootHz; + flowPhaseAccum += + 2 * + Math.PI * + rootHz * + dtSec * + (1 + 0.13 * smoothWaveField + 0.09 * smoothPlateRms) + + 2 * Math.PI * lastMelodyHz * dtSec * 0.065; + if (flowPhaseAccum > 12_000) { + flowPhaseAccum -= Math.floor(flowPhaseAccum / (2 * Math.PI)) * 2 * Math.PI; } + const centroidLean = + (smoothCentroidSemi - harmonyTriad.rootPc) * 2.8 + smoothWaveField * 1.8; + + flowOsc0.frequency.setTargetAtTime(rootHz * 0.5, t, 0.55); + flowOsc1.frequency.setTargetAtTime(rootHz, t, 0.5); + flowOsc2.frequency.setTargetAtTime(rootHz * thirdMul, t, 0.48); + const plateDetune = smoothPlateMean * 4.2 + smoothPlateRms * 2.8; + flowOsc0.detune.setTargetAtTime( + drift0 + smoothMotion * 4 + plateDetune * 0.35 + centroidLean * 0.2, + t, + 0.25, + ); + flowOsc1.detune.setTargetAtTime( + drift1 - smoothBright * 3 + plateDetune * 0.55 + centroidLean * 0.25, + t, + 0.25, + ); + flowOsc2.detune.setTargetAtTime( + drift2 + smoothEnergy * 3.5 + plateDetune * 0.4 + centroidLean * 0.18, + t, + 0.25, + ); + + const flowBed = (0.28 + 0.72 * presence) * 0.95; + const grayFlowBoost = hints.centerGraySalt ? 1.07 : 1; + /** Standing-wave nodal/antinodal breathing + stroke–plate overlap */ + const plateGain = 1 + smoothPlateCov * 0.14 + smoothPlateRms * 0.11; + const platePhase = 1 + smoothPlateMean * 0.1; + const wMod = (0.86 + 0.22 * (0.5 + 0.5 * fCenter)) * platePhase; + const wMod2 = (0.9 + 0.18 * (0.5 + 0.5 * smoothWaveField)) * plateGain; + flowGain0.gain.setTargetAtTime(0.038 * flowBed * wMod, t, 0.18); + flowGain1.gain.setTargetAtTime(0.032 * flowBed * grayFlowBoost * wMod2, t, 0.18); + flowGain2.gain.setTargetAtTime(0.026 * flowBed * wMod * (0.97 + smoothPlateRms * 0.06), t, 0.18); + const dTime = 0.42 + smoothBright * 0.22 + @@ -501,11 +670,39 @@ export function createAmbientSound(): { melodyAudible; melodyGain.gain.setTargetAtTime(Math.min(0.045, lead), t, 0.14); - /** Harmonic ripple: perfect fifth above lead, same delay bus — water-like shimmer */ + /** Ripple: next chord tone above lead (consonant with current triad) */ const ripLead = Math.min(0.045, lead); - melodyRippleOsc.frequency.setTargetAtTime(lastMelodyHz * 1.498307077, t, 0.22); + const ripSemi = rippleSemitonesToNextChordTone(lastMelodyPc, harmonyTriad); + melodyRippleOsc.frequency.setTargetAtTime( + lastMelodyHz * Math.pow(2, ripSemi / 12), + t, + 0.22, + ); melodyRippleGain.gain.setTargetAtTime(Math.min(0.014, ripLead * 0.34), t, 0.12); + if (melodyMode === "blend") { + const tops = topWeightedPitchClasses(weights, mask, 3, 0.052, scaleMode); + const oscs = [comboOsc0, comboOsc1, comboOsc2] as const; + const gains = [comboGain0, comboGain1, comboGain2] as const; + for (let i = 0; i < 3; i++) { + const o = oscs[i]!; + const gn = gains[i]!; + const ent = tops[i]; + o.frequency.setTargetAtTime( + ent ? hzFromPitchClass(ent.pitchClass) : ROOT_HZ, + t, + 0.35, + ); + const wNorm = ent ? Math.min(1, ent.weight / 0.36) : 0; + const cg = Math.min(0.021, 0.011 * wNorm * (0.28 + 0.72 * presence)); + gn.gain.setTargetAtTime(cg, t, 0.2); + } + } else { + comboGain0.gain.setTargetAtTime(0, t, 0.12); + comboGain1.gain.setTargetAtTime(0, t, 0.12); + comboGain2.gain.setTargetAtTime(0, t, 0.12); + } + /** Underwater rolloff: no treble content */ warmFilter.frequency.setTargetAtTime( 520 + smoothBright * 380 + smoothMotion * 120 + smoothPlateRms * 28, @@ -543,6 +740,12 @@ export function createAmbientSound(): { melodyDelayFb = null; melodyRippleOsc = null; melodyRippleGain = null; + comboOsc0 = null; + comboGain0 = null; + comboOsc1 = null; + comboGain1 = null; + comboOsc2 = null; + comboGain2 = null; fluidNoiseSrc = null; fluidLp = null; fluidGain = null; @@ -562,7 +765,7 @@ export function createAmbientSound(): { function getDriveState(): AmbientDriveState | null { if (!enabled || !graphBuilt) return null; - const baseFlowHz = ROOT_HZ * Math.pow(2, smoothCentroidSemi / 12); + const baseFlowHz = lastFlowRootHz; const ripple = Math.min( 1, smoothWaveVel * 0.36 + diff --git a/src/app.ts b/src/app.ts index 694cee7..935ae13 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,9 @@ import { createAmbientSound } from "./ambient-sound"; -import { analyzeSilkImageData, centroidSemitoneFromWeights } from "./color-music"; +import { + analyzeSilkImageData, + centroidSemitoneFromWeights, + inferScaleModeFromVisuals, +} from "./color-music"; import { plateCouplingFromImageData } from "./plate-coupling"; import type { StandingWaveParams } from "./standing-waves"; import { CanvasUtil } from "./canvas-util"; @@ -1172,11 +1176,16 @@ export function mount(root: HTMLElement): () => void { motionPlate * 0.12, ), ); + const scaleModeWave = inferScaleModeFromVisuals( + lastSilkLuma, + a.centerGraySalt, + ); const swp: StandingWaveParams = { timeSec: performance.now() / 1000, centroidSemitone: centroidSemitoneFromWeights( a.presetWeights, a.mask, + scaleModeWave, ), distinctCount: Math.max(1, a.distinctCount), symRotations: silkSettings.symNumRotations ?? 1, @@ -1192,6 +1201,10 @@ export function mount(root: HTMLElement): () => void { } } readHudIntoSettings(); + const scaleMode = inferScaleModeFromVisuals( + lastSilkLuma, + lastCenterGraySalt, + ); ambient.update({ motion, energy, @@ -1200,6 +1213,7 @@ export function mount(root: HTMLElement): () => void { colorDistinctCount: lastColorDistinct, centerGraySalt: lastCenterGraySalt, colorPresetWeights: lastColorWeights, + scaleMode, symRotations: silkSettings.symNumRotations ?? 1, plateMeanField: lastPlateMeanF, plateRms: lastPlateRms, diff --git a/src/color-music.ts b/src/color-music.ts index d3904b1..9cb73ee 100644 --- a/src/color-music.ts +++ b/src/color-music.ts @@ -1,16 +1,27 @@ /** * Map pixels on the silk canvas to the 7 HUD color presets, then drive music (12-TET). * - * **Theory:** Each preset is a **fixed scale degree** (1…7) of a **major** collection: - * intervals from tonic `[0,2,4,5,7,9,11]` semitones (same as C major when tonic = C). - * **Tonic pitch class** is **transposed** when **several** colors are active: rounded - * weighted average of the **reference** chroma (tonic=C case) picks the key center, then - * every preset’s note is `(tonic + degreeStep) mod 12`. **Solo** color → tonic 0 so - * absolute pitches match the legacy “always C major” mapping. + * **Theory:** Each preset is scale degree 1…7 of a **diatonic seven-note set**: + * - **Major (Ionian):** `[0,2,4,5,7,9,11]` — bright / balanced. + * - **Natural minor (Aeolian):** `[0,2,3,5,7,8,10]` — same “white key” cycle, different tonic feel. + * + * **Tonic pitch class** (movable key): with several colors, rounded weighted mean of those + * presets’ **reference** chroma in C for the active **mode** picks the key center; each note is + * `(tonic + degreeStep) mod 12`. **Solo** color → tonic 0 (legacy absolute mapping). + * + * **Mode:** `inferScaleModeFromVisuals` picks minor when the canvas is dark or center-gray “salt” + * (moody), else major — you can override with `AmbientHints.scaleMode`. */ import { SILK_COLOR_PRESETS } from "./silk-colors"; +/** Diatonic seven-note collection for preset degrees 1…7 (same order as HUD presets). */ +export type ScaleMode = "major" | "naturalMinor"; + +function mod12(n: number): number { + return ((n % 12) + 12) % 12; +} + export type ColorMusicAnalysis = { /** Bit i set if preset i (see `SILK_COLOR_PRESETS`) has visible presence */ mask: number; @@ -51,9 +62,29 @@ function dist2(a: RGB, b: RGB): number { */ export const MAJOR_DEGREE_STEPS: readonly number[] = [0, 2, 4, 5, 7, 9, 11]; +/** Natural minor (Aeolian) from tonic — e.g. A minor shares C major’s pitch classes, different center. */ +export const NATURAL_MINOR_DEGREE_STEPS: readonly number[] = [0, 2, 3, 5, 7, 8, 10]; + /** @alias MAJOR_DEGREE_STEPS — legacy name; chroma when tonic is C. */ export const COLOR_INDEX_TO_SEMITONE: readonly number[] = MAJOR_DEGREE_STEPS; +export function degreeSteps(mode: ScaleMode): readonly number[] { + return mode === "naturalMinor" ? NATURAL_MINOR_DEGREE_STEPS : MAJOR_DEGREE_STEPS; +} + +/** + * Heuristic: darker canvas or gray-heavy center → **natural minor**; otherwise **major**. + */ +export function inferScaleModeFromVisuals( + canvasBrightness: number | undefined, + centerGraySalt: boolean | undefined, +): ScaleMode { + if (centerGraySalt) return "naturalMinor"; + const b = canvasBrightness ?? 0.1; + if (b < 0.1) return "naturalMinor"; + return "major"; +} + /** Per-pixel fuzzy assignment to all 7 presets (inverse-distance²); sums to 1. */ function softPresetWeightsForRgb(r: number, g: number, b: number): number[] { const px: RGB = { r, g, b }; @@ -194,63 +225,73 @@ function activePresetIndices(mask: number, weights?: readonly number[]): number[ } /** - * Tonic pitch class (0–11) for **movable** major: with **one** active hue, returns **0** + * Tonic pitch class (0–11) for **movable** key: with **one** active hue, returns **0** * so solo presets keep legacy absolute pitches. With **two or more**, returns rounded - * weighted mean of reference chroma (`MAJOR_DEGREE_STEPS` as C-key pitch classes). + * weighted mean of reference chroma for `mode` (steps in C as if tonic were C). */ export function tonicPitchClassFromAnalysis( mask: number, weights?: readonly number[], + mode: ScaleMode = "major", ): number { const act = activePresetIndices(mask, weights); if (act.length <= 1) return 0; + const steps = degreeSteps(mode); let num = 0; let den = 0; if (weights && weights.length >= 7) { for (const i of act) { const w = weights[i] ?? 0; - num += w * MAJOR_DEGREE_STEPS[i]!; + num += w * steps[i]!; den += w; } } if (den < 1e-9) { for (const i of act) { - num += MAJOR_DEGREE_STEPS[i]!; + num += steps[i]!; } den = act.length; } - return ((Math.round(num / den) % 12) + 12) % 12; + return mod12(Math.round(num / den)); } -export function presetPitchClass(presetIndex: number, tonicPc: number): number { - const step = MAJOR_DEGREE_STEPS[presetIndex]; +export function presetPitchClass( + presetIndex: number, + tonicPc: number, + mode: ScaleMode = "major", +): number { + const step = degreeSteps(mode)[presetIndex]; if (step == null) return 0; - return (tonicPc + step) % 12; + return mod12(tonicPc + step); } /** Weighted average pitch class (fractional) under the current movable tonic. */ export function centroidSemitoneFromWeights( weights: readonly number[], mask: number, + mode: ScaleMode = "major", ): number { if (weights.length < 7) return 0; - const tonic = tonicPitchClassFromAnalysis(mask, weights); + const tonic = tonicPitchClassFromAnalysis(mask, weights, mode); let s = 0; for (let i = 0; i < 7; i++) { - s += (weights[i] ?? 0) * presetPitchClass(i, tonic); + s += (weights[i] ?? 0) * presetPitchClass(i, tonic, mode); } return s; } /** Pitch classes (0–11) for mask bits, sorted — transposed by movable tonic. */ -export function semitonesFromPresetMask(mask: number): number[] { - const tonic = tonicPitchClassFromAnalysis(mask); +export function semitonesFromPresetMask( + mask: number, + mode: ScaleMode = "major", +): number[] { + const tonic = tonicPitchClassFromAnalysis(mask, undefined, mode); const out: number[] = []; for (let i = 0; i < 7; i++) { if (mask & (1 << i)) { - out.push(presetPitchClass(i, tonic)); + out.push(presetPitchClass(i, tonic, mode)); } } out.sort((a, b) => a - b); @@ -265,18 +306,79 @@ export function semitonesFromWeightsAndMask( weights: readonly number[] | undefined, mask: number, threshold = 0.052, + mode: ScaleMode = "major", ): number[] { - const tonic = tonicPitchClassFromAnalysis(mask, weights); + const tonic = tonicPitchClassFromAnalysis(mask, weights, mode); const semis = new Set(); if (weights && weights.length >= 7) { for (let i = 0; i < 7; i++) { if ((weights[i] ?? 0) >= threshold) { - semis.add(presetPitchClass(i, tonic)); + semis.add(presetPitchClass(i, tonic, mode)); } } } if (semis.size === 0) { - return semitonesFromPresetMask(mask); + return semitonesFromPresetMask(mask, mode); } return [...semis].sort((a, b) => a - b); } + +export type WeightedPitchClass = { + presetIndex: number; + weight: number; + pitchClass: number; +}; + +/** + * Strongest distinct preset colors → pitch classes in the current movable key. + * Used to sound **combinations** literally (several quiet partials under the lead). + */ +export function topWeightedPitchClasses( + weights: readonly number[] | undefined, + mask: number, + maxCount: number, + minWeight = 0.055, + mode: ScaleMode = "major", +): WeightedPitchClass[] { + const tonic = tonicPitchClassFromAnalysis(mask, weights, mode); + if (!weights || weights.length < 7) { + const pool = semitonesFromPresetMask(mask, mode); + return pool.slice(0, maxCount).map((pc, i) => ({ + presetIndex: -1, + weight: 0.18 - i * 0.02, + pitchClass: pc, + })); + } + + const ranked: WeightedPitchClass[] = []; + for (let i = 0; i < 7; i++) { + const w = weights[i] ?? 0; + if (w < minWeight) continue; + ranked.push({ + presetIndex: i, + weight: w, + pitchClass: presetPitchClass(i, tonic, mode), + }); + } + ranked.sort((a, b) => b.weight - a.weight); + + const seen = new Set(); + const out: WeightedPitchClass[] = []; + for (const r of ranked) { + const pc = mod12(r.pitchClass); + if (seen.has(pc)) continue; + seen.add(pc); + out.push({ ...r, pitchClass: pc }); + if (out.length >= maxCount) break; + } + + if (out.length === 0) { + const pool = semitonesFromWeightsAndMask(weights, mask, 0.052, mode); + return pool.slice(0, maxCount).map((pc) => ({ + presetIndex: -1, + weight: 0.16, + pitchClass: pc, + })); + } + return out; +}