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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@ changelog that edits its own past is not a record.

## [Unreleased]

### Changed: PERFORM shows the sound large, and its pads have keys

- **PERFORM is a well and a panel.** On the left, the sound: its family (for
a sound opened from the library), its name, large, with a share button that
opens the picture of its card, its description, and a well holding its face
with a glow and a reflection, as stage mode draws it. Click the face to hear
the phrase. On the right, what you turn: CONTROLS with ARRANGE and HOW IT
WORKS, your controls three to a row, the knobs they move, and the pads.
- **An offer stands beside your sound.** B's face grows in amber next to
yours, with what changed and *hold B to peek* under it, and BLEND is a
slider under the two while B holds one.
- **The pads are WANDER, OFFER, PEEK, TAKE and PASS.** PASS passes on B
without growing another, and counts as a pick for what you had once you
have heard B, with UNDO, as NEXT's pass does. Tap WANDER to freeze it (the
FREEZE pad is gone); its ring lights and it says *frozen*.
- **Keep and Back appear when they mean something.** Once the sound has moved
from where you last kept it, *moved · KEEP · BACK* shows by its name.
- **Keys for the pads.** N offers (and passes on B for the next), hold B to
peek, ⇧↵ takes, ↵ keeps when nothing is focused, ⇧⌫ goes back. In EVOLVE, N
deals another pair. None of them fire while you type or a dialog is open.
- **XY and How it works open in the well,** from XY in its corner and HOW IT
WORKS in CONTROLS, so the controls stay in reach beside them. What your
velocity plays is a row of ARRANGE, and its control wears a small *vel*.
- **First steps move into a pill** at the bottom left: one step at a
time with a pip for each, ticked off as you do it, and × to stop it. Steps
you had already done carry over.
- **EVOLVE asks one question.** *Pick the one you'd reach for.* heads it with
its pips, a small TASTE map shows the pair among every sound, and each card
shows its sound's face large in place of the waveform, with ⇄ circuit and
↓ patch in its corner. The buttons read PLAY · 1, PICK A · ←, ANOTHER PAIR ·
N; EVOLVE POOL is dashed until your picks have taught the model, and what
each generation did opens from under it.
- **Stage mode** shows the wordmark and the model's lamp, and the sound's
family where it has one.

### Changed: the bank is quieter, and holding ⌥ shows what the model thinks

- **The bank has tabs and a search.** POOL, SAVED and PRESETS are tabs with
Expand Down
6 changes: 4 additions & 2 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ the long-form notes are this directory's `README.md`.
| `levels.js` | main thread | Pure rules for the levels: the axis, `step`, `dirOf`, `railPath`, `levelForKey`, `startLevel`; unit-tested in `tests/` |
| `worker.js` | Web Worker | Owns `WasmEngine`: fills, duels, fits, refinement, the bench, PERFORM measurement. Serves requests in lanes, the player first. |
| `farm.js` | Web Workers | The render farm: stateless workers rendering pool draws in parallel |
| `perform.js` | main thread | PERFORM: named controls, XY, offers, Wander, Keep/Back, the hood |
| `perform.js` | main thread | PERFORM (the well and the panel, Plan-008 C1): named controls, the well's modes (the face, XY, How it works), offers and B, Blend, Wander and Freeze, the moved bar's Keep/Back, PASS, the pad keys (ADR-018), the hood, stage mode |
| `guide.js` | main thread | The guide pill: the first-visit steps one at a time, bottom left of the stage, and what was done (`auracle-guide`, migrated from `auracle-perform-steps`); unit-tested in `tests/` |
| `live-audio.js` | AudioWorklet | Builds the worklet blob around `LivePoly`; the arpeggiator; the voices' input and the open voice while AUDIO IN is monitored |
| `takes.js` | main thread | CAPTURE's RECORD (the worklet copies the input while it is lit, the engine worker renders the take with `render_take`, sent as `set_take`) and the sounds kept safe for a recording, listed in the pool with RECORD AGAIN |
| `audio-in.js` | main thread | AUDIO IN: the permission (asked only when the module is added), the inputs (one stream per device, fanned out), monitoring (never saved), the clip on first listen, the module's lane on the rack |
Expand Down Expand Up @@ -53,7 +54,8 @@ the long-form notes are this directory's `README.md`.
message channels and keymap in RFC-003, except that Space plays the sound
in every view ([ADR-016](../../docs/decisions/016-space-plays-everywhere.md))
and the views are levels with keys of their own
([ADR-017](../../docs/decisions/017-the-levels-keys.md)).
([ADR-017](../../docs/decisions/017-the-levels-keys.md)), and PERFORM's
pads have the keys of [ADR-018](../../docs/decisions/018-the-pad-keys.md).
New copy, colours and keys follow them; an exception amends the ADR.
- **One design system**
([ADR-011](../../docs/decisions/011-one-design-system.md)): views are
Expand Down
2 changes: 1 addition & 1 deletion apps/web/booth.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//
// 1. **Attract.** After a minute with nobody at the keys, the instrument
// plays itself in PERFORM: a curated patch, a chord progression, two named
// controls moving under an invisible hand (the XY dot follows), then hands
// controls moving under an invisible hand (their dials turn), then hands
// off while Wander drifts the knobs, then an offer grown into B and blended
// in. The patch changes every cycle. It is the product's pitch, performed.
// 2. **Hand over.** Any key, click, touch, wheel or MIDI note stops it on the
Expand Down
150 changes: 150 additions & 0 deletions apps/web/guide.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// The guide pill (Plan-008 §1, "First-visit guide"; ADR-009's one onboarding
// surface): bottom left of the stage, one step at a time, with pips for how
// far along the steps are and × to stop showing them. A step ticks off when
// it happens, not when it is read. PERFORM's three first steps (play, turn a
// control, ask for an offer) are its steps today; the zoom and the model
// view's join it with the levels' zoom (Plan-008 PR C3).
//
// What has been done is the player's, kept in localStorage as
// `auracle-guide` ({done: [ids], closed}), JS-owned. The first steps kept
// their ticks as `auracle-perform-steps` (an array of ids) before the pill:
// read once into the new key and removed (`readGuide`).

