@vibes.diy/prompts 14.3.178 → 14.3.184

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/use-vibe.md CHANGED
@@ -85,13 +85,14 @@ Don't gate management UI on a display flag directly. Encode the rule in `access.
85
85
 
86
86
  Most apps work the moment they open. Some need their owner to do one thing first — paste a key, save a billing portal, connect an account — and that is a property of the app, not of any one integration, so every app that has it takes the same shape. An app has at most one **Setup** surface, and the backend's verdict decides its state, never a display flag. The integration skills (Stripe, Spotify, YouTube) each contribute one item to it: what the app needs, how the backend checks it, and the linked steps in the provider's own words.
87
87
 
88
- **The backend answers what the app needs and whether each item is done.** A `/setup` route in `backend.js`, called with `apiFetch` so `ctx.userInfo` is set, lists the items — the key is present, the portal is saved, the account is connected — with `done` read from `ctx.secrets` or the connection, and `ready` when every item is done. An item whose verdict costs a provider call (is the portal saved?) is asked on the owner's call alone and kept in a document the backend writes; a visitor's read takes the kept value, so visitor traffic spends nothing of the provider's and a blip there keeps the last verdict. The Setup surface is the owner's alone: the backend decides who the owner is, and non-owners never receive the items. Each step is a link to the exact page in that page's own words, with the vibe's real handle and slug filled in from what the backend hands the client. The route carries `ownerHandle` and `appSlug` from `ctx.appInfo`, so the Settings link (`https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings`, **Secrets**) and the CLI line (`npx vibes-diy secrets set KEY --vibe <ownerHandle>/<appSlug>`) are copy-ready rather than templates.
88
+ **The backend answers what the app needs and whether each item is done.** A `/setup` route in `backend.js`, called with `apiFetch` so `ctx.userInfo` is set, lists the items — the key is present, the portal is saved, the account is connected — with `done` read from `ctx.secrets`, the connection or `ctx.stripe.status()`, and `ready` when every item is done. An item whose verdict costs a provider call (is the portal saved?) is asked on the owner's call alone and kept in a document the backend writes; a visitor's read takes the kept value, so visitor traffic spends nothing of the provider's and a blip there keeps the last verdict. The Setup surface is the owner's alone: the backend decides who the owner is, and non-owners never receive the items. Each step is a link to the exact page in that page's own words, with the vibe's real handle and slug filled in from what the backend hands the client. The route carries `ownerHandle` and `appSlug` from `ctx.appInfo`, so the Settings link (`https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings`, **Secrets**) and the CLI line (`npx vibes-diy secrets set KEY --vibe <ownerHandle>/<appSlug>`) are copy-ready rather than templates.
89
89
 
90
90
  ```js
91
91
  // backend.js — the verdict for everyone, the items for the owner alone
92
92
  if (url.pathname === "/setup" && request.method === "GET") {
93
93
  const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
94
- const items = [{ id: "stripe-key", label: "Stripe secret key", done: Boolean(ctx.secrets.STRIPE_SECRET_KEY) }];
94
+ const stripe = await ctx.stripe.status(); // the stripe skill picks the mode: test until the owner takes this app live
95
+ const items = [{ id: "stripe-connect", label: "Connect Stripe", done: stripe.test.state === "ready" }];
95
96
  const ready = items.every((item) => item.done);
96
97
  if (!owner) return json({ ok: true, ready, owner });
97
98
  return json({ ok: true, ready, owner, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
@@ -132,8 +133,8 @@ function SetupScreen({ setup }) {
132
133
  <Row gap={2}><Text>{item.label}</Text><Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : "To do"}</Badge></Row>
133
134
  {/* the skill's steps: each one a link to the exact page, in that page's own words */}
134
135
  <ol>
135
- <li>Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a> and press <b>Reveal test key</b> beside <b>Secret key</b>.</li>
136
- <li>Open <a href={`https://vibes.diy/vibes/mine/${vibe}/settings`} target="_blank" rel="noreferrer">this vibe's Settings tab</a>; under <b>Secrets</b>, enter <code>STRIPE_SECRET_KEY</code>, paste the value, and save.</li>
136
+ <li>Open <a href={`https://vibes.diy/vibes/mine/${vibe}/settings`} target="_blank" rel="noreferrer">this vibe's Settings tab</a> and press <b>Connect Stripe</b> under <b>Payments</b>; Stripe opens its own setup pages.</li>
137
+ <li>Come back here: the feature unlocks as soon as Stripe says the account is ready.</li>
137
138
  </ol>
138
139
  </Stack>
139
140
  ))}
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.184",
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.184",
43
+ "@vibes.diy/call-ai-v2": "14.3.184",
44
+ "@vibes.diy/identity": "14.3.184",
45
+ "@vibes.diy/use-vibes-types": "14.3.184",
46
46
  "arktype": "~2.2.8",
47
47
  "json-schema-faker": "~0.6.3"
48
48
  },