/** * "Home-theater rules of thumb, tested in 1,000 simulated rooms." * Uses the same room-mode model as the subwoofer simulator (src/lib/modal.ts). Seeded, so results reproduce exactly. * Run: ../home-theater-diorama/node_modules/.bin/tsx scripts/rules-study.ts → src/data/rules-study.json */ import { writeFileSync, mkdirSync, copyFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { modes, response as modalResponse, spread, betaFor, FREQS, type P3 } from '../src/lib/modal'; const FT = 0.3048; /** The published run. Sensitivity runs below change one assumption at a time and check the headline findings hold. */ interface Params { t60: number; ear: number; subz: number; fmax: number; freqs: number[] } const BASE: Params = { t60: 0.5, ear: 1.2, subz: 0.3, fmax: 250, freqs: FREQS }; // Seeded PRNG (mulberry32) let seed = 20261004; const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const between = (a: number, b: number) => a + (b - a) * rnd(); const median = (v: number[]) => { const s = [...v].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; const mean = (v: number[]) => v.reduce((a, b) => a + b, 0) / v.length; const pct = (v: number[], p: number) => { const s = [...v].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; }; interface Room { L: number; W: number; H: number } const N = 1000; const rooms: Room[] = []; while (rooms.length < N) { const L = between(12, 26), W = between(10, 20), H = between(7.5, 10); if (W > L) continue; // length runs front to back and is the longer side rooms.push({ L: L * FT, W: W * FT, H: H * FT }); } function study(P: Params) { const EAR = P.ear, SUBZ = P.subz, FMAX = P.fmax, beta = betaFor(P.t60), F = P.freqs; const response = (ms: ReturnType, seat: P3, subs: P3[], lx: number, ly: number, lz: number, b: number) => modalResponse(ms, seat, subs, lx, ly, lz, b, F); // ——— A. Where to sit along the room: the 38% rule and its rivals ——— const FRACS = Array.from({ length: 71 }, (_, i) => 0.15 + i / 100); // 15%..85% of length from the front wall const NAMED = { 'front38': 0.38, 'middle': 0.5, 'back38': 0.62, 'fifths': 0.8 } as const; type Named = keyof typeof NAMED | 'backWall'; const subSetups = { corner: (r: Room): P3[] => [[0.3, 0.3, SUBZ]], frontMid: (r: Room): P3[] => [[r.W / 2, 0.3, SUBZ]], }; const A: Record> = {}; const bestFracs: Record = { corner: [], frontMid: [] }; for (const [name, subsOf] of Object.entries(subSetups)) { const acc = Object.fromEntries((['front38', 'middle', 'back38', 'fifths', 'backWall'] as Named[]).map((k) => [k, { spread: [] as number[], pctile: [] as number[], bestQuarter: 0, level: [] as number[] }])) as Record; for (const r of rooms) { const ms = modes(r.W, r.L, r.H, FMAX); const subs = subsOf(r); const resp = (f: number) => response(ms, [r.W / 2, f * r.L, EAR], subs, r.W, r.L, r.H, beta); const meanDb = (db: number[]) => db.reduce((a, b) => a + b, 0) / db.length; const all = FRACS.map(resp); const curve = all.map(spread); const roomLevel = mean(all.map(meanDb)); const sorted = [...curve].sort((a, b) => a - b); const rank = (v: number) => sorted.filter((x) => x < v).length / sorted.length; // 0 = best bestFracs[name].push(FRACS[curve.indexOf(sorted[0])]); const fr: Record = { front38: NAMED.front38, middle: NAMED.middle, back38: NAMED.back38, fifths: NAMED.fifths, backWall: 1 - 0.35 / r.L }; for (const k of Object.keys(fr) as Named[]) { const db = resp(fr[k]); const v = spread(db); acc[k].spread.push(v); acc[k].level.push(meanDb(db) - roomLevel); // bass level vs the average along the room const p = rank(v); acc[k].pctile.push(p); if (p < 0.25) acc[k].bestQuarter++; } } A[name] = acc; } // ——— B. Room proportions: do "ideal" ratios give smoother bass? ——— const RATIOS: Record = { golden: [1, 1.618, 2.618], louden: [1, 1.4, 1.9], bolt: [1, 1.25, 1.6], sepmeyerA: [1, 1.14, 1.39], sepmeyerB: [1, 1.28, 1.54], sepmeyerC: [1, 1.6, 2.33], cube: [1, 1, 1], doubleSquare: [1, 2, 2], }; /** BS.1116-3 §8.2.2.3 reference-room proportions: 1.1 w/h ≤ l/h ≤ 4.5 w/h − 4, l/h < 3, w/h < 3. */ const bs1116 = (h: number, w: number, l: number) => { const W = w / h, L = l / h; return L >= 1.1 * W && L <= 4.5 * W - 4 && L < 3 && W < 3; }; function roomScore(r: Room) { const ms = modes(r.W, r.L, r.H, FMAX); const subs: P3[] = [[0.3, 0.3, SUBZ]]; const vals: number[] = []; for (const fx of [0.35, 0.45, 0.55, 0.65]) for (const fy of [0.45, 0.55, 0.65, 0.75]) vals.push(spread(response(ms, [fx * r.W, fy * r.L, EAR], subs, r.W, r.L, r.H, beta))); return mean(vals); } const sample = rooms; const randomScores = sample.map(roomScore); const B: Record = {}; for (const [name, [h, w, l]] of Object.entries(RATIOS)) { const scores = sample.map((r) => { const vol = r.L * r.W * r.H, k = Math.cbrt(vol / (h * w * l)); return roomScore({ H: h * k, W: w * k, L: l * k }); }); B[name] = { meanScore: mean(scores), vsRandom: mean(scores) - mean(randomScores), bs1116: bs1116(h, w, l) }; } const randomPassBs = rooms.filter((r) => bs1116(r.H, r.W, r.L)).length / rooms.length; // ——— C. One sub or two: seat-to-seat consistency ——— const seatsOf = (r: Room): P3[] => [0.4, 0.5, 0.6].flatMap((fx) => [0.55, 0.7].map((fy) => [fx * r.W, fy * r.L, EAR] as P3)); const configs = { frontCorner: (r: Room): P3[] => [[0.3, 0.3, SUBZ]], frontMid: (r: Room): P3[] => [[r.W / 2, 0.3, SUBZ]], twoOpposite: (r: Room): P3[] => [[r.W / 2, 0.3, SUBZ], [r.W / 2, r.L - 0.3, SUBZ]], twoSideMids: (r: Room): P3[] => [[0.3, r.L / 2, SUBZ], [r.W - 0.3, r.L / 2, SUBZ]], }; const Cres: Record = {}; for (const [name, subsOf] of Object.entries(configs)) { const sp: number[] = [], sts: number[] = []; for (const r of rooms) { const ms = modes(r.W, r.L, r.H, FMAX); const resp = seatsOf(r).map((s) => response(ms, s, subsOf(r), r.W, r.L, r.H, beta)); sp.push(mean(resp.map(spread))); // seat-to-seat: at each frequency, the standard deviation across seats; then the mean over frequency sts.push(mean(F.map((_, k) => { const v = resp.map((x) => x[k]); const m = mean(v); return Math.sqrt(mean(v.map((x) => (x - m) ** 2))); }))); } Cres[name] = { seatSpread: mean(sp), seatToSeat: mean(sts), perRoom: sts }; } return { A, bestFracs, B, randomScores, randomPassBs, Cres }; } const summarize = (o: { spread: number[]; pctile: number[]; bestQuarter: number; level: number[] }) => ({ meanSpread: +mean(o.spread).toFixed(2), meanLevel: +mean(o.level).toFixed(1), medianPercentile: +median(o.pctile).toFixed(3), bestQuarterShare: +(o.bestQuarter / N).toFixed(3), }); const { A, bestFracs, B, randomScores, randomPassBs, Cres } = study(BASE); const out = { generated: new Date().toLocaleDateString('en-CA'), // local date, not UTC model: { rooms: N, t60: BASE.t60, earHeight: BASE.ear, subHeight: BASE.subz, fmaxModes: BASE.fmax, band: [25, 120], frequencies: BASE.freqs.length, seed: 20261004, lengthFt: [12, 26], widthFt: [10, 20], heightFt: [7.5, 10] }, seat: Object.fromEntries(Object.entries(A).map(([k, v]) => [k, Object.fromEntries(Object.entries(v).map(([n, o]) => [n, summarize(o)]))])), bestFraction: Object.fromEntries(Object.entries(bestFracs).map(([k, v]) => [k, { median: +median(v).toFixed(2), p25: +pct(v, 0.25).toFixed(2), p75: +pct(v, 0.75).toFixed(2) }])), ratios: Object.fromEntries(Object.entries(B).map(([k, v]) => [k, { meanScore: +v.meanScore.toFixed(2), vsRandom: +v.vsRandom.toFixed(2), bs1116: v.bs1116 }])), randomRoomScore: +mean(randomScores).toFixed(2), randomRoomScoreRange: [+pct(randomScores, 0.1).toFixed(2), +pct(randomScores, 0.9).toFixed(2)], randomPassBs1116: +randomPassBs.toFixed(3), subs: Object.fromEntries(Object.entries(Cres).map(([k, v]) => [k, { seatSpread: +v.seatSpread.toFixed(2), seatToSeat: +v.seatToSeat.toFixed(2) }])), }; // ——— Per-room results (public download), so others can check or re-analyze without rerunning ——— const r2 = (v: number) => +v.toFixed(2); const perRoom = rooms.map((r, i) => ({ lengthM: r2(r.L), widthM: r2(r.W), heightM: r2(r.H), seat: Object.fromEntries(Object.entries(A).map(([setup, acc]) => [setup, Object.fromEntries(Object.entries(acc).map(([pos, o]) => [pos, { unevennessDb: r2(o.spread[i]), percentile: r2(o.pctile[i]), levelDb: r2(o.level[i]) }]))])), bestFraction: Object.fromEntries(Object.entries(bestFracs).map(([k, v]) => [k, v[i]])), roomScore16Seats: r2(randomScores[i]), seatToSeatDb: Object.fromEntries(Object.entries(Cres).map(([k, v]) => [k, r2(v.perRoom[i])])), })); // ——— Sensitivity: one assumption at a time. The headline findings should keep their direction and rough size. ——— const headline = (r: ReturnType) => ({ front38Percentile: +median(r.A.corner.front38.pctile).toFixed(2), back38Percentile: +median(r.A.corner.back38.pctile).toFixed(2), goldenVsRandomDb: r2(r.B.golden.vsRandom), cubeVsRandomDb: r2(r.B.cube.vsRandom), doubleSquareVsRandomDb: r2(r.B.doubleSquare.vsRandom), twoSubsCutPct: Math.round((1 - r.Cres.twoOpposite.seatToSeat / r.Cres.frontCorner.seatToSeat) * 100), backWallLevelDb: +mean(r.A.corner.backWall.level).toFixed(1), }); const FINE = Array.from({ length: 96 }, (_, i) => 25 * Math.pow(120 / 25, i / 95)); const VARIANTS: [string, Partial][] = [ ['Published run (0.5 s decay, ears 1.2 m, sub 0.3 m up, modes to 250 Hz, 48 frequencies)', {}], ['Livelier room: 0.7 s bass decay', { t60: 0.7 }], ['Deader room: 0.35 s bass decay', { t60: 0.35 }], ['Lower ears: 1.0 m (deep sofa)', { ear: 1.0 }], ['Sub driver higher: 0.5 m', { subz: 0.5 }], ['Modes to 320 Hz (the simulator’s setting)', { fmax: 320 }], ['Twice as many frequencies (96)', { freqs: FINE }], ]; const sensitivity = VARIANTS.map(([label, change]) => { const res = Object.keys(change).length ? study({ ...BASE, ...change }) : { A, B, Cres, bestFracs, randomScores, randomPassBs }; return { label, ...headline(res as ReturnType) }; }); (out as Record).sensitivity = sensitivity; writeFileSync(resolve(import.meta.dirname, '../src/data/rules-study.json'), JSON.stringify(out, null, 1)); mkdirSync(resolve(import.meta.dirname, '../public/research/code'), { recursive: true }); for (const [from, to] of [['rules-study.ts', 'rules-study.ts.txt'], ['../src/lib/modal.ts', 'modal.ts.txt']]) copyFileSync(resolve(import.meta.dirname, from), resolve(import.meta.dirname, '../public/research/code', to)); // Public files: the summary (with the sensitivity table), every room, and the code that produced them. writeFileSync(resolve(import.meta.dirname, '../public/research/rules-of-thumb-data.json'), JSON.stringify(out, null, 1)); writeFileSync(resolve(import.meta.dirname, '../public/research/rules-of-thumb-rooms.json'), JSON.stringify({ about: 'Per-room results behind /research/rules-of-thumb/. Seat positions are fractions of the room length from the front wall along the center line; backWall is ears 0.35 m from the back wall. Unevenness is the standard deviation of the level across the study frequencies (25–120 Hz); percentile 0 = best of 71 positions; levelDb is relative to the average of those 71 positions. seatToSeatDb: six seats in two rows.', model: out.model, rooms: perRoom })); console.log(JSON.stringify({ ...out, seat: '…' }, null, 1));