export const GUIDE_KEY = "auracle-guide";
export const OLD_STEPS_KEY = "auracle-perform-steps";

/** What has been done, from storage: `get(key)` returns the stored string or
* null. The new key first; else the first steps' old array, migrated
* (`migrated` true: the caller writes the new key and removes the old).
* Anything unreadable is a fresh start. Pure, for the unit tests. */
export function readGuide(get) {
const fresh = { done: [], closed: false, migrated: false };
let raw = null;
try {
raw = get(GUIDE_KEY);
} catch {
return fresh;
}
if (raw != null) {
try {
const v = JSON.parse(raw);
const done = Array.isArray(v?.done) ? v.done.filter((x) => typeof x === "string") : [];
return { done: [...new Set(done)], closed: v?.closed === true, migrated: false };
} catch {
return fresh;
}
}
let old = null;
try {
old = get(OLD_STEPS_KEY);
} catch {
return fresh;
}
if (old == null) return fresh;
try {
const v = JSON.parse(old);
const done = Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
return { done: [...new Set(done)], closed: false, migrated: true };
} catch {
return { ...fresh, migrated: true };
}
}

/** The pill. `el` is its slot (`#guide`, in the stage). Steps are added in
* order with `add({id, text})`, `text()` giving the step's words now (a
* step may name what this sound can do). `done(id)` ticks one off. */
export function createGuide({ el }) {
const steps = [];
let ending = 0; // the closing line's timer
const st = readGuide((k) => localStorage.getItem(k));
const done = new Set(st.done);
let closed = st.closed;
const save = () => {
try {
localStorage.setItem(GUIDE_KEY, JSON.stringify({ done: [...done], closed }));
} catch {
/* a per-viewer convenience; in memory is enough for this visit */
}
};
if (st.migrated) {
save();
try {
localStorage.removeItem(OLD_STEPS_KEY);
} catch {
/* private window */
}
}
const make = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
const allDone = () => steps.length > 0 && steps.every((s) => done.has(s.id));
// The pill: pips, the step, ×. A status, so a screen reader hears each new
// step once.
function render() {
if (ending) return;
el.innerHTML = "";
const now = steps.find((s) => !done.has(s.id));
if (closed || !now) {
el.classList.add("hidden");
return;
}
el.classList.remove("hidden");
const pill = make("div", "next pf-steps");
pill.setAttribute("role", "status");
const pips = make("span", "pips");
pips.setAttribute("aria-hidden", "true");
for (const s of steps) pips.append(make("i", done.has(s.id) ? "done" : ""));
const i = steps.indexOf(now);
const text = make("span", "pf-step now", now.text());
text.dataset.step = now.id;
// The whole step where a narrow pill cuts it, and how far along it is.
text.title = `${now.text()} (step ${i + 1} of ${steps.length})`;
const x = make("button", "x", "×");
x.type = "button";
x.setAttribute("aria-label", "Stop showing these");
x.title = "Stop showing these";
x.onclick = () => {
closed = true;
save();
render();
};
pill.append(pips, text, x);
el.append(pill);
}
return {
add(step) {
if (!steps.some((s) => s.id === step.id)) steps.push(step);
render();
},
done(id) {
if (done.has(id) || !steps.some((s) => s.id === id)) return;
const wasOpen = !closed && !allDone();
done.add(id);
save();
if (!allDone() || !wasOpen) return render();
// The last step: its pip fills and one line says what the loop was,
// for a few seconds, then the pill goes.
el.innerHTML = "";
el.classList.remove("hidden");
const pill = make("div", "next pf-steps");
pill.setAttribute("role", "status");
const pips = make("span", "pips");
pips.setAttribute("aria-hidden", "true");
for (const s of steps) pips.append(make("i", "done"));
pill.append(pips, make("span", "pf-step done all", "That is the loop. Every offer you take or pass teaches it what you like."));
el.append(pill);
ending = setTimeout(() => {
ending = 0;
render();
}, 7000);
},
// A step's words follow the sound (PERFORM's "Turn BRIGHT…").
refresh() {
render();
},
isDone: (id) => done.has(id),
};
}
Loading
Loading