@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 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; and sends a record to a tool the person names (Slack, Google Sheets, a CRM, Zapier) through a Zapier Catch Hook the owner connects",
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
@@ -1 +1 @@
1
- {"version":3,"file":"backend.js","sourceRoot":"","sources":["../../jsr/llms/backend.ts"],"names":[],"mappings":"AAYA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,YAAY;IACnB,WAAW,EACT,ylBAAylB;IAK3lB,IAAI,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC;CAC3G,CAAC"}
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…". Something **external → 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",
@@ -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,2NAA2N;IAC7N,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"}
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 Timing Architecture
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.178",
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.178",
43
- "@vibes.diy/call-ai-v2": "14.3.178",
44
- "@vibes.diy/identity": "14.3.178",
45
- "@vibes.diy/use-vibes-types": "14.3.178",
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
  },