diff --git a/.github/workflows/experiments.yml b/.github/workflows/experiments.yml new file mode 100644 index 0000000..0d7f7e6 --- /dev/null +++ b/.github/workflows/experiments.yml @@ -0,0 +1,42 @@ +name: Experiments +on: + push: + branches: [main] + paths: ['*.mjs', '*.html', '*.css', '*.md', 'experiment-002/**', 'test/**', '.github/workflows/experiments.yml'] + pull_request: + paths: ['*.mjs', '*.html', '*.css', '*.md', 'experiment-002/**', 'test/**', '.github/workflows/experiments.yml'] +permissions: + contents: read +jobs: + replay: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node --test + pages: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: replay + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pages: write + id-token: write + concurrency: + group: github-pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v4 + with: + path: '.' + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/README.md b/README.md index 528aa23..0cc45f9 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,13 @@ Surprise Me. :-P. The first interpretation of this open-ended prompt is a small [static idea machine](index.html). It collides themes from the QSOL-IMC research constellation, proposes a test, and asks how that test could fail. A fixed seed replays the same card; a JSON receipt preserves the proposal without pretending that an experiment has been run. The [experiment record](EXPERIMENT.md) explains the choice, method, and limits. Open `index.html` on GitHub Pages or a local static server. There are no dependencies or outbound requests. Run `node --test` to check the deterministic selection logic. The original invitation above remains intact as the starting condition. + +## Experiment 002: Receipt Rave + +The second interpretation is a [deterministic industrial micro-tracker](experiment-002/): five procedural voices, A minor at 432 Hz, a sample-delay ABX listening game, and SHA-256 receipts for the score and audio. The model inferred an instrument from the recurring themes of music, tracker timing, CPU execution, and evidence. The narrator now has to listen before delivering the deep dive. + +[Play Receipt Rave](https://qsolkcb.github.io/RANDO/experiment-002/) · [Experiment record](experiment-002/EXPERIMENT.md) · [Release notes](experiment-002/RELEASE_NOTES.md) + +Serve the repository with `python3 -m http.server 8000`, then open `http://localhost:8000/experiment-002/`. Run both experiments' tests with `node --test` (Node 22+). There is no package install or build step. Experiment 001 and its record remain available above. + +The `Experiments` workflow checks pull requests and publishes the repository's static files after main-branch tests pass. GitHub Pages must use GitHub Actions as its publishing source. diff --git a/experiment-002/EXPERIMENT.md b/experiment-002/EXPERIMENT.md new file mode 100644 index 0000000..b983233 --- /dev/null +++ b/experiment-002/EXPERIMENT.md @@ -0,0 +1,74 @@ +# RANDO experiment 002 — Receipt Rave + +## The invitation + +On 2 October 2026 in Australia/Adelaide (1 October UTC), Trent invited a second model to build what it inferred he would want from previous context, then closed the window to preserve the surprise. The [original README](../README.md) delegates the creative choice and explicitly permits comedy. The starting commit was `bd3c2eb9fd6fa763800a3864fcba57c00d870fd1`; Experiment 001 was already present. + +This record describes one context-guided software intervention. It is not a controlled comparison of models. The user's label for this run was “Sol 6.1”; this repository does not independently attest the serving model, hidden instructions, or provider infrastructure. + +## The choice + +Build an actual instrument: **Receipt Rave**, a tiny industrial tracker with a listening challenge and inspectable audio receipts. + +The supplied context repeatedly connected industrial electronic music, deterministic patterns, historical tracker timing, CPU execution, and provenance. A recent discussion about hearing playback differences suggested a particularly useful junction: generate the same loop twice, move one lane by an exact number of source samples, and let the listener compare it before seeing the answer. + +The implementation follows that inference. It does not claim to discover the user's true preferences. The available context was a supplied memory summary and targeted retrieval, including the previous RANDO experiment; it was not an exhaustive transcript. That exposure means this run is neither independent of Experiment 001 nor blind to its outcome. No raw chat history is embedded in the artifact. The instrument itself uses only its procedural score and synthetic sound sources. + +The recurring evidence theme also shaped the stopping point: a playable, bounded experiment with replay checks, rather than a growing research roadmap. The original invitation and Experiment 001's code and record are preserved; its landing page gets one link to the new experiment. + +## What was built + +- Four bars, 64 sixteenth-note steps, five lanes: PISTON, SNARE, SHRAPNEL, SUB, CARRIER. +- Deterministic procedural percussion, bass, pulse lead, and two lead echoes. These are original synthetic voices, not Psycle, SID, or Prophet emulation. +- A fixed A-minor note table based on 12-tone equal temperament with A4 = 432 Hz. Tuning is a creative choice with no therapeutic claim. +- 120, 128, 135, or 147 BPM. Timing boundaries are rounded from cumulative rational time, so independently rounding each step cannot accumulate drift. +- Reference A and shifted B. B shifts every CARRIER event and its echoes by 0, 1, 16, 64, 256, or 1024 frames at a 24,000 Hz source rate. All other voices retain the same event state, timing, and gain. Both buffers have the same length. +- WAV, render receipt, and listening-record downloads, plus a dependency-free Node replay checker. + +There is no inference call, account, package dependency, persistent storage, analytics, or external media in the instrument. Loading the page still involves its static host and browser. Each rendered voice carries integer-valued state; JavaScript's exact-integer Number range is used for arithmetic, and the output is mono signed PCM16. Playback converts those source samples to Web Audio floats. The waveform uses an ordinary 2D canvas with no explicit GPU or WebGL API; browser compositing is outside the implementation's control. + +## Replay + +Use Node 22+ from the repository root: + +```sh +node --test +node experiment-002/cli.mjs render ./rave-output 'the-window-is-closed' 147 64 +node experiment-002/cli.mjs verify ./rave-output/receipt.json ./rave-output/A.wav ./rave-output/B.wav +``` + +The output directory must be new. `verify RECEIPT.json` without WAV arguments checks regeneration only; it does not inspect external audio files. Supplying both WAV paths checks their exact bytes too. + +For the browser, serve the repository root with `python3 -m http.server 8000` and open `http://localhost:8000/experiment-002/`, or use its existing HTTPS GitHub Pages host. ES modules need a static server; Web Crypto needs HTTPS or localhost. Seed, tempo, and delay are encoded in the URL. The seed is trimmed and accepts 1–64 Unicode code points; no Unicode normalization is applied. + +The score selector uses FNV-1a over UTF-8 bytes, then a 32-bit linear congruential generator. Neither is cryptographically random. The fixed engine identifier, note table, voice rules, and score ordering define the replay. Source changes that alter output require a new engine version and golden vector. + +## What the receipt establishes + +`rando.audio-receipt.v1` stores settings, engine identity, source rate, frame count, rendered peak/clipping counts, and separate SHA-256 hashes for: + +1. The score as recursively key-sorted JSON with safe integers and no whitespace. +2. Reference and shifted PCM, encoded explicitly as little-endian signed 16-bit samples. +3. Reference and shifted WAV bytes, including their 44-byte headers. + +The checker regenerates the expected receipt and rejects missing or extra fields, unsupported engines, type substitutions, and mismatches. JSON imports check parsed field values, not the textual formatting of the receipt file; ordinary JSON parsing resolves duplicate keys. Receipt file bytes themselves are not hashed or authenticated. + +A successful check establishes agreement with this version of this renderer. It is not an independently implemented verifier, a signature, a chain-of-custody system, a timestamp attestation, or an integration with PROVENANCE. A modified renderer can endorse its own output. The frozen tests help detect accidental version drift; they do not make the implementation a trust anchor. + +Default reference vector: 180,735 frames, peak 9,519, zero clipped samples; PCM SHA-256 `d2cd4ae492adfa0cc4a5b989d9b077f0e83a0ff1fba8da5742ca6a339dbad96a`. + +## The listening game + +An eight-trial session freezes the current settings. A and B keep their reference roles throughout. Each trial uses a fresh browser-random bit to choose whether X plays the A or B buffer. Playback begins only after a button click. The interface requires an audition of each label before accepting an answer, then hides correctness until completion or an explicit early exit. An audition means playback was started; it does not certify that a person listened to the entire clip. + +The export retains the audio receipt, each revealed X mapping and answer, and each started audition with its trial number, label, gain at start, and browser device sample rate. Subsequent slider changes are not captured. There is no local persistence: navigating away loses the session unless it has been exported. Starting another session replaces the previous in-memory record. + +The zero-sample control has identical PCM. Its random label assignments still exist in the export, but the UI reports no meaningful hearing score for those assignments. For other delays, a score describes this session only. The tool does not infer significance, establish a threshold, or generalize to other listeners. + +Limits: this is informal local blinding, not adversarial concealment. Source inspection, browser debugging, or an exported mapping can reveal the answers. Browser resampling, output device latency, acoustics, headphones, level, and expectations can affect what a listener hears. Source sample displacement is exact; speaker delivery timing is not measured. Overlap changes can alter mix peaks naturally; there is no per-variant loudness normalization. Procedural pulses are band-unlimited and may alias. No human listening result was collected during development. + +## What to observe on return + +Trent can record whether the build was surprising, useful, amusing, or unwanted; which contextual cues it seemed to capture; which assumptions missed; and whether he keeps any audio or listening records. Those observations are pending. Acceptance is not inferred from the absence of supervision. + +The potato remains an unsuitable control for human preference. It did, however, correctly reject every claim of sub-bass authorship. diff --git a/experiment-002/RELEASE_NOTES.md b/experiment-002/RELEASE_NOTES.md new file mode 100644 index 0000000..da6af79 --- /dev/null +++ b/experiment-002/RELEASE_NOTES.md @@ -0,0 +1,22 @@ +# Receipt Rave v0.1.0 — The sub has a receipt + +Experiment 002 in RANDO. A context-guided surprise built on 2 October 2026 (Adelaide). + +## Added + +- A deterministic, five-lane industrial micro-tracker with 432 Hz A-minor tuning. +- Four tempos, six exact source-sample delay settings, and a visible 64-step score. +- Reference/shifted auditions, an eight-trial ABX game, and an identical-reference control. +- PCM16 WAV exports, SHA-256 render receipts, listening records, and a Node replay checker. +- A one-character tamper demonstration. The checksum has declined to be “interesting.” +- One CI workflow for both experiments' tests and static Pages deployment after a successful main-branch test run. + +Experiment 001 remains available. No external audio, sample pack, synthesizer library, or model call is required. + +## Validation and limits + +The local Node suite passes **14/14 tests**, including the original three tests, a frozen audio vector, all tempo settings over four synthetic seeds, identical control, nonzero-delay differences, WAV encoding, altered receipts, and a damaged external WAV. FFprobe recognizes the default export as mono PCM16 at 24 kHz, 7.530625 seconds. + +The receipt establishes deterministic byte agreement. The listening game records an informal session. Neither grants diplomatic immunity to a narrator who says “let's unpack the bass.” + +Human preference and listening outcomes remain unobserved. See [the experiment record](EXPERIMENT.md) for replay instructions and the full boundaries. diff --git a/experiment-002/app.mjs b/experiment-002/app.mjs new file mode 100644 index 0000000..4c3a497 --- /dev/null +++ b/experiment-002/app.mjs @@ -0,0 +1,187 @@ +import { config, render, SAMPLE_RATE, LANES, makeReceipt, verifyReceipt, wavBytes, trial } from './core.mjs'; + +const $ = id => document.getElementById(id); +let current, reference, shifted, receipt, context, source, gain, session; +let preparing = false, verifying = false, playEpoch = 0; +const locked = ['seed', 'bpm', 'delay', 'forge']; +const artifacts = ['wav-a', 'wav-b', 'receipt', 'tamper']; + +function buttons() { + const ready = !!receipt && !preparing; + for (const id of locked) $(id).disabled = preparing || !!session?.active; + for (const id of artifacts) $(id).disabled = !ready || !!session?.active || (id === 'tamper' && verifying); + for (const id of ['play-a', 'play-b', 'begin']) $(id).disabled = !ready || !!session?.active; + $('stop').disabled = !source; + $('verify-file').disabled = verifying; +} + +function stop() { + playEpoch++; + if (source) { source.onended = null; source.stop(); source = null; } + buttons(); +} + +async function play(which, label) { + const Audio = window.AudioContext || window.webkitAudioContext; + if (!Audio) throw new Error('This browser has no Web Audio playback. WAV export still works.'); + if (!context) { + context = new Audio(); + gain = context.createGain(); gain.connect(context.destination); + } + const epoch = ++playEpoch; + await context.resume(); + if (epoch !== playEpoch) return false; + if (source) { source.onended = null; source.stop(); source = null; } + const pcm = which === 'A' ? reference.pcm : shifted.pcm; + const buffer = context.createBuffer(1, pcm.length, SAMPLE_RATE); + const samples = buffer.getChannelData(0); + for (let i = 0; i < pcm.length; i++) samples[i] = pcm[i] / 32768; + gain.gain.value = Number($('volume').value) / 100; + const playing = context.createBufferSource(); + playing.buffer = buffer; playing.connect(gain); source = playing; + playing.onended = () => { if (source === playing) { source = null; buttons(); } }; + playing.start(); buttons(); + $('status').textContent = `Playing ${label}. Output ${$('volume').value}%.`; + if (session?.active) session.plays.push({ trial: session.answers.length + 1, label, + gain_percent_at_start: Number($('volume').value), device_sample_rate: context.sampleRate }); + return true; +} + +function action(fn) { + return async () => { try { await fn(); } catch (e) { $('status').textContent = e.message; } }; +} + +function draw() { + const canvas = $('scope'), ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = '#424039'; ctx.beginPath(); ctx.moveTo(0, 55); ctx.lineTo(1000, 55); ctx.stroke(); + ctx.strokeStyle = '#e4ad55'; ctx.beginPath(); + for (let x = 0; x < 1000; x++) { + const from = Math.floor(x * reference.pcm.length / 1000); + const to = Math.floor((x + 1) * reference.pcm.length / 1000); + let lo = 0, hi = 0; + for (let i = from; i < to; i++) { lo = Math.min(lo, reference.pcm[i]); hi = Math.max(hi, reference.pcm[i]); } + ctx.moveTo(x, 55 - hi * 48 / 16000); ctx.lineTo(x, 55 - lo * 48 / 16000); + } + ctx.stroke(); + $('readout').textContent = `${reference.pcm.length} FRAMES / 24 kHz / MONO PCM16 / PEAK ${reference.peak} / CLIPPED ${reference.clipped}`; + $('tracker').replaceChildren(); + LANES.forEach((name, lane) => { + const label = document.createElement('span'); label.className = 'lane-name'; label.textContent = name; + const row = document.createElement('div'); row.className = 'lane-steps'; + for (let step = 0; step < 64; step++) { + const mark = document.createElement('span'); + const hit = reference.score.events.some(e => e.lane === lane && e.step === step); + mark.className = 'step' + (step % 16 === 0 ? ' bar' : '') + (hit ? lane === 4 ? ' lead' : ' hit' : ''); + mark.title = `${name} / step ${step + 1}${hit ? ' / hit' : ''}`; + row.append(mark); + } + $('tracker').append(label, row); + }); +} + +async function forge(input) { + if (preparing || session?.active) return; + const c = config(input); + preparing = true; stop(); buttons(); $('status').textContent = 'Rendering exact PCM and hashing both versions…'; + try { + // Let the disabled state paint before CPU work. + await new Promise(resolve => requestAnimationFrame(resolve)); + const a = render(c), b = render(c, true), r = await makeReceipt(c); + current = c; reference = a; shifted = b; receipt = r; + $('seed').value = c.seed; $('bpm').value = String(c.bpm); $('delay').value = String(c.delay); + draw(); $('hash-a').textContent = r.reference_pcm_sha256; $('hash-b').textContent = r.shifted_pcm_sha256; + $('difference').textContent = `B delays the CARRIER lane and its echoes by ${c.delay} samples (${(c.delay * 1000 / SAMPLE_RATE).toFixed(3)} ms). A is unchanged. Both have ${a.pcm.length} frames.`; + $('status').textContent = 'Loop ready. The sub has a receipt. The narrator has no alibi.'; + const url = new URL(location.href); for (const [k,v] of Object.entries(c)) url.searchParams.set(k, v); + history.replaceState(null, '', url); + } finally { preparing = false; buttons(); } +} + +function download(bytes, type, name) { + const url = URL.createObjectURL(new Blob([bytes], { type })); + const link = document.createElement('a'); link.href = url; link.download = name; document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +const jsonDownload = (value, name) => download(JSON.stringify(value, null, 2) + '\n', 'application/json', name); + +function nextTrial() { + session.bit = crypto.getRandomValues(new Uint8Array(1))[0] & 1; + session.heard = new Set(); + $('guess-a').disabled = true; $('guess-b').disabled = true; + $('trial-label').textContent = `TRIAL ${session.answers.length + 1} / 8`; +} + +function finish(aborted = false) { + stop(); session.active = false; session.aborted = aborted; + $('session').hidden = true; + const correct = session.answers.filter(x => x.correct).length; + $('result').textContent = session.config.delay === 0 + ? `Identical-reference control: ${session.answers.length} answers recorded. A and B have the same PCM; correctness labels carry no hearing information.` + : `${correct} / ${session.answers.length} correct${aborted ? ' (ended early)' : ''}. This records this session only; it does not establish a hearing threshold.`; + $('session-export').disabled = false; buttons(); +} + +function guess(choice) { + if (!session?.active || session.heard.size !== 3) return; + session.answers.push(trial(session.bit, choice)); stop(); + if (session.answers.length === 8) finish(); else nextTrial(); +} + +$('forge-form').addEventListener('submit', e => { e.preventDefault(); action(() => forge({ seed: $('seed').value, bpm: Number($('bpm').value), delay: Number($('delay').value) }))(); }); +$('play-a').addEventListener('click', action(() => play('A', 'reference A'))); +$('play-b').addEventListener('click', action(() => play('B', 'shifted B'))); +$('stop').addEventListener('click', stop); +$('volume').addEventListener('input', () => { $('volume-value').value = $('volume').value + '%'; if (gain) gain.gain.value = Number($('volume').value) / 100; }); +$('wav-a').addEventListener('click', () => download(wavBytes(reference.pcm), 'audio/wav', 'receipt-rave-A.wav')); +$('wav-b').addEventListener('click', () => download(wavBytes(shifted.pcm), 'audio/wav', 'receipt-rave-B.wav')); +$('receipt').addEventListener('click', () => jsonDownload(receipt, 'receipt-rave-receipt.json')); + +$('begin').addEventListener('click', () => { + stop(); session = { active: true, config: { ...current }, receipt: { ...receipt }, answers: [], plays: [] }; + $('session').hidden = false; $('result').textContent = ''; $('session-export').disabled = true; + nextTrial(); buttons(); +}); +for (const label of ['A', 'B', 'X']) $('hear-' + label.toLowerCase()).addEventListener('click', action(async () => { + if (!session?.active) return; + const activeSession = session, activeTrial = session.answers.length; + const played = await play(label === 'X' ? session.bit ? 'B' : 'A' : label, label); + if (!played) return; + if (session !== activeSession || !session.active || session.answers.length !== activeTrial) return; + session.heard.add(label); + $('guess-a').disabled = session.heard.size !== 3; $('guess-b').disabled = session.heard.size !== 3; +})); +$('guess-a').addEventListener('click', () => guess('A')); $('guess-b').addEventListener('click', () => guess('B')); +$('abort').addEventListener('click', () => finish(true)); +$('session-export').addEventListener('click', () => jsonDownload({ schema: 'rando.listening-record.v1', + method: 'informal-local-abx', planned_trials: 8, ended_early: session.aborted, + identical_reference_control: session.config.delay === 0, audio_receipt: session.receipt, + plays: session.plays, answers: session.answers }, 'receipt-rave-listening.json')); + +async function check(value) { + const result = await verifyReceipt(value); + $('verify-result').textContent = result.verified ? 'VERIFIED / regenerated score, PCM, WAV, and render statistics agree.' + : `REJECTED / mismatch: ${result.mismatches.join(', ')}`; +} +async function verifyAction(fn) { + if (verifying) return; + verifying = true; buttons(); $('verify-result').textContent = 'Regenerating the receipt…'; + try { await check(await fn()); } + catch (e) { $('verify-result').textContent = `REJECTED / ${e.message}`; } + finally { verifying = false; buttons(); } +} +$('tamper').addEventListener('click', () => verifyAction(() => ({ ...receipt, + reference_pcm_sha256: (receipt.reference_pcm_sha256[0] === '0' ? '1' : '0') + receipt.reference_pcm_sha256.slice(1) }))); +$('verify-file').addEventListener('change', () => verifyAction(async () => { + const file = $('verify-file').files[0]; + if (!file || file.size > 16384) throw new Error('Choose a JSON receipt of at most 16 KiB.'); + return JSON.parse(await file.text()); +})); + +const url = new URL(location.href); +let initial; +try { initial = config({ seed: url.searchParams.get('seed') ?? undefined, + bpm: url.searchParams.has('bpm') ? Number(url.searchParams.get('bpm')) : undefined, + delay: url.searchParams.has('delay') ? Number(url.searchParams.get('delay')) : undefined }); } +catch { initial = config(); } +action(() => forge(initial))(); diff --git a/experiment-002/cli.mjs b/experiment-002/cli.mjs new file mode 100644 index 0000000..ea50423 --- /dev/null +++ b/experiment-002/cli.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Dependency-free artifact renderer and replay checker. Node 22+. +import { mkdir, readFile, writeFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { config, render, makeReceipt, verifyReceipt, wavBytes, sha256 } from './core.mjs'; + +const [mode, ...args] = process.argv.slice(2); +try { + if (mode === 'render' && args.length >= 1 && args.length <= 4) { + const [directory, seed, bpm, delay] = args; + const c = config({ seed, bpm: bpm === undefined ? undefined : Number(bpm), + delay: delay === undefined ? undefined : Number(delay) }); + const receipt = await makeReceipt(c); + const a = wavBytes(render(c).pcm), b = wavBytes(render(c, true).pcm); + await mkdir(directory); // New directory required; never replace an earlier render. + await writeFile(join(directory, 'A.wav'), a, { flag: 'wx' }); + await writeFile(join(directory, 'B.wav'), b, { flag: 'wx' }); + await writeFile(join(directory, 'receipt.json'), JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' }); + console.log(`Rendered ${receipt.frames} frames per file into ${directory}`); + } else if (mode === 'verify' && (args.length === 1 || args.length === 3)) { + if ((await stat(args[0])).size > 16384) throw new Error('Receipt exceeds 16 KiB.'); + const receipt = JSON.parse(await readFile(args[0], 'utf8')); + const result = await verifyReceipt(receipt); + if (!result.verified) throw new Error(`Receipt mismatch: ${result.mismatches.join(', ')}`); + if (args.length === 3) { + for (const [path, field] of [[args[1], 'reference_wav_sha256'], [args[2], 'shifted_wav_sha256']]) { + if ((await stat(path)).size !== 44 + receipt.frames * 2) throw new Error(`Wrong WAV length: ${path}`); + if (await sha256(await readFile(path)) !== receipt[field]) throw new Error(`WAV mismatch: ${path}`); + } + } + console.log(args.length === 3 ? 'VERIFIED / receipt replay and both supplied WAV files agree.' + : 'VERIFIED / receipt replay agrees. No external WAV files were checked.'); + } else { + throw new Error('Usage: node experiment-002/cli.mjs render NEW_DIRECTORY [SEED] [BPM] [DELAY]\n node experiment-002/cli.mjs verify RECEIPT.json [A.wav B.wav]'); + } +} catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/experiment-002/core.mjs b/experiment-002/core.mjs new file mode 100644 index 0000000..deb71a1 --- /dev/null +++ b/experiment-002/core.mjs @@ -0,0 +1,199 @@ +// Receipt Rave v1. CPU synthesis with integer-valued state and PCM16 output. +// JavaScript Numbers carry exact integers here; this is not a chip emulator. +export const ENGINE = 'receipt-rave-v1'; +export const SAMPLE_RATE = 24000; +export const BPMS = Object.freeze([120, 128, 135, 147]); +export const DELAYS = Object.freeze([0, 1, 16, 64, 256, 1024]); +export const LANES = Object.freeze(['PISTON', 'SNARE', 'SHRAPNEL', 'SUB', 'CARRIER']); +// Frozen millihertz: 12-tone equal temperament, A4 = 432 Hz. +const NOTES = Object.freeze([108000, 121227, 128435, 144163, 161817, 171438, 192436]); +const SCALE = Object.freeze([0, 2, 3, 4, 6, 4, 2, 0]); +const trunc = Math.trunc; + +export function config(input = {}) { + const seed = input.seed ?? 'the-window-is-closed'; + const bpm = input.bpm ?? 147; + const delay = input.delay ?? 64; + if (typeof seed !== 'string' || !seed.trim() || [...seed.trim()].length > 64) + throw new RangeError('Seed must contain 1–64 characters.'); + if (!BPMS.includes(bpm)) throw new RangeError('Unsupported tempo.'); + if (!DELAYS.includes(delay)) throw new RangeError('Unsupported sample delay.'); + return { seed: seed.trim(), bpm, delay }; +} + +function hashSeed(seed) { + let h = 0x811c9dc5; + for (const byte of new TextEncoder().encode(seed)) h = Math.imul(h ^ byte, 0x01000193) >>> 0; + return h; +} + +function random(seed) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state; + }; +} + +export function stepFrame(step, bpm) { + return trunc((step * SAMPLE_RATE * 60 + bpm * 2) / (bpm * 4)); +} + +export function makeScore(input) { + const c = config(input); + const next = random(hashSeed(c.seed)); + const transpose = next() % 4; + const events = []; + const add = (step, lane, note, salt) => events.push({ step, lane, note, salt }); + for (let step = 0; step < 64; step++) { + const r = next(); + const beat = step % 16; + if (beat % 4 === 0 || (beat === 14 && (r & 1))) add(step, 0, 0, r); + if (beat === 4 || beat === 12 || (step > 47 && beat === 15)) add(step, 1, 0, r ^ 0x51); + if (beat % 2 === 0 || (step > 31 && (r >>> 20) % 3 === 0)) add(step, 2, 0, r ^ 0x72); + if (beat % 4 === 0 || beat === 7 || beat === 15) add(step, 3, [0, 0, 5, 3][trunc(step / 16)], r); + if (beat % 2 === 0 || beat === 11 || beat === 15) + add(step, 4, SCALE[(trunc(beat / 2) + transpose + trunc(step / 16)) % 8], r); + } + return { engine: ENGINE, seed: c.seed, bpm: c.bpm, steps: 64, events }; +} + +const phaseIncrement = mhz => { + // Split the 32-bit scale so intermediate integers stay below 2^53. + const numerator = mhz * 16777216, denominator = SAMPLE_RATE * 1000; + return trunc(numerator / denominator) * 256 + trunc((numerator % denominator) * 256 / denominator); +}; +const triangle = phase => { + const p = phase >>> 20; + return p < 2048 ? p * 2 - 2048 : 6143 - p * 2; +}; +const pulse = phase => (phase >>> 24) < 72 ? 1536 : -600; + +function voice(event, start, mix, level = 1024) { + const lane = event.lane; + const length = [7200, 3000, 1000, 5200, 3100][lane]; + const amplitude = [5100, 2400, 700, 2500, 1600][lane]; + const frequency = NOTES[event.note] * (lane === 4 ? 4 : 1); + let phase = 0, second = 0, smooth = 0; + const noise = random(event.salt); + for (let i = 0; i < length && start + i < mix.length; i++) { + let value; + if (lane === 0) { + const mhz = 47000 + trunc(125000 * (length - i) * (length - i) / (length * length)); + phase = (phase + phaseIncrement(mhz)) >>> 0; + value = triangle(phase); + if (i < 64) value += trunc(((noise() >>> 20) - 2048) * (64 - i) / 64); + } else if (lane === 1 || lane === 2) { + phase = (phase + phaseIncrement(lane === 1 ? 181000 : 3487000)) >>> 0; + second = (second + phaseIncrement(5213000)) >>> 0; + const n = (noise() >>> 20) - 2048; + value = lane === 1 ? trunc((n * 3 + triangle(phase)) / 4) + : trunc((n + ((phase ^ second) >>> 31 ? 2048 : -2048)) / 2); + } else { + phase = (phase + phaseIncrement(frequency)) >>> 0; + const raw = lane === 3 ? trunc((triangle(phase) * 3 + pulse(phase)) / 4) : pulse(phase); + smooth += trunc((raw - smooth) / (lane === 3 ? 4 : 2)); + value = smooth; + } + const attack = Math.min(64, i); + const envelope = trunc((length - i) * 1024 / length); + const shaped = trunc(value * amplitude / 2048); + mix[start + i] += trunc(shaped * envelope * attack * level / (1024 * 64 * 1024)); + } +} + +// delay shifts only the CARRIER lane and its echoes. Both outputs have equal length. +export function render(input, shifted = false) { + const c = config(input); + const score = makeScore(c); + const frames = stepFrame(64, c.bpm) + SAMPLE_RATE; + const mix = new Int32Array(frames); + for (const event of score.events) { + const offset = shifted && event.lane === 4 ? c.delay : 0; + const start = stepFrame(event.step, c.bpm) + offset; + voice(event, start, mix); + if (event.lane === 4) { + voice(event, start + stepFrame(3, c.bpm), mix, 256); + voice(event, start + stepFrame(6, c.bpm), mix, 96); + } + } + const pcm = new Int16Array(frames); + let peak = 0, clipped = 0; + for (let i = 0; i < frames; i++) { + const sample = mix[i]; + if (sample > 32767 || sample < -32768) clipped++; + pcm[i] = Math.max(-32768, Math.min(32767, sample)); + peak = Math.max(peak, Math.abs(pcm[i])); + } + return { pcm, score, peak, clipped }; +} + +export function pcmBytes(pcm) { + const bytes = new Uint8Array(pcm.length * 2); + const view = new DataView(bytes.buffer); + for (let i = 0; i < pcm.length; i++) view.setInt16(i * 2, pcm[i], true); + return bytes; +} + +export function wavBytes(pcm) { + const raw = pcmBytes(pcm); + const bytes = new Uint8Array(44 + raw.length); + const view = new DataView(bytes.buffer); + const label = (offset, text) => [...text].forEach((x, i) => view.setUint8(offset + i, x.charCodeAt(0))); + label(0, 'RIFF'); view.setUint32(4, bytes.length - 8, true); + label(8, 'WAVE'); label(12, 'fmt '); view.setUint32(16, 16, true); + view.setUint16(20, 1, true); view.setUint16(22, 1, true); + view.setUint32(24, SAMPLE_RATE, true); view.setUint32(28, SAMPLE_RATE * 2, true); + view.setUint16(32, 2, true); view.setUint16(34, 16, true); + label(36, 'data'); view.setUint32(40, raw.length, true); bytes.set(raw, 44); + return bytes; +} + +export function canonical(value) { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value); + if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']'; + if (value && Object.getPrototypeOf(value) === Object.prototype) + return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}'; + throw new TypeError('Canonical data must contain only JSON values and safe integers.'); +} + +export async function sha256(bytes) { + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map(x => x.toString(16).padStart(2, '0')).join(''); +} + +export async function makeReceipt(input) { + const c = config(input); + const a = render(c), b = render(c, true); + const [score, ah, bh, aw, bw] = await Promise.all([ + sha256(new TextEncoder().encode(canonical(a.score))), sha256(pcmBytes(a.pcm)), + sha256(pcmBytes(b.pcm)), sha256(wavBytes(a.pcm)), sha256(wavBytes(b.pcm)) + ]); + return { schema: 'rando.audio-receipt.v1', engine: ENGINE, ...c, + sample_rate: SAMPLE_RATE, frames: a.pcm.length, score_sha256: score, + reference_pcm_sha256: ah, shifted_pcm_sha256: bh, + reference_wav_sha256: aw, shifted_wav_sha256: bw, + reference_peak: a.peak, shifted_peak: b.peak, + reference_clipped: a.clipped, shifted_clipped: b.clipped }; +} + +export async function verifyReceipt(receipt) { + if (!receipt || Object.getPrototypeOf(receipt) !== Object.prototype) throw new TypeError('Expected a receipt object.'); + if (receipt.schema !== 'rando.audio-receipt.v1' || receipt.engine !== ENGINE) + throw new Error('Unsupported receipt schema or engine.'); + const c = config(receipt); + if (c.seed !== receipt.seed) throw new Error('Receipt seed is not normalized.'); + const expected = await makeReceipt(c); + if (canonical(Object.keys(receipt).sort()) !== canonical(Object.keys(expected).sort())) + throw new Error('Receipt fields differ from the v1 schema.'); + const mismatches = Object.keys(expected).filter(k => receipt[k] !== expected[k]); + return { verified: mismatches.length === 0, mismatches }; +} + +// Caller supplies a browser-random bit. The seed is deliberately not the ABX answer key. +export function trial(bit, choice) { + if (bit !== 0 && bit !== 1) throw new RangeError('Trial bit must be 0 or 1.'); + if (choice !== 'A' && choice !== 'B') throw new RangeError('Choice must be A or B.'); + return { x: bit === 0 ? 'A' : 'B', choice, correct: choice === (bit === 0 ? 'A' : 'B') }; +} diff --git a/experiment-002/index.html b/experiment-002/index.html new file mode 100644 index 0000000..b37cc60 --- /dev/null +++ b/experiment-002/index.html @@ -0,0 +1,64 @@ + + + + + + + RANDO 002 / Receipt Rave + + + +
+
R∅NDOEXPERIMENT 002 / 02 OCT 2026FIELD NOTES ↗
+
+
+

