From 00a446fec559e69be2ea0c5188e1fcd87a441796 Mon Sep 17 00:00:00 2001 From: Aaron Koppel Date: Wed, 21 Oct 2026 20:29:08 -0400 Subject: [PATCH 1/2] Allow qualified fifth and sixth endings without changing musical generation Add a deterministic eligibility check for settled fifth and major-sixth endings near the finite duration target, with the original tonic fallback. Preserve all random draws, planned voices, note timing, drones, tuning, synthesis and stereo behavior before the selected ending. Use replay engine 3 for the audition and retain engine 1 for saved performances. Keep the public v1.0 release line and existing release tag. Document the musical rules and replay compatibility. Validation: 60 Node checks passed (historical baseline run separately), and all 17 Chromium browser checks passed after correcting keyboard-focus setup and allowing time for repeated native exports. Prefix comparisons cover 400 dispersed seeds across three root tones and Short/Long, plus unchanged Infinite continuation. --- README.md | 14 ++++-- player.html | 4 +- player.js | 92 ++++++++++++++++++++++++++++++----- tests/README.md | 38 +++++++++++++-- tests/browser/player.spec.cjs | 15 ++++-- tests/player.test.cjs | 82 ++++++++++++++++++++++++++++--- 6 files changed, 211 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1ea86e8..bcaaf3a 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,15 @@ In Short, Long, and Infinite modes, the engine follows the same rules to shape t * **Contour Constraints:** A bounded random walk keeps the melody between A2 and G#4. Notes that exceed the range are octave-shifted downward. * **Tension Mapping:** Cadence choices and resets at the start of an arc update a tension value. Phrase position and tension influence the next cadence; tension also shapes FM brightness, the use of fractured ratios, and whether a drone includes a third. -Short and Long seek a natural tonic ending after their target duration. Infinite continues developing the same musical structure until stopped. +Short and Long seek a musical ending around their target duration. The tonic remains the fallback. A fifth can end a settled passage when the melody returns to a recent pitch by step or repetition. A major sixth needs a stepwise excursion back to a recent pitch in a major passage. Both require a stable harmonic center, low tension, and compatibility with any remaining drone. All voices and reverb finish their full decay. Infinite continues developing the same musical structure until stopped. + +This branch auditions qualified endings for v1.0. These are general compositional rules, not a guarantee that every ending will be equally convincing; listening across varied performances remains part of the evaluation. + +Only ending eligibility changes. Melody generation, note timing, tempo, drones, tuning, synthesis, and stereo movement retain their existing rules. The ending check consumes no random draws and leaves every planned voice before the chosen ending unchanged. ### Technical Architecture -Official release: **v1.0** (1.0.0). Musical engine: **1**. +Release line: **v1.0** (1.0.0). This branch uses musical engine **3**, with engine **1** retained for saved replays. Engine identifiers describe musical behavior independently of the release number; engine 2 belongs to a separate tempo experiment. *Open* uses the standard Web Audio API with no runtime dependencies, using a custom procedural engine to synthesize the environment live. @@ -46,7 +50,7 @@ A desktop browser is recommended for listening, with keyboard shortcuts for reco Set the length and root tone before pressing Play. -1. **Length:** Select Short, Long, or Infinite. Short is a song-like form that begins seeking its natural ending after 1 minute and typically lasts about 1–3 minutes. Long is a more ambient form that begins seeking its natural ending at a point chosen between 10 and 30 minutes for each performance. These are ranges rather than fixed runtimes: resolving the phrase and allowing the sound to decay can carry a performance beyond them. Infinite continues until stopped. +1. **Length:** Select Short, Long, or Infinite. Short is a song-like form with a 1-minute ending target and typically lasts about 1–3 minutes. Long is a more ambient form with an ending target chosen between 10 and 30 minutes for each performance. A qualified fifth or sixth may close the passage on the strike just before the target, at most six seconds early; otherwise the music continues toward a suitable ending. These are ranges rather than fixed runtimes: resolving the phrase and allowing the sound to decay can carry a performance beyond them. Infinite continues until stopped. 2. **Root Tone:** Drag the dial up to raise the root frequency or down to lower it, using a mouse or touch. The range is 110–200 Hz. With the dial focused, use the arrow keys for 1 Hz adjustments. The readout below the dial shows the selected frequency. 3. **Play:** Create a new performance using the selected root tone and length. 4. **Replay:** Restart the current or most recent performance from the beginning with its saved root tone, length, and WAV sample rate. Replay restores the dial and length even if you changed them after stopping. It is disabled until a performance has started, then remains available during playback, after Stop, and after a natural ending. @@ -62,13 +66,13 @@ Your last length and root tone are remembered locally. On mobile, keep the playe Open **Revisit** below the playback buttons to load an earlier performance, share a replay link, or export a WAV. -1. **Load & play:** Paste an eight-character hexadecimal seed, an Open WAV filename (with or without `.wav`), or a replay link, then press Load & play. A seed or older filename uses the root tone and length currently selected above. New WAV filenames and replay links restore those settings automatically, including the root tone dial position. Opening a replay link prepares the controls and waits for you to press Load & play; it never starts audio automatically. Once loaded and playing, this becomes the performance returned to by the main Replay button. Play always draws a fresh seed, even when a replay link is open. +1. **Load & play:** Paste an eight-character hexadecimal seed, an Open WAV filename (with or without `.wav`), or a replay link, then press Load & play. A seed or older filename uses the root tone and length currently selected above. A bare seed uses the current engine; an older filename without an engine tag uses engine 1 by convention. New WAV filenames and replay links restore those settings automatically, including the root tone dial position. Opening a replay link prepares the controls and waits for you to press Load & play; it never starts audio automatically. Once loaded and playing, this becomes the performance returned to by the main Replay button. Play always draws a fresh seed, even when a replay link is open. 2. **Copy replay link:** After playing or replaying, copy a link to that performance. It includes the seed, root tone, length, musical engine version, and WAV sample rate. If clipboard access is unavailable, a selected link appears for manual copying. Copy and export always refer to the current or most recent performance, even if you change the controls after stopping. 3. **Export again:** Use Export WAV in Revisit, or **`Shift + E`**, to render the performance from its beginning. You can stop listening immediately and still export its complete planned ending. A saved replay link or new WAV filename lets you return after closing the page. The generator is deterministic: matching seed, root tone, length, and engine version reproduce the planned notes, timing, FM voices, and stereo movement. The sample rate is retained for WAV rendering; live playback uses the device's audio rate. Browser or device differences can affect the rendered samples, and mastering or other processing applied outside Open is not part of replay. Repeating a seed deliberately revisits a performance; fresh random seeds are not a mathematical guarantee that a seed can never recur. -Older filenames contain a seed and timestamps but omit the root tone, length, and engine version. Replay cannot infer those missing settings from the filename or verify which engine made it. Links and new filenames with an unsupported engine version are rejected with an explanation, rather than silently replayed with changed musical rules. This release supports engine 1; it does not load archived engines or reconstruct a seed from an audio recording. +Older filenames contain a seed and timestamps but omit the root tone, length, and engine version. Replay cannot infer those missing settings from the filename or verify which engine made it. Links and new filenames with an unsupported engine version are rejected with an explanation, rather than silently replayed with changed musical rules. This branch supports engines 1 and 3. Engine-1 links and filenames preserve the original tonic endings. New performances use engine 3, and their full filenames and replay links retain that version. A bare seed can therefore have a different ending under the current engine. The player does not reconstruct a seed from an audio recording. ### Output & Saving Save the audio for listening and keep its original filename or replay link to preserve the settings. Live recording remains a desktop keyboard feature; WAV export is also available in Revisit. diff --git a/player.html b/player.html index 1b7bee3..bb39177 100644 --- a/player.html +++ b/player.html @@ -22,7 +22,7 @@ - +
@@ -103,7 +103,7 @@

