Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Official release: **v1.0** (1.0.0). Musical engine: **1**.
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.

Expand All @@ -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.
Expand All @@ -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. 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.
Expand Down
4 changes: 2 additions & 2 deletions player.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<meta name="twitter:image" content="https://stereo-images.github.io/open/social-preview.png" />
<meta name="twitter:image:alt" content="Open — Bell Music for Desktop Listening. By Aaron Koppel." />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="stylesheet" href="style.css?v=1.0&amp;rev=replay-button" />
<link rel="stylesheet" href="style.css?v=1.0&amp;rev=qualified-endings" />
</head>
<body class="player">
<main class="shell" aria-label="Audio Instrument">
Expand Down Expand Up @@ -103,7 +103,7 @@ <h1 id="playerTitle" class="title-style">Open</h1>
</footer>
</main>

<script src="player.js?v=1.0&amp;rev=replay-button"></script>
<script src="player.js?v=1.0&amp;rev=qualified-endings"></script>
</body>
</html>

92 changes: 80 additions & 12 deletions player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
};
Expand All @@ -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) {
Expand Down Expand Up @@ -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());
Expand All @@ -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);
Expand Down
Loading
Loading