THE WINDOW CLOSED. THE MACHINES GOT LOUD.

+

Receipt
Rave.

+

A tiny industrial tracker that can show its working.

Forge a loop. Move its lead by a few samples. Test your ears. Keep the evidence.

432 Hz / A MINOR / CPU SYNTHESIS
+
+
+

01 / THE FOUNDRY

64 STEPS · 5 LANES · ZERO DEPENDENCIES
+
+ + + + +
+

Same seed + settings + engine → same PCM bytes. 1–64 Unicode characters. No model or server generates this audio.

+
FORGING…
+ +
+

A is the original. B shifts only the lead lane and its echoes. Same duration, same mix settings.

+

Preparing the first loop.

+ +
+
+
+

02 / CAN YOU HEAR IT?

ABX / 8 TRIALS
+

Hear A, hear B, then hear X. Is X a copy of A or B? Lock each answer before the next trial. Results stay hidden until the end.

+

Use the same output and volume. This is an informal listening game; the 0-sample control has identical references.

+ + +
+ +
+
+

03 / SHOW THE RECEIPT

SHA-256
+

The score, raw PCM, and WAV files each get a hash. Export a receipt and regenerate it here—or in Node—to check the bytes.

+
REFERENCE PCM…SHIFTED PCM…
+
+

A matching hash establishes byte agreement. It does not certify authorship, scientific validity, or good taste.