Open

- + diff --git a/player.js b/player.js index cc4a266..06750a9 100644 --- a/player.js +++ b/player.js @@ -55,13 +55,16 @@ const STATE_KEY = "open_player_settings"; // schema-stable key: don't tie this to the script version // Bump when musical planning or synthesis changes. UI-only releases keep this // version; unknown versions must never silently play with a different engine. - const ENGINE_VERSION = "1"; + // Engine 2 is reserved for the separate tempo experiment. Musical engine + // identifiers are independent of the public v1.0 release number. + const ENGINE_VERSION = "3"; + const SUPPORTED_ENGINES = new Set(["1", ENGINE_VERSION]); function seedHex(seed) { return seed.toString(16).padStart(8, "0"); } function validateReplay(record) { - if (record.engine !== ENGINE_VERSION) - throw new Error(`This performance needs engine ${record.engine || "(unspecified)"}; this player uses ${ENGINE_VERSION}.`); + if (!SUPPORTED_ENGINES.has(record.engine)) + throw new Error(`This performance needs engine ${record.engine || "(unspecified)"}; this player supports engines 1 and ${ENGINE_VERSION}.`); if (!/^[0-9a-f]{8}$/i.test(record.seed || "")) throw new Error("Enter an eight-character hexadecimal seed."); const tone = Number(record.tone), sampleRate = Number(record.sampleRate); @@ -72,7 +75,7 @@ if (record.sampleRate != null && (!Number.isInteger(sampleRate) || sampleRate < 3000 || sampleRate > 384000)) throw new Error("The replay sample rate is invalid."); return { seed: parseInt(record.seed, 16), tone, duration: record.duration, - engine: ENGINE_VERSION, sampleRate: record.sampleRate == null ? null : sampleRate }; + engine: record.engine, sampleRate: record.sampleRate == null ? null : sampleRate }; } function parseReplayQuery(params) { @@ -99,9 +102,11 @@ const seed = legacy ? legacy[1] : input; if (!/^[0-9a-f]{8}$/i.test(seed)) throw new Error("Paste an eight-character seed, an Open WAV filename, or a replay link."); - return { ...validateReplay({ seed, engine: ENGINE_VERSION, + // Older open-run names use engine 1 by convention. Bare seeds explore the + // current engine; complete filenames and links always retain their version. + return { ...validateReplay({ seed, engine: legacy ? "1" : ENGINE_VERSION, tone: $("tone")?.value ?? "110", duration: $("songDuration")?.value ?? "short" }), - partial: true }; + partial: true, legacy: !!legacy }; } function replayLink(snapshot) { @@ -737,9 +742,42 @@ let liveNextGroup = null; let liveTimeShift = 0; + // Conservative compositional eligibility, evaluated on already planned notes. + // This consumes no random numbers and does not alter the approach to an ending. + function qualifiedEnding(history, degree, isMinor, atClimax, droneNotes, time) { + if (history.length < 4 || atClimax || (degree !== 4 && degree !== 5)) return null; + if (degree === 5 && isMinor) return null; + const current = history.at(-1), previous = history.at(-2); + const tensionLimit = degree === 5 ? 0.15 : 0.25; + if (!history.slice(-4).every(n => n.key === current.key && n.tension <= tensionLimit)) return null; + const interval = (a, b) => 12 * Math.log2(a / b); + const step = Math.abs(interval(current.freq, previous.freq)); + if (step > 2.01) return null; + const earlier = history.slice(0, -1); + const returnAt = earlier.findIndex(n => n.key === current.key && Math.abs(interval(n.freq, current.freq)) < 0.01); + if (returnAt < 0) return null; + if (degree === 5) { + // A sixth must complete a small excursion back to a known pitch, rather + // than simply repeat or end a new upward run. No particular melody is prescribed. + const gesture = history.slice(returnAt); + if (gesture.length < 3 || step < 0.01 || !gesture.every((n, i) => + n.key === current.key && n.tension <= tensionLimit && + (i === 0 || Math.abs(interval(n.freq, gesture[i - 1].freq)) <= 2.01))) return null; + } + // Include drone release and room decay. Avoid a fresh drone attack and + // semitone, major-seventh, or tritone clashes with its nominal pitches. + // FM partials remain inharmonic; this is a harmonic safeguard, not an audition. + for (const drone of droneNotes) { + if (time < drone.time + 2) return null; + const pitchClass = ((Math.round(interval(current.freq, drone.freq)) % 12) + 12) % 12; + if ([1, 6, 11].includes(pitchClass)) return null; + } + return degree === 5 ? "sixth" : "fifth"; + } + // The live musical rules have one owner. Planning draws all musical and // spatial choices before rendering; neither renderer consumes these streams. - function createPerformanceGenerator(seed, baseFreq, duration) { + function createPerformanceGenerator(seed, baseFreq, duration, engine = ENGINE_VERSION) { const rand = mulberry32(seed); const chance = p => rand() < p; const spatialRandom = mulberry32((seed ^ 0x57E2E0) >>> 0); @@ -750,6 +788,9 @@ let phraseStep = 0, phraseCount = 0, arcLen = 6, arcPos = -1, arcClimaxAt = 4; let tension = 0, lastCadenceType = "none", currentCadenceType = "none"; let lastDroneStart = -9999, lastDroneDur = 0, finished = false; + const allowOpenEnding = engine === ENGINE_VERSION && duration !== Infinity; + const endingHistory = []; + let endingDrones = []; function startNewArc() { arcLen = 4 + Math.floor(rand() * 5); arcClimaxAt = Math.max(2, arcLen - 2 - Math.floor(rand() * 2)); @@ -861,6 +902,7 @@ group.notes.push(planBell(fEnd, nextTimeA, 25.0, 0.5, 0, 0, rand, spatialRandom)); finished = true; group.ending = true; + if (allowOpenEnding) group.endingKind = "tonic"; return group; } } @@ -1000,6 +1042,30 @@ notesSinceModulation++; nextTimeA += (1 / runDensity) * (0.95 + rand() * 0.1); + if (allowOpenEnding) { + endingDrones = endingDrones.filter(n => group.time < n.time + n.duration + 10.465); + endingDrones.push(...group.notes.filter(n => n.kind === "bass")); + if (!isDroneSolo) { + endingHistory.push({ freq, key: `${((circlePosition % 12) + 12) % 12}:${isMinor}`, + tension }); + if (endingHistory.length > 5) endingHistory.shift(); + // Consider the strike straddling the target, at most six seconds + // early, and later strikes until the existing tonic fallback wins. + const earliest = duration - Math.min(6, 1 / runDensity); + if (group.time >= earliest && nextTimeA >= duration) { + const kind = qualifiedEnding(endingHistory, degNow, isMinor, + arcPos === arcClimaxAt, endingDrones, group.time); + if (kind) { + finished = true; + group.ending = true; + group.endingKind = kind; + } + } + } else { + // Do not join a remembered contour across a drone-only passage. + endingHistory.length = 0; + } + } return group; } }; @@ -1016,9 +1082,10 @@ throw new Error("Invalid duration"); } - function createPerformance(seed, tone, durationInput) { + function createPerformance(seed, tone, durationInput, engine = ENGINE_VERSION) { + if (!SUPPORTED_ENGINES.has(engine)) throw new Error("Unsupported musical engine"); const duration = resolveDuration(seed, durationInput); - const generator = createPerformanceGenerator(seed, tone, duration); + const generator = createPerformanceGenerator(seed, tone, duration, engine); const groups = []; let lastVoiceEnd = 0; while (duration !== Infinity || generator.nextTime < 1800) { @@ -1247,13 +1314,14 @@ if (!Number.isFinite(tone)) tone = 110; tone = Math.round(Math.max(110, Math.min(200, tone))); const duration = replay ? replay.duration : $("songDuration")?.value ?? "short"; - const performance = createPerformance(seed, tone, duration); + const engine = replay?.engine ?? ENGINE_VERSION; + const performance = createPerformance(seed, tone, duration, engine); buildMixBus(seed); setSeed(seed); // Created once per run; exports keep this ID even after Stop or a new Play. const runId = `${seedHex(seed)}-${Date.now().toString(36)}`; sessionSnapshot = Object.freeze({ ...performance, sampleRate: replay?.sampleRate ?? ctx.sampleRate, - runId, engine: ENGINE_VERSION }); + runId, engine }); if (replay) { applyControls({ tone, songDuration: duration }); saveState(readControls()); @@ -1270,7 +1338,7 @@ setButtonState("playing"); replayStatus(replay?.partial - ? `Playing seed ${seedHex(seed)} with the selected root tone and length. A seed or older filename cannot verify the original settings or engine.` + ? `Playing seed ${seedHex(seed)} with the selected root tone and length (engine ${engine}). ${replay.legacy ? "An older filename assumes engine 1 but cannot verify the original settings or engine." : "A seed alone does not include the original settings; keep a replay link or full filename to preserve this version."}` : `${replay ? "Replaying" : "Playing"} seed ${seedHex(seed)}. You can copy its replay link or export it below.`); if (timerInterval) clearInterval(timerInterval); diff --git a/tests/README.md b/tests/README.md index 5c7e0ee..4bcf71c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -38,9 +38,9 @@ PCM tolerance as repeated exports (at most two 16-bit steps, RMS error below DSP changes must bump `ENGINE_VERSION`; never reuse an engine version for a different performance. UI-only changes can retain it. -The sparse interface, musical probabilities, synthesis envelopes, live ending rules, and manual Stop fade are retained. Completed voices disconnect after a 100 ms filter-settling allowance. Natural completion releases the bus after the last scheduled voice, filter settling, full impulse response, pre-delay, and a 250 ms return-filter margin. Cleanup uses audio time so browser suspension does not truncate a pending tail. New sessions reset drone timing that belonged to earlier sessions. +The sparse interface, musical probabilities, synthesis envelopes, and manual Stop fade are retained. Engine 1 preserves the original ending rules; engine 3 adds the qualified endings described below. Completed voices disconnect after a 100 ms filter-settling allowance. Natural completion releases the bus after the last scheduled voice, filter settling, full impulse response, pre-delay, and a 250 ms return-filter margin. Cleanup uses audio time so browser suspension does not truncate a pending tail. New sessions reset drone timing that belonged to earlier sessions. -If the scheduler resumes after an event's start time has passed, it schedules the next event 50 ms ahead of the current audio clock and keeps the current phrase state. It does not generate missed events. Normal scheduling, clock suspension, and the live ending rules retain their previous behavior. Export now follows that same planned ending. +If the scheduler resumes after an event's start time has passed, it schedules the next event 50 ms ahead of the current audio clock and keeps the current phrase state. It does not generate missed events. Normal scheduling and clock suspension retain their previous behavior. Export follows the same planned ending as live playback for the selected engine. WAV encoding runs in a dedicated worker. At most 65,536 frames per channel are copied and transferred in each message; the next chunk is sent only after the worker acknowledges the previous one. The worker builds the final file from Blob parts, preserving the original 16-bit conversion without a full-size WAV ArrayBuffer on the main thread. The full OfflineAudioContext render buffer is still required (roughly 741–806 MB for 35 minutes of rendered audio at 44.1–48 kHz), so long exports remain memory intensive. @@ -100,11 +100,11 @@ The render buffer remains available during encoding while WAV Blob parts accumul ## Shared performance and export -Play captures seed, tone, length, its deterministic ending threshold, and the native sample rate. Short begins seeking an ending after 1 minute; Long chooses a threshold from 10–30 minutes using a random stream separate from the musical and spatial streams. A pure planner retains plain note data for the complete timed performance, or the first 30 minutes for Infinite. Musical choices use the existing live random draw order; stereo choices use a separate stream. Live and offline rendering use the same planned voice parameters and rendering functions. New Play replaces the retained performance; an in-flight export keeps its own reference. Stop and natural cleanup release live audio nodes while retaining the score for export. Disposal clears the score. +Play captures seed, tone, length, its deterministic ending threshold, and the native sample rate. Short has a 1-minute target; Long chooses a target from 10–30 minutes using a random stream separate from the musical and spatial streams. Engine 1 seeks its tonic after the target; engine 3 can also accept a qualified fifth or sixth around that target. A pure planner retains plain note data for the complete timed performance, or the first 30 minutes for Infinite. Musical choices use the existing live random draw order; stereo choices use a separate stream. Live and offline rendering use the same planned voice parameters and rendering functions. New Play replaces the retained performance; an in-flight export keeps its own reference. Stop and natural cleanup release live audio nodes while retaining the score for export. Disposal clears the score. Infinite continues with an incremental generator after the retained 30-minute prefix. It does not append later events to the score, so retained history remains bounded. Export never advances that generator. Scheduler-stall offsets belong only to live playback and never mutate the score. -A 50 ms scheduling lead-in gives live nodes a common future origin, avoiding late starts for an opening drone. Export starts at that origin. A common session origin controls master fade, the reverb-send envelope, and resonator LFO phase. The room uses a fixed deterministic impulse seed and the same native sample rate for both renderers. The opening reverb target is scheduled once; repeatedly targeting the same level with the same time constant is unnecessary. Natural-end eligibility uses the planned event time and the existing lookahead allowance, so it no longer depends on callback jitter at the duration threshold. +A 50 ms scheduling lead-in gives live nodes a common future origin, avoiding late starts for an opening drone. Export starts at that origin. A common session origin controls master fade, the reverb-send envelope, and resonator LFO phase. The room uses a fixed deterministic impulse seed and the same native sample rate for both renderers. The opening reverb target is scheduled once; repeatedly targeting the same level with the same time constant is unnecessary. Tonic-end eligibility uses the planned event time and the existing lookahead allowance, so it does not depend on callback jitter at the duration threshold. Qualified endings also use planned event times. Regressions check multiple seeds and durations, full voice modulation/envelopes and panning, room samples and LFO start times, zero seeds, repeat exports after Stop/control changes, exporting during replacement Play, stalls, and bounded Infinite history. Native browser tests compare repeat WAV headers and PCM samples and inspect tail silence. Native DSP summation can round differently: the repeat comparison permits at most two 16-bit PCM steps and requires RMS difference below 0.01% of the signal; it does not require identical file bytes. These establish shared scheduling and repeatable rendering in the tested browser; they do not claim a byte-identical recording of physical device output. @@ -130,3 +130,33 @@ stop stream tracks and started oscillators, and permit another Play. Failed offl resonator setup stops partial oscillators, preserves live playback, and permits another export. These checks exercise resource ownership, not actual out-of-memory recovery: a browser process terminated by the OS cannot run JavaScript cleanup. + + +## Qualified endings (engine 3 audition) + +Engine 1 retains its exact score and export behavior, including the frozen Short +and Long fixtures. Engine 3 adds conditional fifth and major-sixth endings to +finite performances. Engine 2 is reserved for the separate tempo experiment; +the app's release line stays v1.0. Bare seeds and fresh Play use engine 3; +versioned links/filenames preserve their engine, and unversioned legacy filenames +assume engine 1 with a warning about missing settings. + +A candidate can be the strike straddling the duration target, at most one nominal +interval or six seconds early (whichever is shorter), or a later strike before +the original tonic fallback. It must return to a pitch among the previous four +bell strikes, arrive by at most two semitones, and retain the same harmonic center +for the last four strikes. Tension must be at most 0.25 for fifths or 0.15 for +sixths, and candidates at an arc climax are rejected. A sixth is major-mode only +and must finish a nontrivial, stepwise excursion to that pitch. History resets +across drone-only passages. New drone attacks are excluded; nominal semitone, +major-seventh and tritone clashes with drone voices (including the full room +tail) are rejected. These are explicit musical heuristics, not a psychoacoustic +model of every FM partial or a promise of a pleasing ending. + +The gate consumes no random draws, adds no notes, and changes no voice or timing +before the chosen ending. Both renderers consume that same completed score. +Tests cover the recovered eleven-note sixth-ending contour across nine seeds, +rejection cases, 400 dispersed seeds at three tones across Short and Long, +unchanged Infinite continuation, and live/export controls and tail completion. +Native WAV round trips cover both engines, including reload and restoration +from a full filename. Engine-3 files require this branch until it is adopted. diff --git a/tests/browser/player.spec.cjs b/tests/browser/player.spec.cjs index 8ecc1ab..1402480 100644 --- a/tests/browser/player.spec.cjs +++ b/tests/browser/player.spec.cjs @@ -107,7 +107,11 @@ test('one-click Replay restores the latest settings and clears on reload', async await page.locator('#songDuration').selectOption('long'); await page.locator('#replayInput').fill('invalid input should not affect Replay'); await page.locator('#revisit summary').click(); - await replay.focus(); + // Use keyboard navigation: programmatic focus after a pointer click does + // not consistently activate :focus-visible across browser versions. + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(replay).toBeFocused(); await expect(replay).toHaveCSS('outline-style', 'solid'); await page.keyboard.press('Enter'); await expect(page.locator('#replayStatus')).toContainText('Replaying seed 03b38656'); @@ -143,7 +147,7 @@ test('invalid replay links stay silent and the Revisit panel fits a narrow viewp await page.locator('#replayInput').fill('03b38656'); await page.locator('#replayInput').press('Enter'); await expect(page.locator('#playNow')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('#replayStatus')).toContainText('cannot verify the original settings or engine'); + await expect(page.locator('#replayStatus')).toContainText('A seed alone does not include the original settings'); }); test('clipboard denial exposes a selectable replay link after Stop', async ({ page }) => { @@ -166,9 +170,9 @@ test('clipboard denial exposes a selectable replay link after Stop', async ({ pa await expect(page.locator('#replayLink')).toBeFocused(); }); -test('a downloaded filename recreates the same native WAV after reloading', async ({ page }) => { +for (const engine of ['1', '3']) test(`engine ${engine} filename recreates the same native WAV after reloading`, async ({ page }) => { test.setTimeout(120000); - await page.goto('/player.html?' + replayQuery); + await page.goto('/player.html?' + replayQuery.replace('engine=1', `engine=${engine}`)); await page.locator('#replayRun').click(); await expect(page.locator('#playNow')).toHaveAttribute('aria-pressed', 'true'); await page.locator('#stop').click(); @@ -176,7 +180,7 @@ test('a downloaded filename recreates the same native WAV after reloading', asyn await page.locator('#exportWav').click(); const first = await firstDownload; const filename = first.suggestedFilename(); - expect(filename).toMatch(/^open-run-03b38656-[a-z0-9]+-g1-200hz-short-44100sr-\d+\.wav$/); + expect(filename).toMatch(new RegExp(`^open-run-03b38656-[a-z0-9]+-g${engine}-200hz-short-44100sr-\\d+\\.wav$`)); const original = await fs.readFile(await first.path()); await page.goto('/player.html'); await page.locator('#revisit summary').click(); @@ -421,6 +425,7 @@ test('native note drift starts at the strike and moves stereo energy through its }); test('repeated native WAV exports preserve the performance after Stop and control changes', async ({ page }) => { + test.setTimeout(120000); await page.goto('/player.html'); await page.locator('#playNow').click(); await expect(page.locator('#playNow')).toHaveAttribute('aria-pressed', 'true'); diff --git a/tests/player.test.cjs b/tests/player.test.cjs index 28e6a22..59d9dda 100644 --- a/tests/player.test.cjs +++ b/tests/player.test.cjs @@ -136,6 +136,8 @@ function harness(source = fs.readFileSync(path.join(__dirname, '../player.js'), renderWavExport, bufferToWave, beginNaturalEnd, scheduleNote, handleVisibilityChange, // Lazy wrappers also allow loading the original source, which predates planning. planDrone: (...args) => planDrone(...args), renderGroup: (...args) => renderGroup(...args), + createPerformance: (...args) => createPerformance(...args), + qualifiedEnding: (...args) => qualifiedEnding(...args), replayFromUI: () => replayFromUI(), copyReplayLink: () => copyReplayLink(), replayLastPerformance: () => replayLastPerformance(), replayLink: (...args) => replayLink(...args), parseReplayInput: (...args) => parseReplayInput(...args), @@ -285,7 +287,7 @@ test('replay restores frozen Short and Long scores without changing musical choi for (const [seed, tone, mode, duration, hash] of fixtures) { const h = dialHarness(tone); h.elements.get('songDuration').value = mode; - h.elements.get('replayInput').value = seed; + h.elements.get('replayInput').value = `open-run-${seed}-fixture-g1-${tone}hz-${mode}-44100sr-1.wav`; await h.api.replayFromUI(); const snapshot = h.api.state().snapshot; assert.equal(snapshot.seed, parseInt(seed, 16)); @@ -310,7 +312,7 @@ test('new filenames with or without .wav and links restore the score, dial, and h.contexts[1].resolve(audioBuffer([new Float32Array(4)])); await pending; assert.equal(h.elements.get('exportWav').disabled, false); - assert.match(h.downloads[0], /^open-run-03b38656-[a-z0-9]+-g1-200hz-short-48000sr-\d+\.wav$/); + assert.match(h.downloads[0], /^open-run-03b38656-[a-z0-9]+-g3-200hz-short-48000sr-\d+\.wav$/); for (const value of [h.downloads[0], h.downloads[0].replace('.wav', ' (1).wav'), h.downloads[0].replace('.wav', ''), h.downloads[0].replace('.wav', ' (1)'), h.api.replayLink(original)]) { const restored = dialHarness(); @@ -320,7 +322,7 @@ test('new filenames with or without .wav and links restore the score, dial, and assert.equal(snapshot.tone, 200); assert.equal(snapshot.duration, 'short'); assert.equal(snapshot.sampleRate, 48000); - assert.equal(snapshot.engine, '1'); + assert.equal(snapshot.engine, '3'); assert.equal(JSON.stringify(snapshot.groups), JSON.stringify(original.groups)); assert.equal(restored.elements.get('tone').value, '200'); assert.equal(restored.elements.get('hzReadout').textContent, '200'); @@ -334,6 +336,73 @@ test('new filenames with or without .wav and links restore the score, dial, and h.sandbox.__OPEN_PLAYER_KILL__(); }); +test('qualified endings preserve the recovered eleven-note melody and every planned voice', () => { + const h = harness(); + for (const seed of [0x03b38656, 0x0b1617ff, 0x0bd8aae6, 0x16dd0502, + 0x284b74f5, 0x2e612fc6, 0x3ea456e2, 0x47a7b7d4, 0x5acb23bd]) { + const original = h.api.createPerformance(seed, 200, 'short', '1'); + const current = h.api.createPerformance(seed, 200, 'short', '3'); + const notes = current.groups.flatMap(g => g.notes); + const bells = notes.filter(n => n.kind === 'bell'); + assert.deepEqual(Array.from(bells, n => Math.round(12 * Math.log2(n.freq / 200))), + [2, 0, 2, 4, 5, 7, 9, 7, 5, 7, 9]); + assert.equal(current.groups.at(-1).endingKind, 'sixth'); + assert.ok(bells.at(-1).time > 57 && bells.at(-1).time < 60); + assert.equal(notes.filter(n => n.kind === 'bass').length, 2); + assert.equal(JSON.stringify(current.groups.map(g => g.notes)), + JSON.stringify(original.groups.slice(0, current.groups.length).map(g => g.notes))); + assert.ok(current.exportDuration >= Math.max(...notes.map(n => n.time + n.duration)) + 10.465 - 1e-8); + } +}); + +test('open endings reject unsettled harmony, unprepared sixths, leaps, and conflicting drones', () => { + const h = harness(), pitch = n => 200 * 2 ** (n / 12); + const history = offsets => offsets.map(n => ({ freq: pitch(n), key: '0:false', tension: 0.05 })); + const sixth = history([9, 7, 5, 7, 9]); + const check = (notes = sixth, degree = 5, minor = false, climax = false, drones = []) => + h.api.qualifiedEnding(notes, degree, minor, climax, drones, 60); + assert.equal(check(), 'sixth'); + assert.equal(check(history([7, 5, 4, 5, 7]), 4), 'fifth'); + assert.equal(check(history([0, 2, 4, 7, 9])), null); // New, unprepared pitch. + assert.equal(check(history([9, 9, 9, 9, 9])), null); // Repetition alone is insufficient. + assert.equal(check(history([9, 7, 5, 0, 9])), null); // Large final leap. + assert.equal(check(sixth, 5, true), null); + assert.equal(check(sixth, 5, false, true), null); + assert.equal(check(sixth.map((n, i) => i === 2 ? { ...n, key: '1:false' } : n)), null); + assert.equal(check(sixth.map(n => ({ ...n, tension: 0.3 }))), null); + const drone = (offset, time = 35) => ({ freq: pitch(offset), time, duration: 22 }); + for (const offset of [8, 3, 10]) + assert.equal(check(sixth, 5, false, false, [drone(offset)]), null); + assert.equal(check(sixth, 5, false, false, [drone(-12, 59.95)]), null); + assert.equal(check(sixth, 5, false, false, [drone(-12), drone(-5)]), 'sixth'); +}); + +test('varied seeds retain the engine-1 approach and tonic fallback without changing Infinite', () => { + const h = harness(), kinds = new Set(); + for (let i = 0; i < 400; i++) { + const seed = Math.imul(i + 1, 0x9e3779b9) >>> 0; + const mode = i < 40 ? 'long' : 'short', tone = [110, 155, 200][i % 3]; + const before = h.api.createPerformance(seed, tone, mode, '1'); + const after = h.api.createPerformance(seed, tone, mode, '3'); + const last = after.groups.at(-1); + kinds.add(last.endingKind); + assert.equal(last.ending, true); + assert.ok(last.time >= after.targetDuration - Math.min(6, 1 / after.density)); + assert.equal(after.groups.filter(g => g.ending).length, 1); + assert.equal(JSON.stringify(after.groups.map(g => g.notes)), + JSON.stringify(before.groups.slice(0, after.groups.length).map(g => g.notes))); + assert.ok(after.exportDuration <= before.exportDuration); + } + assert.deepEqual([...kinds].sort(), ['fifth', 'sixth', 'tonic']); + for (const seed of [0, 0x0bd8aae6, 0xffffffff]) { + const a = h.api.createPerformance(seed, 200, 'infinite', '1'); + const b = h.api.createPerformance(seed, 200, 'infinite', '3'); + assert.equal(JSON.stringify(a.groups), JSON.stringify(b.groups)); + for (let i = 0; i < 20; i++) + assert.equal(JSON.stringify(a.continuation.next()), JSON.stringify(b.continuation.next())); + } +}); + test('legacy filenames and boundary seeds use explicit current controls', async () => { const h = dialHarness('121'); h.elements.get('songDuration').value = 'long'; @@ -350,7 +419,8 @@ test('legacy filenames and boundary seeds use explicit current controls', async assert.equal(snapshot.seed, seed); assert.equal(snapshot.tone, 121); assert.equal(snapshot.duration, 'long'); - assert.match(h.elements.get('replayStatus').textContent, /cannot verify the original settings or engine/); + assert.equal(snapshot.engine, input.startsWith('open-run-') ? '1' : '3'); + assert.match(h.elements.get('replayStatus').textContent, /selected root tone and length/); } h.sandbox.__OPEN_PLAYER_KILL__(); }); @@ -360,7 +430,7 @@ test('invalid replay data never interrupts or replaces a playing performance', a const original = h.api.state().snapshot, bus = h.api.state().bus; const link = h.api.replayLink(original); const invalid = ['', '123', '0x12345678', '123456789', 'zzzzzzzz', - link.replace('engine=1', 'engine=2'), link.replace('tone=155', 'tone=201'), + link.replace('engine=3', 'engine=2'), link.replace('tone=155', 'tone=201'), link.replace('tone=155', 'tone=109'), link.replace('tone=155', 'tone=121.5'), link.replace('length=short', 'length=300'), link.replace('rate=44100', 'rate=NaN'), link.replace('rate=44100', 'rate=0'), link.replace('&rate=44100', ''), @@ -840,7 +910,7 @@ function voiceControls(ctx, origin = 0) { } test('timed exports match live voices, modulation, envelopes, and natural endings', async () => { - for (const [seed, duration] of [[0, 'short'], [12345, 'short'], [2, 'long'], [1, 'long']]) { + for (const [seed, duration] of [[0, 'short'], [12345, 'short'], [0x0bd8aae6, 'short'], [2, 'long'], [1, 'long']]) { const h = harness(); h.sandbox.crypto.getRandomValues = a => { a[0] = seed; return a; }; h.sandbox.document.getElementById('songDuration').value = duration; From 6ae9d3632e27a31b25df80c9d4f9eb305c4acdee Mon Sep 17 00:00:00 2001 From: Aaron Koppel Date: Wed, 21 Oct 2026 20:46:46 -0400 Subject: [PATCH 2/2] Document adoption of qualified endings in the v1.0 player Replace audition-branch wording with the current ending and replay behavior. No executable code changes. The musical implementation passed Node checks and native Chromium, Firefox and WebKit CI. --- README.md | 6 +++--- tests/README.md | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bcaaf3a..b4f10c9 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ In Short, Long, and Infinite modes, the engine follows the same rules to shape t Short and Long seek a musical ending around their target duration. The tonic remains the fallback. A fifth can end a settled passage when the melody returns to a recent pitch by step or repetition. A major sixth needs a stepwise excursion back to a recent pitch in a major passage. Both require a stable harmonic center, low tension, and compatibility with any remaining drone. All voices and reverb finish their full decay. Infinite continues developing the same musical structure until stopped. -This branch auditions qualified endings for v1.0. These are general compositional rules, not a guarantee that every ending will be equally convincing; listening across varied performances remains part of the evaluation. +These ending rules allow a settled passage to remain harmonically open. They guide the choice of a final note without prescribing a particular melody or guaranteeing an equally convincing ending in every performance. Only ending eligibility changes. Melody generation, note timing, tempo, drones, tuning, synthesis, and stereo movement retain their existing rules. The ending check consumes no random draws and leaves every planned voice before the chosen ending unchanged. ### Technical Architecture -Release line: **v1.0** (1.0.0). This branch uses musical engine **3**, with engine **1** retained for saved replays. Engine identifiers describe musical behavior independently of the release number; engine 2 belongs to a separate tempo experiment. +Release line: **v1.0** (1.0.0). Musical engine: **3**, with engine **1** retained for saved replays. Engine identifiers describe musical behavior independently of the release number. *Open* uses the standard Web Audio API with no runtime dependencies, using a custom procedural engine to synthesize the environment live. @@ -72,7 +72,7 @@ Open **Revisit** below the playback buttons to load an earlier performance, shar The generator is deterministic: matching seed, root tone, length, and engine version reproduce the planned notes, timing, FM voices, and stereo movement. The sample rate is retained for WAV rendering; live playback uses the device's audio rate. Browser or device differences can affect the rendered samples, and mastering or other processing applied outside Open is not part of replay. Repeating a seed deliberately revisits a performance; fresh random seeds are not a mathematical guarantee that a seed can never recur. -Older filenames contain a seed and timestamps but omit the root tone, length, and engine version. Replay cannot infer those missing settings from the filename or verify which engine made it. Links and new filenames with an unsupported engine version are rejected with an explanation, rather than silently replayed with changed musical rules. This branch supports engines 1 and 3. Engine-1 links and filenames preserve the original tonic endings. New performances use engine 3, and their full filenames and replay links retain that version. A bare seed can therefore have a different ending under the current engine. The player does not reconstruct a seed from an audio recording. +Older filenames contain a seed and timestamps but omit the root tone, length, and engine version. Replay cannot infer those missing settings from the filename or verify which engine made it. Links and new filenames with an unsupported engine version are rejected with an explanation, rather than silently replayed with changed musical rules. The player supports engines 1 and 3. Engine-1 links and filenames preserve the original tonic endings. New performances use engine 3, and their full filenames and replay links retain that version. A bare seed can therefore have a different ending under the current engine. The player does not reconstruct a seed from an audio recording. ### Output & Saving Save the audio for listening and keep its original filename or replay link to preserve the settings. Live recording remains a desktop keyboard feature; WAV export is also available in Revisit. diff --git a/tests/README.md b/tests/README.md index 4bcf71c..bec68ba 100644 --- a/tests/README.md +++ b/tests/README.md @@ -132,7 +132,7 @@ another export. These checks exercise resource ownership, not actual out-of-memo recovery: a browser process terminated by the OS cannot run JavaScript cleanup. -## Qualified endings (engine 3 audition) +## Qualified endings (engine 3) Engine 1 retains its exact score and export behavior, including the frozen Short and Long fixtures. Engine 3 adds conditional fifth and major-sixth endings to @@ -159,4 +159,5 @@ Tests cover the recovered eleven-note sixth-ending contour across nine seeds, rejection cases, 400 dispersed seeds at three tones across Short and Long, unchanged Infinite continuation, and live/export controls and tail completion. Native WAV round trips cover both engines, including reload and restoration -from a full filename. Engine-3 files require this branch until it is adopted. +from a full filename. Engine-3 files select the qualified-ending behavior; +engine-1 files preserve the original tonic endings.