@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/backend.js +2 -2
- package/llms/backend.js.map +1 -1
- package/llms/backend.md +166 -2
- package/llms/connections.js +6 -1
- package/llms/connections.js.map +1 -1
- package/llms/connections.md +16 -4
- package/llms/stripe-subscriptions.js +1 -1
- package/llms/stripe-subscriptions.js.map +1 -1
- package/llms/stripe-subscriptions.md +280 -154
- package/llms/stripe.js +1 -1
- package/llms/stripe.js.map +1 -1
- package/llms/stripe.md +250 -130
- package/llms/use-vibe.md +5 -4
- 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/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
|
|
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
|
|
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
|
|
136
|
-
<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",
|
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.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.
|
|
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.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
|
},
|