@vibes.diy/prompts 14.3.178 → 14.3.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/llms/backend.js +2 -2
- package/llms/backend.js.map +1 -1
- package/llms/backend.md +162 -2
- package/llms/web-audio.js +1 -1
- package/llms/web-audio.js.map +1 -1
- package/llms/web-audio.md +509 -1
- package/package.json +5 -5
package/llms/backend.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const backendConfig = {
|
|
2
2
|
name: "backend",
|
|
3
3
|
label: "backend-js",
|
|
4
|
-
description: "server-side backend.js file: answer HTTP/webhook requests at the app's /_api URL, react to committed data changes, and run scheduled jobs — its writes go through the app's own access rules; reads a web page, feed or API the person names and hands it to the page through /_api; emails, notifies and reminds people through ctx.notify by handle — a weekly digest, a summary for the owner, a Monday email to the manager — and invites a recipient who is not in the app yet;
|
|
5
|
-
cues: ["email", "notify", "remind", "digest", "zapier", "catch hook", "webhook", "slack", "sheets", "crm"],
|
|
4
|
+
description: "server-side backend.js file: answer HTTP/webhook requests at the app's /_api URL, react to committed data changes, and run scheduled jobs — its writes go through the app's own access rules; reads a web page, feed or API the person names and hands it to the page through /_api; emails, notifies and reminds people through ctx.notify by handle — a weekly digest, a summary for the owner, a Monday email to the manager — and invites a recipient who is not in the app yet; sends a record to a tool the person names (Slack, Google Sheets, a CRM, Zapier) through a Zapier Catch Hook the owner connects; and takes records in from Zapier — a Google Forms response, a new sheet row, an email — at its /_api URL, checked against a shared secret",
|
|
5
|
+
cues: ["email", "notify", "remind", "digest", "zapier", "catch hook", "webhook", "slack", "sheets", "crm", "google forms"],
|
|
6
6
|
};
|
|
7
7
|
//# sourceMappingURL=backend.js.map
|
package/llms/backend.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"AAaA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,YAAY;IACnB,WAAW,EACT,guBAAguB;IAKluB,IAAI,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,cAAc,CAAC;CAC3H,CAAC"}
|
package/llms/backend.md
CHANGED
|
@@ -21,7 +21,9 @@ create or mention `backend.js`.
|
|
|
21
21
|
## When to use which handler — signal words
|
|
22
22
|
|
|
23
23
|
- **`fetch`** — "webhook", "OAuth", "callback", "API endpoint", "receive",
|
|
24
|
-
"form POST", "Stripe/GitHub/Sonos sends us…"
|
|
24
|
+
"form POST", "Stripe/GitHub/Sonos sends us…", "from Zapier", "when a form
|
|
25
|
+
is submitted in Google Forms", "every new row in my sheet". Something
|
|
26
|
+
**external → us**.
|
|
25
27
|
- **`onChange`** — "when X is created", "after a write", "notify", "send an
|
|
26
28
|
email", "aggregate", "moderate", "sync to…". An **internal write → a side
|
|
27
29
|
effect**: `ctx.notify` for every person the app emails, notifies or reminds,
|
|
@@ -400,7 +402,9 @@ const routes = await ctx.github.routes(); // [{ repo, accountLogin, binderHandle
|
|
|
400
402
|
anything else **throw** — a handler that returns normally has acknowledged
|
|
401
403
|
the event, so the platform will not call it again for that write, while a
|
|
402
404
|
thrown `onChange` is re-delivered with backoff (a handful of tries over a few
|
|
403
|
-
minutes) and a run that never succeeds lands in the app's logs.
|
|
405
|
+
minutes) and a run that never succeeds lands in the app's logs. Records
|
|
406
|
+
travelling the other way, from a tool into the app, arrive at `fetch`:
|
|
407
|
+
see *Records in from Zapier* below.
|
|
404
408
|
|
|
405
409
|
```js
|
|
406
410
|
export async function onChange(event, ctx) {
|
|
@@ -843,6 +847,162 @@ signed in.
|
|
|
843
847
|
Writes a handler makes with `ctx.db.put` still pass through `access.js` as the
|
|
844
848
|
caller, so the app's rules hold for API writes too.
|
|
845
849
|
|
|
850
|
+
### Records in from Zapier — a Zap that POSTs to `/_api`
|
|
851
|
+
|
|
852
|
+
A Zap brings records into the app by POSTing to its `/_api` route.
|
|
853
|
+
Reach for it when the ask says the records come *from* another tool — "from
|
|
854
|
+
Zapier", "when a form is submitted in Google Forms", "every new row in my
|
|
855
|
+
sheet", "each new email in my inbox". The owner makes a Zap whose trigger is
|
|
856
|
+
that tool and whose action is **Webhooks by Zapier → POST** to
|
|
857
|
+
`https://vibes.diy/vibe/<owner>/<slug>/_api/zapier/lead`, sending the fields as
|
|
858
|
+
JSON and mapping the tool's own id for the record (the form response id, the
|
|
859
|
+
row id, the message id) to `id`. The route answers the world once the app is
|
|
860
|
+
published.
|
|
861
|
+
|
|
862
|
+
A Zap carries no vibes.diy sign-in, so the route runs as nobody, and the
|
|
863
|
+
recipe has three parts:
|
|
864
|
+
|
|
865
|
+
- **A shared secret proves the sender.** The owner picks a long random value,
|
|
866
|
+
saves it as a secret (`vibes-diy secrets set ZAPIER_INBOUND_SECRET`), and
|
|
867
|
+
pastes the same value into the Zap as an `x-zapier-secret` header. The
|
|
868
|
+
handler compares the two in constant time and answers `401` when they
|
|
869
|
+
differ. The secret rides a header of the app's own naming: `Authorization`
|
|
870
|
+
and `Cookie` are removed before the handler sees the request, and
|
|
871
|
+
`x-vibes-*` belongs to the platform.
|
|
872
|
+
- **The same record lands on the same document.** Zapier can deliver one
|
|
873
|
+
record twice, so its key comes from the record itself — the mapped `id`, or
|
|
874
|
+
a hash of the body — and the record the app shows is written at
|
|
875
|
+
`lead:<key>` with `ifRev: null`, so a second delivery meets a `conflict`
|
|
876
|
+
and changes nothing.
|
|
877
|
+
- **The route writes a sealed arrival, and the tick turns it into the
|
|
878
|
+
record.** The route's write is anonymous, so `access.js` admits it with
|
|
879
|
+
`allowAnonymous: true` — and a type an anonymous route may write, any
|
|
880
|
+
visitor may write directly too. So the route writes an `arrival` carrying a
|
|
881
|
+
seal (an HMAC of its fields under the secret) on a channel no reader is
|
|
882
|
+
granted, and `scheduled`, which reads every channel, makes the seal again,
|
|
883
|
+
writes each matching arrival as the record the app shows (a type only
|
|
884
|
+
`scheduled` writes), and clears the arrival. An arrival with no matching
|
|
885
|
+
seal is cleared without becoming a record. The tick pages through every
|
|
886
|
+
arrival on the `next` cursor, up to ten pages a tick, so a pile of unsealed
|
|
887
|
+
arrivals never stands in front of a real one, and it handles each arrival
|
|
888
|
+
on its own — fields checked as strings, any failure logged — so one odd
|
|
889
|
+
document is cleared and the rest go through. `access.js` keeps every
|
|
890
|
+
arrival at an `arrival:` id, so none can occupy a lead's id.
|
|
891
|
+
|
|
892
|
+
Gate both handlers on the secret, so the app works fully before any Zap
|
|
893
|
+
exists, and say in your reply what the owner sets up: the secret, the Zap's
|
|
894
|
+
POST address and header, and anonymous writes for the app (publishing from
|
|
895
|
+
the editor turns them on when `access.js` declares `allowAnonymous`; after a
|
|
896
|
+
CLI push, `vibes-diy app anon-write <owner>/<slug> on`).
|
|
897
|
+
|
|
898
|
+
### `backend.js`
|
|
899
|
+
```js
|
|
900
|
+
export const config = { scheduled: { interval: "1m" } };
|
|
901
|
+
|
|
902
|
+
const enc = new TextEncoder();
|
|
903
|
+
|
|
904
|
+
function json(data, status) {
|
|
905
|
+
return new Response(JSON.stringify(data), { status: status ?? 200, headers: { "content-type": "application/json" } });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function hex(buf) {
|
|
909
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// The seal: an HMAC of the record's fields under the shared secret.
|
|
913
|
+
async function sealOf(secret, arrival) {
|
|
914
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
915
|
+
return hex(await crypto.subtle.sign("HMAC", key, enc.encode(JSON.stringify([arrival.key, arrival.name, arrival.email]))));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Constant time: both sides become fixed-length digests, compared byte by byte with no early exit.
|
|
919
|
+
async function sameText(a, b) {
|
|
920
|
+
const [x, y] = await Promise.all([a, b].map(async (s) => new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(s)))));
|
|
921
|
+
let diff = 0;
|
|
922
|
+
for (let i = 0; i < x.length; i++) diff |= x[i] ^ y[i];
|
|
923
|
+
return diff === 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export async function fetch(request, ctx) {
|
|
927
|
+
const url = new URL(request.url);
|
|
928
|
+
if (url.pathname !== "/zapier/lead" || request.method !== "POST") return json({ ok: false, reason: "not found" }, 404);
|
|
929
|
+
const secret = ctx.secrets.ZAPIER_INBOUND_SECRET;
|
|
930
|
+
if (!secret) return json({ ok: false, reason: "this app is still being set up" }, 503);
|
|
931
|
+
const given = request.headers.get("x-zapier-secret") ?? "";
|
|
932
|
+
if (!(await sameText(given, secret))) return json({ ok: false, reason: "unrecognized sender" }, 401);
|
|
933
|
+
const raw = await request.text();
|
|
934
|
+
let body;
|
|
935
|
+
try {
|
|
936
|
+
body = JSON.parse(raw);
|
|
937
|
+
} catch {
|
|
938
|
+
return json({ ok: false, reason: "send the fields as JSON" }, 400);
|
|
939
|
+
}
|
|
940
|
+
if (typeof body?.name !== "string" || typeof body?.email !== "string") {
|
|
941
|
+
return json({ ok: false, reason: "name and email are needed" }, 400);
|
|
942
|
+
}
|
|
943
|
+
// The key comes from the record itself, so a second delivery of it lands on the same lead.
|
|
944
|
+
const source = typeof body.id === "string" && body.id !== "" ? body.id : raw;
|
|
945
|
+
const key = hex(await crypto.subtle.digest("SHA-256", enc.encode(source)));
|
|
946
|
+
const arrival = { type: "arrival", key, name: body.name.slice(0, 200), email: body.email.slice(0, 200) };
|
|
947
|
+
arrival.seal = await sealOf(secret, arrival);
|
|
948
|
+
await ctx.db.put(arrival, { db: "leads", id: "arrival:" + arrival.seal });
|
|
949
|
+
return json({ ok: true, key });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export async function scheduled(event, ctx) {
|
|
953
|
+
const secret = ctx.secrets.ZAPIER_INBOUND_SECRET;
|
|
954
|
+
let added = 0;
|
|
955
|
+
let cleared = 0;
|
|
956
|
+
let after;
|
|
957
|
+
let pages = 0;
|
|
958
|
+
// Page through every arrival (up to 10 pages a tick), so a pile of unsealed ones never hides a real one.
|
|
959
|
+
do {
|
|
960
|
+
const page = secret ? await ctx.db.query({ db: "leads", field: "type", key: "arrival", limit: 200, after }) : [];
|
|
961
|
+
for (const arrival of page) {
|
|
962
|
+
// Each arrival is handled on its own: whatever one holds, the tick clears it and moves on.
|
|
963
|
+
try {
|
|
964
|
+
const wellFormed = ["key", "name", "email", "seal"].every((f) => typeof arrival[f] === "string");
|
|
965
|
+
if (wellFormed && (await sameText(arrival.seal, await sealOf(secret, arrival)))) {
|
|
966
|
+
const lead = { type: "lead", name: arrival.name, email: arrival.email, addedAt: event.scheduledTime };
|
|
967
|
+
await ctx.db.put(lead, { db: "leads", id: "lead:" + arrival.key, ifRev: null });
|
|
968
|
+
added += 1;
|
|
969
|
+
} else {
|
|
970
|
+
cleared += 1;
|
|
971
|
+
}
|
|
972
|
+
} catch (err) {
|
|
973
|
+
// A conflict means this lead is already here; anything else is logged and the arrival cleared.
|
|
974
|
+
if (err?.code !== "conflict") ctx.log("warn", "arrival set aside", { id: arrival._id, reason: String(err?.message ?? err) });
|
|
975
|
+
}
|
|
976
|
+
await ctx.db.delete(arrival._id, { db: "leads" }).catch((err) => ctx.log("warn", "arrival kept", { id: arrival._id, reason: String(err?.message ?? err) }));
|
|
977
|
+
}
|
|
978
|
+
after = page.next;
|
|
979
|
+
pages += 1;
|
|
980
|
+
} while (after && pages < 10);
|
|
981
|
+
ctx.log("info", "zapier arrivals", { added, cleared });
|
|
982
|
+
}
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
### `access.js`
|
|
986
|
+
```js
|
|
987
|
+
export function leads(doc, oldDoc, user, ctx) {
|
|
988
|
+
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
|
|
989
|
+
if (doc.type === "arrival") {
|
|
990
|
+
// An arrival lives at `arrival:<seal>`, so no arrival can sit at a lead's id.
|
|
991
|
+
if (typeof doc._id !== "string" || !doc._id.startsWith("arrival:")) throw { forbidden: "an arrival lives at arrival:<seal>" };
|
|
992
|
+
if (doc._deleted) ctx.requireBackend("scheduled"); // the tick clears arrivals
|
|
993
|
+
return { channels: ["arrivals"], allowAnonymous: true }; // no reader is granted this channel
|
|
994
|
+
}
|
|
995
|
+
if (doc.type === "lead") {
|
|
996
|
+
ctx.requireBackend("scheduled"); // a lead is written only after its seal matched
|
|
997
|
+
return { channels: ["leads"], grant: { users: { [user.userHandle]: ["leads"] } } };
|
|
998
|
+
}
|
|
999
|
+
throw { forbidden: "unknown type" };
|
|
1000
|
+
}
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
The page lists `lead` records with `useLiveQuery("type", { key: "lead" })`,
|
|
1004
|
+
and the owner sees each one within a minute of the Zap firing.
|
|
1005
|
+
|
|
846
1006
|
### Bulk transformations run as a route
|
|
847
1007
|
|
|
848
1008
|
A change that touches many stored records is written once, as a named `/_api` route, and run
|
package/llms/web-audio.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const webAudioConfig = {
|
|
2
2
|
name: "web-audio",
|
|
3
3
|
label: "Web Audio API",
|
|
4
|
-
description: "Web Audio fundamentals; echo/delay with effects in the feedback path; mic monitoring with a metronome; audio‑clock scheduling; timing design for multi‑channel drum machines and MIDI synths with accurate voice overlap.",
|
|
4
|
+
description: "Web Audio fundamentals; echo/delay with effects in the feedback path; mic monitoring with a metronome; audio‑clock scheduling; timing design for multi‑channel drum machines and MIDI synths with accurate voice overlap; a complete synthesized drum machine (kick, snare, hats, 16‑step sequencer, tempo, swing, saturation, volume).",
|
|
5
5
|
cues: [
|
|
6
6
|
"audio",
|
|
7
7
|
"sound",
|
package/llms/web-audio.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-audio.js","sourceRoot":"","sources":["../../jsr/llms/web-audio.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,eAAe;IACtB,WAAW,EACT,
|
|
1
|
+
{"version":3,"file":"web-audio.js","sourceRoot":"","sources":["../../jsr/llms/web-audio.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,eAAe;IACtB,WAAW,EACT,yUAAyU;IAC3U,IAAI,EAAE;QACJ,OAAO;QACP,OAAO;QACP,QAAQ;QACR,OAAO;QACP,OAAO;QACP,aAAa;QACb,YAAY;QACZ,WAAW;QACX,cAAc;QACd,MAAM;QACN,OAAO;QACP,MAAM;QACN,QAAQ;QACR,WAAW;QACX,SAAS;QACT,SAAS;QACT,gBAAgB;QAChB,cAAc;KACf;CACF,CAAC"}
|
package/llms/web-audio.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Web Audio API: Fundamentals, Echo with FX-in-Feedback, Mic Monitoring + Metronome, and
|
|
1
|
+
# Web Audio API: Fundamentals, Echo with FX-in-Feedback, Mic Monitoring + Metronome, Timing Architecture, and a Complete Drum Machine
|
|
2
2
|
|
|
3
3
|
Authoritative source: Issue #228 research threads — comments 3192681700, 3192696052, 3192806626.
|
|
4
4
|
|
|
@@ -16,6 +16,10 @@ Authoritative source: Issue #228 research threads — comments 3192681700, 31926
|
|
|
16
16
|
> iOS. Once the context is `running`, all of those are fine for scheduling. iOS
|
|
17
17
|
> also suspends audio after backgrounding/lock, so **re-check `audioCtx.state` on
|
|
18
18
|
> every gesture** and resume again if needed. See the `unlockAudio()` gate in §1.
|
|
19
|
+
>
|
|
20
|
+
> **A drum machine, beat maker or step sequencer starts from §9** — a complete,
|
|
21
|
+
> runnable one with a synthesized kit, sixteen steps, tempo, swing, saturation
|
|
22
|
+
> and volume.
|
|
19
23
|
|
|
20
24
|
## 1) Fundamentals and Core Nodes
|
|
21
25
|
|
|
@@ -355,4 +359,508 @@ function handleStop() {
|
|
|
355
359
|
|
|
356
360
|
The Play button names what it plays ("Play the drums"), the Stop button is beside it from the first sound, and a small line credits the engine ("Sounds by Tone.js (MIT)") — the audience hears drums, not infrastructure.
|
|
357
361
|
|
|
362
|
+
## 9) A complete drum machine — the shell every beat vibe starts from
|
|
363
|
+
|
|
364
|
+
This is a full, runnable two-file app: a synthesized four-voice kit (kick,
|
|
365
|
+
snare, closed and open hat), a sixteen-step sequencer, tempo and swing, and
|
|
366
|
+
global saturation and volume on the master bus. No samples, no downloads —
|
|
367
|
+
every sound is built from oscillators and noise, so it plays the moment the
|
|
368
|
+
person taps. Start here for any drum machine, beat maker or groovebox and grow
|
|
369
|
+
it: more voices, more steps, accents, pattern banks.
|
|
370
|
+
|
|
371
|
+
Why it sounds good, in four moves:
|
|
372
|
+
|
|
373
|
+
- **Kick** — a sine that sweeps 150 Hz → 55 Hz in 50 ms, then settles to 45 Hz:
|
|
374
|
+
the sweep is the punch, the tail is the boom. A few milliseconds of
|
|
375
|
+
band-passed noise on top is the beater, so the kick still reads on phone
|
|
376
|
+
speakers that cannot reproduce 45 Hz.
|
|
377
|
+
- **Snare** — two tuned triangle "shell" tones under high-passed noise with a
|
|
378
|
+
presence lift at 5 kHz for the wires.
|
|
379
|
+
- **Hats** — the 808 recipe: six square waves at inharmonic ratios of 40 Hz,
|
|
380
|
+
band-passed at 10 kHz and high-passed at 7 kHz. A closed hat chokes a ringing
|
|
381
|
+
open hat through the open hat's own gate gain, like the real machine.
|
|
382
|
+
- **Master bus** — voices → bus (headroom) → drive gain → `tanh` WaveShaper
|
|
383
|
+
(4× oversampled, so the new harmonics do not alias into fizz) → makeup gain →
|
|
384
|
+
volume → a fast `DynamicsCompressor` as a limiter. The makeup gain falls as
|
|
385
|
+
the drive rises, so Saturation changes the colour, not the loudness; Volume
|
|
386
|
+
is squared so the slider feels even.
|
|
387
|
+
|
|
388
|
+
The engine is a plain module (§6): the gate from §1, the lookahead scheduler
|
|
389
|
+
from §4, and a `playhead()` the interface polls each animation frame, so the
|
|
390
|
+
lit step follows what is reaching the speakers rather than the timer. The
|
|
391
|
+
interface calls `getDrums()` synchronously at the top of every tap handler —
|
|
392
|
+
Play, and each step — which is what unlocks sound on iOS.
|
|
393
|
+
|
|
394
|
+
### `lib/drum-engine.js`
|
|
395
|
+
```js
|
|
396
|
+
// Everything that touches AudioContext lives here — no React, so a re-render never rebuilds the graph.
|
|
397
|
+
export const STEPS = 16;
|
|
398
|
+
export const TRACKS = [
|
|
399
|
+
{ id: "kick", label: "Kick" },
|
|
400
|
+
{ id: "snare", label: "Snare" },
|
|
401
|
+
{ id: "closed", label: "Closed hat" },
|
|
402
|
+
{ id: "open", label: "Open hat" },
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
let audioCtx;
|
|
406
|
+
let drums;
|
|
407
|
+
|
|
408
|
+
// §1's gate: synchronous, inside the tap, with one real (silent) sound.
|
|
409
|
+
function unlockAudio() {
|
|
410
|
+
audioCtx ||= new (window.AudioContext || window.webkitAudioContext)();
|
|
411
|
+
if (audioCtx.state !== "running") {
|
|
412
|
+
audioCtx.resume();
|
|
413
|
+
const osc = audioCtx.createOscillator();
|
|
414
|
+
const gain = audioCtx.createGain();
|
|
415
|
+
gain.gain.value = 0.0001;
|
|
416
|
+
osc.connect(gain).connect(audioCtx.destination);
|
|
417
|
+
osc.start();
|
|
418
|
+
osc.stop(audioCtx.currentTime + 0.01);
|
|
419
|
+
}
|
|
420
|
+
return audioCtx;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Call from a tap handler, synchronously: unlocks audio and builds the one engine on that context.
|
|
424
|
+
export function getDrums() {
|
|
425
|
+
const ctx = unlockAudio();
|
|
426
|
+
drums ||= createDrums(ctx);
|
|
427
|
+
return drums;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function createDrums(ctx) {
|
|
431
|
+
const settings = { pattern: {}, bpm: 112, swing: 0, drive: 0.3, volume: 0.8 };
|
|
432
|
+
|
|
433
|
+
// Master chain: voices → bus → drive → tanh shaper → makeup → volume → limiter → speakers.
|
|
434
|
+
const bus = ctx.createGain();
|
|
435
|
+
const drive = ctx.createGain();
|
|
436
|
+
const shaper = ctx.createWaveShaper();
|
|
437
|
+
const makeup = ctx.createGain();
|
|
438
|
+
const volume = ctx.createGain();
|
|
439
|
+
const limiter = ctx.createDynamicsCompressor();
|
|
440
|
+
bus.gain.value = 0.7; // headroom: a kick and a hat on the same step stay under full scale
|
|
441
|
+
shaper.curve = tanhCurve(MAX_DRIVE);
|
|
442
|
+
shaper.oversample = "4x"; // keeps the new harmonics from folding back down as fizz
|
|
443
|
+
limiter.threshold.value = -3;
|
|
444
|
+
limiter.knee.value = 0;
|
|
445
|
+
limiter.ratio.value = 20;
|
|
446
|
+
limiter.attack.value = 0.002;
|
|
447
|
+
limiter.release.value = 0.1;
|
|
448
|
+
bus.connect(drive).connect(shaper).connect(makeup).connect(volume).connect(limiter).connect(ctx.destination);
|
|
449
|
+
|
|
450
|
+
const noise = noiseBuffer(ctx);
|
|
451
|
+
const live = new Set(); // every scheduled source and its start time, so Stop can cancel the future
|
|
452
|
+
let openChoke = null; // the ringing open hat's gate — a closed hat shuts it, like the real machine
|
|
453
|
+
|
|
454
|
+
function chokeOpen(at) {
|
|
455
|
+
openChoke?.gain.setTargetAtTime(0, at, 0.006);
|
|
456
|
+
openChoke = null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function play(source, at, end, nodes = []) {
|
|
460
|
+
const entry = { source, at };
|
|
461
|
+
live.add(entry);
|
|
462
|
+
source.onended = () => {
|
|
463
|
+
live.delete(entry);
|
|
464
|
+
source.disconnect();
|
|
465
|
+
for (const node of nodes) node.disconnect();
|
|
466
|
+
};
|
|
467
|
+
source.start(at);
|
|
468
|
+
source.stop(end);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// A percussive envelope: 2 ms attack, exponential decay to silence.
|
|
472
|
+
function envelope(at, peak, decay, out = bus) {
|
|
473
|
+
const amp = ctx.createGain();
|
|
474
|
+
amp.gain.setValueAtTime(0.0001, at);
|
|
475
|
+
amp.gain.exponentialRampToValueAtTime(peak, at + 0.002);
|
|
476
|
+
amp.gain.exponentialRampToValueAtTime(0.0001, at + decay);
|
|
477
|
+
amp.connect(out);
|
|
478
|
+
return amp;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function filter(type, frequency, q = 0.7) {
|
|
482
|
+
const node = ctx.createBiquadFilter();
|
|
483
|
+
node.type = type;
|
|
484
|
+
node.frequency.value = frequency;
|
|
485
|
+
node.Q.value = q;
|
|
486
|
+
return node;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 808-style metal: six square waves at inharmonic ratios, band-passed high.
|
|
490
|
+
function hat(at, decay, peak, out) {
|
|
491
|
+
const band = filter("bandpass", 10000);
|
|
492
|
+
const high = filter("highpass", 7000);
|
|
493
|
+
const amp = envelope(at, peak, decay, out);
|
|
494
|
+
band.connect(high).connect(amp);
|
|
495
|
+
[2, 3, 4.16, 5.43, 6.79, 8.21].forEach((ratio, i) => {
|
|
496
|
+
const osc = ctx.createOscillator();
|
|
497
|
+
osc.type = "square";
|
|
498
|
+
osc.frequency.value = 40 * ratio;
|
|
499
|
+
osc.connect(band);
|
|
500
|
+
play(osc, at, at + decay + 0.02, i === 0 ? [band, high, amp] : []);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const voices = {
|
|
505
|
+
kick(at) {
|
|
506
|
+
// A sine that drops from 150 Hz to 45 Hz: the pitch sweep is the punch, the tail is the boom.
|
|
507
|
+
const body = ctx.createOscillator();
|
|
508
|
+
body.frequency.setValueAtTime(150, at);
|
|
509
|
+
body.frequency.exponentialRampToValueAtTime(55, at + 0.05);
|
|
510
|
+
body.frequency.exponentialRampToValueAtTime(45, at + 0.4);
|
|
511
|
+
const amp = envelope(at, 1, 0.5);
|
|
512
|
+
body.connect(amp);
|
|
513
|
+
play(body, at, at + 0.55, [amp]);
|
|
514
|
+
// The beater: a few ms of filtered noise, so the kick still reads on phone speakers.
|
|
515
|
+
const click = ctx.createBufferSource();
|
|
516
|
+
click.buffer = noise;
|
|
517
|
+
const tone = filter("bandpass", 3500, 0.8);
|
|
518
|
+
const clickAmp = envelope(at, 0.3, 0.012);
|
|
519
|
+
click.connect(tone).connect(clickAmp);
|
|
520
|
+
play(click, at, at + 0.03, [tone, clickAmp]);
|
|
521
|
+
},
|
|
522
|
+
snare(at) {
|
|
523
|
+
// Two tuned shell tones under a bright burst of noise for the wires.
|
|
524
|
+
for (const [freq, level] of [[185, 0.45], [330, 0.25]]) {
|
|
525
|
+
const shell = ctx.createOscillator();
|
|
526
|
+
shell.type = "triangle";
|
|
527
|
+
shell.frequency.setValueAtTime(freq, at);
|
|
528
|
+
shell.frequency.exponentialRampToValueAtTime(freq * 0.85, at + 0.1);
|
|
529
|
+
const amp = envelope(at, level, 0.14);
|
|
530
|
+
shell.connect(amp);
|
|
531
|
+
play(shell, at, at + 0.16, [amp]);
|
|
532
|
+
}
|
|
533
|
+
const wires = ctx.createBufferSource();
|
|
534
|
+
wires.buffer = noise;
|
|
535
|
+
const high = filter("highpass", 1500);
|
|
536
|
+
const air = filter("peaking", 5000);
|
|
537
|
+
air.gain.value = 6;
|
|
538
|
+
const amp = envelope(at, 0.6, 0.22);
|
|
539
|
+
wires.connect(high).connect(air).connect(amp);
|
|
540
|
+
play(wires, at, at + 0.25, [high, air, amp]);
|
|
541
|
+
},
|
|
542
|
+
closed(at) {
|
|
543
|
+
chokeOpen(at);
|
|
544
|
+
hat(at, 0.06, 0.32, bus);
|
|
545
|
+
},
|
|
546
|
+
open(at) {
|
|
547
|
+
chokeOpen(at); // one open hat at a time, so the next closed hat always silences it
|
|
548
|
+
openChoke = ctx.createGain();
|
|
549
|
+
openChoke.connect(bus);
|
|
550
|
+
hat(at, 0.45, 0.24, openChoke);
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// The scheduler: a 25 ms timer tops up 120 ms of sound ahead of the audio clock (§4).
|
|
555
|
+
const LOOKAHEAD = 0.12;
|
|
556
|
+
let timer = 0;
|
|
557
|
+
let step = 0;
|
|
558
|
+
let gridTime = 0;
|
|
559
|
+
let lit = -1;
|
|
560
|
+
const lights = []; // { step, at }: the playhead follows the sound, not the timer
|
|
561
|
+
|
|
562
|
+
function tick() {
|
|
563
|
+
const sixteenth = 60 / settings.bpm / 4;
|
|
564
|
+
// A throttled background tab can fall far behind the audio clock: skip the missed steps
|
|
565
|
+
// (keeping the beat's phase) instead of scheduling a burst of hits in the past.
|
|
566
|
+
if (gridTime < ctx.currentTime) {
|
|
567
|
+
const missed = Math.ceil((ctx.currentTime - gridTime) / sixteenth);
|
|
568
|
+
gridTime += missed * sixteenth;
|
|
569
|
+
step = (step + missed) % STEPS;
|
|
570
|
+
}
|
|
571
|
+
while (gridTime < ctx.currentTime + LOOKAHEAD) {
|
|
572
|
+
// Swing pushes every off-beat sixteenth late; 1 is a full triplet shuffle.
|
|
573
|
+
const at = gridTime + (step % 2 ? (settings.swing * sixteenth) / 3 : 0);
|
|
574
|
+
for (const { id } of TRACKS) if (settings.pattern[id]?.[step]) voices[id](at);
|
|
575
|
+
lights.push({ step, at });
|
|
576
|
+
gridTime += sixteenth;
|
|
577
|
+
step = (step + 1) % STEPS;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function update(next) {
|
|
582
|
+
Object.assign(settings, next);
|
|
583
|
+
const now = ctx.currentTime;
|
|
584
|
+
// Drive 0 is warm, 1 is crunchy. Makeup gain keeps loudness level as the drive rises,
|
|
585
|
+
// so the slider changes the colour, not the volume.
|
|
586
|
+
const gain = 1 + settings.drive * (MAX_DRIVE - 1);
|
|
587
|
+
drive.gain.setTargetAtTime(gain / MAX_DRIVE, now, 0.02);
|
|
588
|
+
makeup.gain.setTargetAtTime(1.3 / Math.sqrt(gain), now, 0.02);
|
|
589
|
+
volume.gain.setTargetAtTime(settings.volume ** 2, now, 0.02); // squared, so the slider feels even
|
|
590
|
+
}
|
|
591
|
+
update({});
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
update,
|
|
595
|
+
start() {
|
|
596
|
+
if (timer) return;
|
|
597
|
+
step = 0;
|
|
598
|
+
gridTime = ctx.currentTime + 0.05;
|
|
599
|
+
tick();
|
|
600
|
+
timer = setInterval(tick, 25);
|
|
601
|
+
},
|
|
602
|
+
stop() {
|
|
603
|
+
clearInterval(timer);
|
|
604
|
+
timer = 0;
|
|
605
|
+
lights.length = 0;
|
|
606
|
+
lit = -1;
|
|
607
|
+
// Cancel every hit that is scheduled but not yet sounding; ringing tails finish on their own.
|
|
608
|
+
for (const entry of live) if (entry.at > ctx.currentTime) entry.source.stop();
|
|
609
|
+
},
|
|
610
|
+
// Audition one sound now — a tap on a step while stopped.
|
|
611
|
+
hit(id) {
|
|
612
|
+
voices[id](ctx.currentTime + 0.005);
|
|
613
|
+
},
|
|
614
|
+
// The newest step whose sound has reached the speakers; poll it from requestAnimationFrame.
|
|
615
|
+
playhead() {
|
|
616
|
+
// currentTime is what the engine has rendered; subtract the output latency for what is heard
|
|
617
|
+
// (it matters on Bluetooth headphones).
|
|
618
|
+
const heard = ctx.currentTime - (ctx.outputLatency || ctx.baseLatency || 0);
|
|
619
|
+
while (lights.length && lights[0].at <= heard) lit = lights.shift().step;
|
|
620
|
+
return lit;
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const MAX_DRIVE = 10;
|
|
626
|
+
|
|
627
|
+
// tanh(MAX_DRIVE·x) across the shaper's −1…1 input; the drive gain picks how far up the curve we go.
|
|
628
|
+
function tanhCurve(k) {
|
|
629
|
+
const curve = new Float32Array(4096);
|
|
630
|
+
for (let i = 0; i < curve.length; i++) curve[i] = Math.tanh(k * ((i / (curve.length - 1)) * 2 - 1));
|
|
631
|
+
return curve;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function noiseBuffer(ctx) {
|
|
635
|
+
const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
|
636
|
+
const data = buffer.getChannelData(0);
|
|
637
|
+
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
|
|
638
|
+
return buffer;
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
A tap on a step toggles it, and while stopped it plays that sound so the
|
|
643
|
+
person hears what they placed. Every slider writes through to the engine, and
|
|
644
|
+
the scheduler reads the settings on its next step, so edits land in time
|
|
645
|
+
without a restart. Grooves save to the database as one document each; the
|
|
646
|
+
save button waits on `can.create`, the same verdict the server enforces.
|
|
647
|
+
|
|
648
|
+
### `App.jsx`
|
|
649
|
+
```jsx
|
|
650
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
651
|
+
import { useFireproof } from "use-fireproof";
|
|
652
|
+
import { useVibe, useViewer } from "use-vibes";
|
|
653
|
+
import { Badge, Button, Card, Field, Heading, Icon, IconButton, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
654
|
+
import { Slider } from "@vibes.diy/pouf/interactive";
|
|
655
|
+
import { STEPS, TRACKS, getDrums } from "./lib/drum-engine.js";
|
|
656
|
+
|
|
657
|
+
const START = {
|
|
658
|
+
kick: [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0],
|
|
659
|
+
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1],
|
|
660
|
+
closed: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0],
|
|
661
|
+
open: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
function Knob({ label, value, min, max, step = 1, shown, onChange }) {
|
|
665
|
+
return (
|
|
666
|
+
<Stack gap={1}>
|
|
667
|
+
<Row gap={2} align="center">
|
|
668
|
+
<Text size="sm">{label}</Text>
|
|
669
|
+
<Spacer />
|
|
670
|
+
<Text size="sm" muted num>
|
|
671
|
+
{shown}
|
|
672
|
+
</Text>
|
|
673
|
+
</Row>
|
|
674
|
+
<Slider value={[value]} onChange={([v]) => onChange(v)} min={min} max={max} step={step} label={label} />
|
|
675
|
+
</Stack>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export default function App() {
|
|
680
|
+
const { database, useLiveQuery } = useFireproof("drumMachine");
|
|
681
|
+
const { me, can, ready } = useVibe("drumMachine");
|
|
682
|
+
const { requestLogin, ViewerTag } = useViewer();
|
|
683
|
+
const { docs: grooves } = useLiveQuery("type", { key: "groove" });
|
|
684
|
+
|
|
685
|
+
const [grid, setGrid] = useState(START);
|
|
686
|
+
const [bpm, setBpm] = useState(112);
|
|
687
|
+
const [swing, setSwing] = useState(0);
|
|
688
|
+
const [drive, setDrive] = useState(30);
|
|
689
|
+
const [volume, setVolume] = useState(80);
|
|
690
|
+
const [playing, setPlaying] = useState(false);
|
|
691
|
+
const [step, setStep] = useState(-1);
|
|
692
|
+
const [name, setName] = useState("");
|
|
693
|
+
const [saving, setSaving] = useState(false);
|
|
694
|
+
const drumsRef = useRef(null);
|
|
695
|
+
|
|
696
|
+
const settings = { pattern: grid, bpm, swing: swing / 100, drive: drive / 100, volume: volume / 100 };
|
|
697
|
+
|
|
698
|
+
// Every change reaches the engine; the scheduler reads it on its next step, so edits land in time.
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
drumsRef.current?.update(settings);
|
|
701
|
+
}, [grid, bpm, swing, drive, volume]);
|
|
702
|
+
|
|
703
|
+
// Silence on leaving the page.
|
|
704
|
+
useEffect(() => () => drumsRef.current?.stop(), []);
|
|
705
|
+
|
|
706
|
+
// The lit step follows the sound: each frame asks the engine which step has reached the speakers.
|
|
707
|
+
useEffect(() => {
|
|
708
|
+
if (!playing) return;
|
|
709
|
+
let raf = 0;
|
|
710
|
+
const frame = () => {
|
|
711
|
+
setStep(drumsRef.current.playhead());
|
|
712
|
+
raf = requestAnimationFrame(frame);
|
|
713
|
+
};
|
|
714
|
+
raf = requestAnimationFrame(frame);
|
|
715
|
+
return () => cancelAnimationFrame(raf);
|
|
716
|
+
}, [playing]);
|
|
717
|
+
|
|
718
|
+
// Called synchronously at the top of a tap handler — that is what unlocks audio on iOS.
|
|
719
|
+
function drums() {
|
|
720
|
+
const engine = getDrums();
|
|
721
|
+
if (!drumsRef.current) engine.update(settings);
|
|
722
|
+
drumsRef.current = engine;
|
|
723
|
+
return engine;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function togglePlay() {
|
|
727
|
+
const engine = drums();
|
|
728
|
+
if (playing) {
|
|
729
|
+
engine.stop();
|
|
730
|
+
setStep(-1);
|
|
731
|
+
} else {
|
|
732
|
+
engine.start();
|
|
733
|
+
}
|
|
734
|
+
setPlaying(!playing);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function toggleStep(track, i) {
|
|
738
|
+
const engine = drums();
|
|
739
|
+
const on = !grid[track][i];
|
|
740
|
+
setGrid({ ...grid, [track]: grid[track].map((v, j) => (j === i ? (on ? 1 : 0) : v)) });
|
|
741
|
+
if (on && !playing) engine.hit(track); // hear the sound you just placed
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function load(groove) {
|
|
745
|
+
setGrid(groove.grid);
|
|
746
|
+
setBpm(groove.bpm);
|
|
747
|
+
setSwing(groove.swing ?? 0);
|
|
748
|
+
setDrive(groove.drive ?? 30);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const draft = { type: "groove", name: name || "untitled", grid, bpm, swing, drive, authorHandle: me?.userHandle };
|
|
752
|
+
const verdict = can.create(draft);
|
|
753
|
+
|
|
754
|
+
// Clear the field as the save starts and hold the button until the write lands; a save that
|
|
755
|
+
// did not land hands the name back, keeping anything typed since.
|
|
756
|
+
async function save() {
|
|
757
|
+
const typed = name;
|
|
758
|
+
setSaving(true);
|
|
759
|
+
setName("");
|
|
760
|
+
try {
|
|
761
|
+
const result = await database.put({ ...draft, createdAt: Date.now() });
|
|
762
|
+
if (!result.ok) setName((now) => now || typed);
|
|
763
|
+
} finally {
|
|
764
|
+
setSaving(false);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
|
|
770
|
+
<Stack gap={4}>
|
|
771
|
+
<Row gap={2} align="center" wrap>
|
|
772
|
+
<Heading level={1}>Drum machine</Heading>
|
|
773
|
+
<Spacer />
|
|
774
|
+
<Badge tone={playing ? "up" : "idle"}>{playing ? `Playing · ${bpm} bpm` : "Ready"}</Badge>
|
|
775
|
+
</Row>
|
|
776
|
+
|
|
777
|
+
<Card variant="tight">
|
|
778
|
+
<Stack gap={3}>
|
|
779
|
+
{TRACKS.map(({ id, label }) => (
|
|
780
|
+
<Stack key={id} gap={1}>
|
|
781
|
+
<Text size="sm" muted>
|
|
782
|
+
{label}
|
|
783
|
+
</Text>
|
|
784
|
+
{/* Eight steps a line on a phone, all sixteen on a wider screen. */}
|
|
785
|
+
<div className="grid grid-cols-8 md:grid-cols-16 justify-items-center gap-1 select-none touch-manipulation">
|
|
786
|
+
{Array.from({ length: STEPS }, (_, i) => {
|
|
787
|
+
const on = Boolean(grid[id][i]);
|
|
788
|
+
return (
|
|
789
|
+
<IconButton
|
|
790
|
+
key={i}
|
|
791
|
+
size="sm"
|
|
792
|
+
label={`${label} step ${i + 1}${on ? ", on" : ", off"}`}
|
|
793
|
+
variant={on || i === step ? "solid" : "quiet"}
|
|
794
|
+
tone={i === step ? "info" : on ? "up" : "idle"}
|
|
795
|
+
icon={<Icon name={on ? "idle" : i % 4 === 0 ? "dots" : "off"} size="sm" />}
|
|
796
|
+
onClick={() => toggleStep(id, i)}
|
|
797
|
+
/>
|
|
798
|
+
);
|
|
799
|
+
})}
|
|
800
|
+
</div>
|
|
801
|
+
</Stack>
|
|
802
|
+
))}
|
|
803
|
+
</Stack>
|
|
804
|
+
</Card>
|
|
805
|
+
|
|
806
|
+
<Card variant="tight">
|
|
807
|
+
<Stack gap={3}>
|
|
808
|
+
<Row gap={2} align="center">
|
|
809
|
+
<Button onClick={togglePlay}>{playing ? "Stop" : "Play the drums"}</Button>
|
|
810
|
+
<Spacer />
|
|
811
|
+
<Button size="sm" variant="quiet" onClick={() => setGrid(Object.fromEntries(TRACKS.map(({ id }) => [id, Array(STEPS).fill(0)])))}>
|
|
812
|
+
Clear
|
|
813
|
+
</Button>
|
|
814
|
+
</Row>
|
|
815
|
+
<Knob label="Tempo" value={bpm} min={60} max={180} shown={`${bpm} bpm`} onChange={setBpm} />
|
|
816
|
+
<Knob label="Swing" value={swing} min={0} max={100} shown={`${swing}%`} onChange={setSwing} />
|
|
817
|
+
<Knob label="Saturation" value={drive} min={0} max={100} shown={`${drive}%`} onChange={setDrive} />
|
|
818
|
+
<Knob label="Volume" value={volume} min={0} max={100} shown={`${volume}%`} onChange={setVolume} />
|
|
819
|
+
</Stack>
|
|
820
|
+
</Card>
|
|
821
|
+
|
|
822
|
+
<Card variant="tight">
|
|
823
|
+
<Stack gap={3}>
|
|
824
|
+
<Heading level={2}>Saved grooves</Heading>
|
|
825
|
+
{!ready && (
|
|
826
|
+
<div aria-busy="true">
|
|
827
|
+
<Skeleton variant="row" />
|
|
828
|
+
</div>
|
|
829
|
+
)}
|
|
830
|
+
{ready && !me && (
|
|
831
|
+
<Row gap={2} align="center" wrap>
|
|
832
|
+
<Text muted>Sign in to save this groove.</Text>
|
|
833
|
+
<Button size="sm" variant="quiet" onClick={requestLogin}>
|
|
834
|
+
Sign in
|
|
835
|
+
</Button>
|
|
836
|
+
</Row>
|
|
837
|
+
)}
|
|
838
|
+
{ready && me && verdict.ok && (
|
|
839
|
+
<Row gap={2} align="top" wrap>
|
|
840
|
+
<Field label="Groove name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} />}</Field>
|
|
841
|
+
<Button size="sm" loading={saving} onClick={save}>
|
|
842
|
+
Save groove
|
|
843
|
+
</Button>
|
|
844
|
+
</Row>
|
|
845
|
+
)}
|
|
846
|
+
{ready && me && !verdict.ok && <Text muted>Saving grooves is closed here. You can still play and load the saved ones.</Text>}
|
|
847
|
+
{grooves.map((g) => (
|
|
848
|
+
<Row key={g._id} gap={2} align="center" wrap>
|
|
849
|
+
<Button size="sm" variant="quiet" onClick={() => load(g)}>
|
|
850
|
+
{g.name}
|
|
851
|
+
</Button>
|
|
852
|
+
<Text size="sm" muted num>
|
|
853
|
+
{g.bpm} bpm
|
|
854
|
+
</Text>
|
|
855
|
+
{g.authorHandle && <ViewerTag userHandle={g.authorHandle} />}
|
|
856
|
+
</Row>
|
|
857
|
+
))}
|
|
858
|
+
</Stack>
|
|
859
|
+
</Card>
|
|
860
|
+
</Stack>
|
|
861
|
+
</main>
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
358
866
|
— End —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "14.3.
|
|
3
|
+
"version": "14.3.181",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"exports": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@adviser/cement": "~0.5.34",
|
|
42
|
-
"@vibes.diy/api-types": "14.3.
|
|
43
|
-
"@vibes.diy/call-ai-v2": "14.3.
|
|
44
|
-
"@vibes.diy/identity": "14.3.
|
|
45
|
-
"@vibes.diy/use-vibes-types": "14.3.
|
|
42
|
+
"@vibes.diy/api-types": "14.3.181",
|
|
43
|
+
"@vibes.diy/call-ai-v2": "14.3.181",
|
|
44
|
+
"@vibes.diy/identity": "14.3.181",
|
|
45
|
+
"@vibes.diy/use-vibes-types": "14.3.181",
|
|
46
46
|
"arktype": "~2.2.8",
|
|
47
47
|
"json-schema-faker": "~0.6.3"
|
|
48
48
|
},
|