+
+
+

The joke has
a checksum.

Experiment 001 made questions. This run inferred an instrument from recurring themes: industrial music, tracker timing, deterministic execution, and inspectable evidence.

The experiment is what the model chose to build when given room. Your listening results are a separate observation. Neither proves general AI autonomy or human hearing limits.

READ THE CHOICE, METHOD & LIMITS ↗
+
+ +
+ + diff --git a/experiment-002/style.css b/experiment-002/style.css new file mode 100644 index 0000000..62d4dfb --- /dev/null +++ b/experiment-002/style.css @@ -0,0 +1,4 @@ +:root{color-scheme:dark;--bg:#111;--panel:#191919;--ink:#edeae1;--muted:#aaa69c;--line:#424039;--accent:#e4ad55;font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}a{color:inherit;text-underline-offset:4px}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.35;cursor:default}button,input,select{border:1px solid var(--line);border-radius:0;background:var(--bg);color:var(--ink);padding:12px}button{font-size:11px;letter-spacing:.06em;font-weight:700;min-height:44px}button:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}.primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.primary:hover:not(:disabled){background:#f8c57e;color:#111}.shell{max-width:1280px;margin:auto;padding:0 36px}header{display:flex;justify-content:space-between;align-items:center;gap:16px;min-height:80px;border-bottom:1px solid var(--line);font-size:10px;letter-spacing:.12em}.brand{font-size:28px;letter-spacing:-3px;text-decoration:none;font-weight:900}header a:last-child{color:var(--accent)}.intro{display:grid;grid-template-columns:1.3fr 1fr;gap:0 60px;padding:62px 0 56px;align-items:end}.eyebrow{color:var(--accent);font-size:11px;letter-spacing:.12em;line-height:1.7}.intro>.eyebrow{grid-column:1/-1;margin:0 0 26px}h1{font-family:Arial,Helvetica,sans-serif;font-size:clamp(72px,10vw,148px);line-height:.83;letter-spacing:-.07em;margin:0;font-weight:900}h1 em{color:var(--accent);font-style:normal}.intro-side{max-width:390px}.intro-side p{font-size:17px;line-height:1.6;margin:0 0 18px}.intro-side p+p{font-size:14px;color:var(--muted)}.stamp{font-size:10px;letter-spacing:.08em;display:block;border-top:1px solid var(--line);padding-top:20px}.console{background:var(--panel);border:1px solid var(--line);padding:24px}.section-head{display:flex;justify-content:space-between;align-items:center;gap:20px;padding-bottom:20px;border-bottom:1px solid var(--line)}.section-head h2,.section-head>span{font-size:11px;letter-spacing:.08em;margin:0}.section-head>span{font-size:9px;color:var(--muted)}.controls{display:flex;gap:12px;align-items:end;margin-top:24px}label{font-size:10px;letter-spacing:.08em;color:var(--muted);display:flex;flex-direction:column;gap:8px}.seed-label{flex:1;min-width:0}input,select{font-size:13px;min-height:44px;min-width:0;letter-spacing:0}.controls button{white-space:nowrap}.micro{font-size:11px;line-height:1.7;color:var(--muted)}.scope{position:relative;background:#111;margin-top:26px;border:1px solid var(--line);height:122px}.scope canvas{display:block;width:100%;height:100%}.scope span{position:absolute;bottom:8px;left:10px;background:#111e;padding:3px;font-size:9px;color:var(--accent)}.tracker{display:grid;grid-template-columns:88px 1fr;gap:5px 10px;padding:24px 0}.lane-name{font-size:10px;line-height:18px;color:var(--muted)}.lane-steps{display:grid;grid-template-columns:repeat(64,minmax(0,1fr));gap:2px;min-width:0}.step{height:18px;background:#252525;border-bottom:1px solid #38372f}.step.bar{border-left:1px solid #a49a80}.step.hit{background:#aa7d3a}.step.lead{background:#e4ad55}.transport,.button-row{display:flex;gap:8px;flex-wrap:wrap}.transport{align-items:center}.volume{margin-left:auto;flex-direction:row;align-items:center;font-size:10px;gap:10px}.volume input{padding:0;width:110px;accent-color:var(--accent);min-height:44px}.volume output{min-width:32px}.status{font-size:12px;line-height:1.6;min-height:20px;color:var(--accent);margin-bottom:0}.lower{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.lower p{font-size:13px;line-height:1.7}.lower p.micro{font-size:11px}.lower .button-row{margin:14px 0}.hash{margin:22px 0;display:grid;gap:8px}.hash span,.verify>label{font-size:9px;color:var(--muted);letter-spacing:.1em}.hash code{font-size:10px;overflow-wrap:anywhere;line-height:1.7;color:var(--accent);margin-bottom:6px}.verify{border-top:1px solid var(--line);margin-top:22px;padding-top:20px;display:grid;gap:12px}.verify input{max-width:100%;font-size:11px}.verify p{font-size:11px;margin:0;color:var(--muted)}#session{margin:22px 0}#session[hidden]{display:none}#result{font-size:13px;line-height:1.7;margin:18px 0;color:var(--accent)}#session-export{margin-top:12px}.notes{display:grid;grid-template-columns:1fr 1fr;gap:60px;margin:64px 0;padding:36px 0;border-top:1px solid var(--line)}.notes h2{font-family:Arial,Helvetica,sans-serif;letter-spacing:-.055em;font-size:clamp(36px,5vw,65px);line-height:1.05;margin:0}.notes p{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 16px}.notes a{font-size:10px;color:var(--accent);line-height:1.8}footer{border-top:1px solid var(--line);padding:26px 0 40px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-size:9px;letter-spacing:.06em;color:var(--muted)}footer a{color:var(--accent)} +@media(max-width:850px){.shell{padding:0 20px}.intro{gap:30px}.controls{flex-wrap:wrap}.seed-label{flex-basis:100%}.lower{grid-template-columns:1fr}.tracker{grid-template-columns:68px 1fr;gap:5px}.lane-name{font-size:8px}.lane-steps{gap:1px}.volume{margin-left:0}.notes{gap:30px}} +@media(max-width:520px){header{font-size:8px}header>span{display:none}.intro{grid-template-columns:1fr;padding-top:42px}.intro>.eyebrow{font-size:9px}.intro-side{margin-top:28px}.intro-side p:first-child{font-size:18px}.console{padding:16px}.section-head>span{font-size:8px;text-align:right;max-width:140px}.controls label:not(.seed-label){flex:1}.controls button{width:100%}.scope{height:100px}.step{height:14px}.lane-name{line-height:14px}.notes{grid-template-columns:1fr}.transport button{flex:1}.volume{width:100%}.volume input{flex:1}.lower .button-row button{flex:1}} diff --git a/index.html b/index.html index 13e93f8..3b04cc3 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,7 @@ QSOL-IMC / EXPERIMENT 001 LOCAL SYSTEM +

NEW / EXPERIMENT 002: RECEIPT RAVE — PLAY THE NOISE, CHECK THE BYTES ↗

AUTONOMOUS REPOSITORY EXPERIMENT /// NO ACCOUNT · NO TELEMETRY
diff --git a/test/receipt-rave.test.mjs b/test/receipt-rave.test.mjs new file mode 100644 index 0000000..59bd061 --- /dev/null +++ b/test/receipt-rave.test.mjs @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { config, render, makeScore, makeReceipt, verifyReceipt, canonical, pcmBytes, wavBytes, + stepFrame, SAMPLE_RATE, BPMS, DELAYS, trial } from '../experiment-002/core.mjs'; + +const golden = { + schema: 'rando.audio-receipt.v1', engine: 'receipt-rave-v1', + seed: 'the-window-is-closed', bpm: 147, delay: 64, sample_rate: 24000, frames: 180735, + score_sha256: 'b962d33584d3510d3379d99e74ac22f1383bc0489f8eb2294a9ff009975d86d4', + reference_pcm_sha256: 'd2cd4ae492adfa0cc4a5b989d9b077f0e83a0ff1fba8da5742ca6a339dbad96a', + shifted_pcm_sha256: '6292a1dcb77d05e9322bd6442b70f5675fa039d53b607585b06368f51e2440a7', + reference_wav_sha256: '9a4cce27073049436d53bd148e9ba9c3b95aaff63d20f0d0ca01bf85ea2e863d', + shifted_wav_sha256: '78f19e0eebec8e347129874ae44ed3aa5a0021b14cd4a0722cac303be96cf300', + reference_peak: 9519, shifted_peak: 8816, reference_clipped: 0, shifted_clipped: 0 +}; + +test('v1 frozen score, PCM, WAV and statistics replay exactly', async () => { + assert.deepEqual(await makeReceipt(config()), golden); + assert.deepEqual(await verifyReceipt(golden), { verified: true, mismatches: [] }); +}); + +test('seed normalization, Unicode, and parameter bounds are explicit', () => { + assert.equal(config({ seed: ' α🙂 ' }).seed, 'α🙂'); + assert.doesNotThrow(() => config({ seed: '🙂'.repeat(64) })); + for (const seed of ['', ' ', 1, '🙂'.repeat(65)]) assert.throws(() => config({ seed })); + for (const bpm of [0, 148, '147', true, NaN]) assert.throws(() => config({ bpm })); + for (const delay of [-1, 2, '64', true, Infinity]) assert.throws(() => config({ delay })); +}); + +test('the score is unaffected by delay and has bounded, ordered events', () => { + assert.deepEqual(makeScore({ delay: 0 }), makeScore({ delay: 1024 })); + const score = makeScore({ seed: 'integer-pistons' }); + assert.equal(score.steps, 64); + assert.equal(new Set(score.events.map(e => e.lane)).size, 5); + for (const e of score.events) { + assert.ok(e.step >= 0 && e.step < 64 && e.lane >= 0 && e.lane < 5); + assert.ok(e.note >= 0 && e.note < 7); + } + assert.notDeepEqual(score, makeScore({ seed: 'integer-pistons!' })); +}); + +test('zero-delay control is byte-identical; every nonzero delay changes PCM', () => { + const a = render({ delay: 0 }); + assert.deepEqual(a.pcm, render({ delay: 0 }, true).pcm); + for (const delay of DELAYS.slice(1)) { + const b = render({ delay }, true); + assert.equal(b.pcm.length, a.pcm.length); + assert.notDeepEqual(b.pcm, a.pcm); + assert.deepEqual(render({ delay }).pcm, a.pcm); + } +}); + +test('timing uses cumulative nearest-frame boundaries without per-step rounding drift', () => { + assert.equal(stepFrame(64, 147), 156735); + assert.equal(stepFrame(64, 120), 192000); + assert.equal(stepFrame(0, 147), 0); + assert.equal(SAMPLE_RATE, 24000); +}); + +test('synthetic corpus renders non-silent PCM without clipping at all tempos', () => { + for (const seed of ['the-window-is-closed', 'a', 'potato', 'α🙂']) for (const bpm of BPMS) { + for (const shifted of [false, true]) { + const r = render({ seed, bpm, delay: 1024 }, shifted); + assert.ok(r.peak > 1000 && r.peak < 32768); + assert.equal(r.clipped, 0); + assert.equal(r.pcm.length, stepFrame(64, bpm) + SAMPLE_RATE); + assert.equal(r.pcm.at(-1), 0, 'tail must fit within the fixed render'); + } + } +}); + +test('WAV uses standard mono PCM16 little-endian header and sample encoding', () => { + const pcm = Int16Array.from([-32768, -1, 0, 1, 32767]); + assert.deepEqual([...pcmBytes(pcm)], [0,128,255,255,0,0,1,0,255,127]); + const bytes = wavBytes(pcm), view = new DataView(bytes.buffer), text = new TextDecoder(); + assert.equal(text.decode(bytes.slice(0, 4)), 'RIFF'); + assert.equal(text.decode(bytes.slice(8, 12)), 'WAVE'); + assert.equal(text.decode(bytes.slice(12, 16)), 'fmt '); + assert.equal(text.decode(bytes.slice(36, 40)), 'data'); + assert.equal(view.getUint32(4, true), bytes.length - 8); + assert.equal(view.getUint16(20, true), 1); assert.equal(view.getUint16(22, true), 1); + assert.equal(view.getUint32(24, true), 24000); assert.equal(view.getUint32(28, true), 48000); + assert.equal(view.getUint16(32, true), 2); assert.equal(view.getUint16(34, true), 16); + assert.equal(view.getUint32(40, true), pcm.length * 2); + assert.deepEqual(bytes.slice(44), pcmBytes(pcm)); +}); + +test('canonical score JSON sorts keys and rejects non-JSON and non-integer values', () => { + assert.equal(canonical({ z: [null, true, 7], a: 'α' }), '{"a":"α","z":[null,true,7]}'); + for (const value of [1.5, NaN, Infinity, undefined, 2 ** 53, { a: undefined }, new Date()]) + assert.throws(() => canonical(value)); +}); + +test('receipt rejects changed hash, numeric type, fields, engine, and normalized seed', async () => { + for (const field of ['score_sha256', 'reference_pcm_sha256', 'shifted_pcm_sha256', 'reference_wav_sha256', 'shifted_wav_sha256']) { + const r = await verifyReceipt({ ...golden, [field]: '0'.repeat(64) }); + assert.deepEqual(r, { verified: false, mismatches: [field] }); + } + assert.equal((await verifyReceipt({ ...golden, frames: String(golden.frames) })).verified, false); + await assert.rejects(verifyReceipt({ ...golden, engine: 'future-v2' })); + await assert.rejects(verifyReceipt({ ...golden, extra: true })); + await assert.rejects(verifyReceipt({ ...golden, seed: ' the-window-is-closed ' })); + const missing = { ...golden }; delete missing.bpm; + await assert.rejects(verifyReceipt(missing)); + await assert.rejects(verifyReceipt(null)); +}); + +test('ABX answer mapping records caller-supplied random bits and rejects invalid choices', () => { + assert.deepEqual(trial(0, 'A'), { x: 'A', choice: 'A', correct: true }); + assert.deepEqual(trial(1, 'A'), { x: 'B', choice: 'A', correct: false }); + assert.deepEqual(trial(1, 'B'), { x: 'B', choice: 'B', correct: true }); + assert.throws(() => trial(2, 'A')); assert.throws(() => trial(0, 'X')); +}); + +test('CLI checks supplied files and refuses damaged WAVs and reused output directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'receipt-rave-')); + const cli = fileURLToPath(new URL('../experiment-002/cli.mjs', import.meta.url)); + const run = (...args) => spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' }); + const out = join(root, 'render'); + try { + assert.equal(run('render', out).status, 0); + const receipt = join(out, 'receipt.json'), a = join(out, 'A.wav'), b = join(out, 'B.wav'); + assert.equal(run('verify', receipt, a, b).status, 0); + assert.notEqual(run('render', out).status, 0); + const bytes = await readFile(a); bytes[100] ^= 1; await writeFile(a, bytes); + const bad = run('verify', receipt, a, b); + assert.equal(bad.status, 1); assert.match(bad.stderr, /WAV mismatch/); + assert.equal(run('verify', receipt).status, 0); + await writeFile(receipt, JSON.stringify({ ...golden, reference_peak: 0 })); + assert.equal(run('verify', receipt).status, 1); + } finally { await rm(root, { recursive: true, force: true }); } +});