@msb235/blits 0.3.0 → 0.4.0

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/dist/mixer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { clampWeight, envelope, passAt, passesOf, phaseAt } from './clock.js';
2
2
  import { curve } from './easing.js';
3
3
  import { clampRun, Lanes } from './lanes.js';
4
- import { motionOf, noFrame } from './motion.js';
4
+ import { motionOf, noFrame, noRevive } from './motion.js';
5
5
  import { builtOf, intosOf, readKeys } from './patch.js';
6
6
  import { reading } from './reading.js';
7
7
  import { Store } from './store.js';
@@ -21,6 +21,7 @@ function stub() {
21
21
  reaches: false,
22
22
  delay: 0,
23
23
  since: 0,
24
+ shown: 0,
24
25
  weight: 0,
25
26
  rested: false,
26
27
  bands: new Uint8Array(0),
@@ -171,9 +172,28 @@ export class Voice {
171
172
  keeping = false;
172
173
  /** For a motion patch, the hook it was given to ask the mix for its subjects' voice time. */
173
174
  frame = null;
175
+ /** For a motion patch, the hook it was given to bring a subject faded out of this voice back. */
176
+ revive = null;
177
+ /** Subjects fading out of this voice alone, by the ramp each started; null while none are. */
178
+ parts = null;
179
+ /** Subjects faded out of this voice, by the mix time each left at; null while none have. */
180
+ parted = null;
174
181
  ease;
175
182
  /** How many passes its `loop` plays, worked out once. */
176
183
  passes;
184
+ /** Voice ms its passes last, Infinity for an aperiodic patch or a loop for good. */
185
+ span;
186
+ /** The state behind a motion patch, undefined for any other. */
187
+ motion;
188
+ /** Its `hold`, for a patch it applies to: motion holds its target already. */
189
+ holdsBefore;
190
+ holdsAfter;
191
+ /** The mix time it first showed: when it was cued, or the first sync after. */
192
+ opened = Number.NaN;
193
+ /** Which of its entries in the mix's due queue is current; older ones are skipped when popped. */
194
+ dueToken = 0;
195
+ /** Records made while it was pending, whose `since` its start may since have moved. */
196
+ early = null;
177
197
  /** Reused for every call this voice makes, so it is valid only during the call. */
178
198
  setting;
179
199
  /** Every subject this voice has been asked about, so a handover knows when it is finished. */
@@ -193,6 +213,8 @@ export class Voice {
193
213
  placing = false;
194
214
  resolve;
195
215
  done;
216
+ play;
217
+ played;
196
218
  constructor(id, spec, patch, fade, now, start, slotOf, channels, host, send) {
197
219
  this.id = id;
198
220
  this.spec = spec;
@@ -208,6 +230,14 @@ export class Voice {
208
230
  : undefined;
209
231
  this.ease = fade.ease === undefined ? undefined : curve(fade.ease);
210
232
  this.passes = passesOf(spec.loop);
233
+ this.span =
234
+ patch.period > 0 && Number.isFinite(this.passes)
235
+ ? patch.period * this.passes
236
+ : Number.POSITIVE_INFINITY;
237
+ this.motion = motionOf(patch);
238
+ const holds = this.motion === undefined ? spec.hold : undefined;
239
+ this.holdsBefore = holds === 'before' || holds === 'both';
240
+ this.holdsAfter = holds === 'after' || holds === 'both';
211
241
  this.setting = {
212
242
  timestamp: 0,
213
243
  dt: 0,
@@ -225,6 +255,9 @@ export class Voice {
225
255
  this.done = new Promise((r) => {
226
256
  this.resolve = r;
227
257
  });
258
+ this.played = new Promise((r) => {
259
+ this.play = r;
260
+ });
228
261
  if (now >= start)
229
262
  this.state = 'live';
230
263
  }
@@ -283,6 +316,7 @@ export class Voice {
283
316
  v.holder = null;
284
317
  v.laned = false;
285
318
  v.log = null;
319
+ v.early = null;
286
320
  if (controls) {
287
321
  v.anchorNow = controls.anchorNow;
288
322
  v.anchorElapsed = controls.anchorElapsed;
@@ -294,6 +328,7 @@ export class Voice {
294
328
  v.outAt = controls.outAt;
295
329
  }
296
330
  v.resolve = noSend;
331
+ v.play = noSend;
297
332
  return v;
298
333
  }
299
334
  rateAt(now) {
@@ -349,8 +384,25 @@ class Mixer {
349
384
  wantsPose = false;
350
385
  /** The pose `pull` folds a subject into where it cannot read straight from the lanes. */
351
386
  scratch;
387
+ /** The last array `pull` read, by position, with each subject's chain head, to skip the lookup. */
388
+ /** Live voices by the earliest mix time `moveTo` would change each, a binary heap. */
389
+ due = [];
390
+ /** Voices retired since `moveTo` last pruned them. */
391
+ retired = 0;
392
+ /** Visit every voice each sync, as before the due queue; for tests that compare the two. */
393
+ walkAll = false;
394
+ pulled = [];
395
+ pulledHeads = [];
396
+ /** Each remembered head's lane slot, and the `version` and `relinks` they were all current at. */
397
+ pulledSlots = new Int32Array(0);
398
+ pulledVersion = Number.NaN;
399
+ pulledRelinks = -1;
400
+ /** Counts chains made stale one subject at a time, which `version` does not. */
401
+ relinks = 0;
352
402
  /** Read once a sync, so a `reduce` function is not called per voice per subject. */
353
403
  reducedNow = false;
404
+ /** Something was changed since the last sync, so the next frame may differ from this one. */
405
+ stirred = false;
354
406
  /**
355
407
  * Per subject, the first record of the chain through every live voice that reaches it, or a stub
356
408
  * where none does. Relinked when `version` moves, which is whenever the list or a voice's pending
@@ -360,6 +412,17 @@ class Mixer {
360
412
  version = 0;
361
413
  /** Per subject, the voices whose `subjects` name it, in voice order. */
362
414
  named = new Store();
415
+ /** Voices a subject has been faded out of, which `drop` looks in besides those that reach it. */
416
+ parters = new Set();
417
+ /**
418
+ * Motion patches that may hold state for a subject no voice still reaching it plays, which `drop`
419
+ * frees besides those voices' own: per subject, a patch a voice naming it left while another
420
+ * voice plays on, or that the host retargeted it on; and any patch a voice over every subject
421
+ * left while another plays on. How many live voices play each motion patch says which.
422
+ */
423
+ strays = new Store();
424
+ strayAll = new Set();
425
+ playing = new Map();
363
426
  /** How many voices in the list carry `subjects`. */
364
427
  naming = 0;
365
428
  /** The voices in the list that name no subjects, in voice order. */
@@ -447,17 +510,23 @@ class Mixer {
447
510
  ? 0
448
511
  : this.now;
449
512
  const voice = new Voice(this.nextId++, spec, patch, spec.fade ?? {}, this.now, start, this.slotOf, this.channels, this.opts.host, this.send);
450
- if (!Number.isNaN(this.now))
513
+ if (!Number.isNaN(this.now)) {
451
514
  voice.cuedAt = this.now;
515
+ voice.opened = this.now;
516
+ }
452
517
  voice.placing = anchored;
453
- const motion = motionOf(patch);
518
+ const motion = voice.motion;
454
519
  if (motion !== undefined) {
520
+ this.playing.set(motion, (this.playing.get(motion) ?? 0) + 1);
455
521
  voice.frame = Mixer.frameHook(new WeakRef(this), new WeakRef(voice));
456
522
  motion.frame = voice.frame;
523
+ voice.revive = Mixer.reviveHook(new WeakRef(this), new WeakRef(voice));
524
+ motion.revive = voice.revive;
457
525
  }
458
526
  this.voices.push(voice);
459
527
  this.index(voice);
460
- this.version++;
528
+ this.changed(voice);
529
+ this.stirred = true;
461
530
  if (spec.locus !== undefined)
462
531
  this.loci++;
463
532
  if (spec.from === 'current')
@@ -467,7 +536,8 @@ class Mixer {
467
536
  this.place();
468
537
  if (!Number.isNaN(this.now) && voice.state === 'pending' && this.now >= voice.start) {
469
538
  voice.state = 'live';
470
- this.version++;
539
+ this.started(voice);
540
+ this.changed(voice);
471
541
  }
472
542
  }
473
543
  // After placing, so the controls it starts with are where its anchors put it at the cue.
@@ -475,6 +545,7 @@ class Mixer {
475
545
  voice.log = [];
476
546
  voice.note(Number.NEGATIVE_INFINITY);
477
547
  }
548
+ this.schedule(voice);
478
549
  return this.handle(voice);
479
550
  }
480
551
  blend(patches, by, spec = {}) {
@@ -523,24 +594,41 @@ class Mixer {
523
594
  const reach = now - (this.opts.history?.ms ?? 0);
524
595
  this.announced = this.announced.filter((a) => a.at >= reach);
525
596
  }
526
- for (const voice of this.voices) {
597
+ // A live mix visits only the voices due by now; a projection, which copies few, visits all.
598
+ const visit = this.projecting || this.walkAll ? this.voices : this.popDue(now);
599
+ for (const voice of visit) {
527
600
  if (voice.state === 'done')
528
601
  continue;
529
- if (voice.state === 'live' && voice.outAt <= now)
602
+ if (Number.isNaN(voice.opened))
603
+ voice.opened = now;
604
+ if ((voice.state === 'live' || voice.state === 'held') && voice.outAt <= now)
530
605
  this.beginFade(voice, {}, Math.max(voice.start, voice.outAt));
531
606
  if (voice.state === 'pending' && now >= voice.start) {
532
607
  voice.state = 'live';
533
- this.version++;
608
+ this.started(voice);
609
+ this.changed(voice);
534
610
  }
535
- const period = voice.patch.period;
536
- const passes = voice.passes;
537
- if (voice.state === 'live' && period > 0 && Number.isFinite(passes)) {
538
- const end = period * passes + voice.latest;
539
- // The fade starts when the last pass ended, not at the frame that noticed, so it plays the
540
- // same at any frame rate and a read at another time can find it.
541
- if (voice.elapsedAt(now) >= end)
542
- this.beginFade(voice, {}, Math.max(voice.start, Math.min(now, voice.timeAt(end))));
611
+ if (voice.state !== 'pending' && Number.isFinite(voice.span)) {
612
+ const end = voice.span + voice.latest;
613
+ if (voice.elapsedAt(now) >= end) {
614
+ voice.play(true);
615
+ // The fade starts when the last pass ended, not at the frame that noticed, so it plays
616
+ // the same at any frame rate and a read at another time can find it.
617
+ if (voice.state === 'live') {
618
+ if (voice.holdsAfter)
619
+ voice.state = 'held';
620
+ else
621
+ this.beginFade(voice, {}, Math.max(voice.start, Math.min(now, voice.timeAt(end))));
622
+ }
623
+ }
624
+ else if (voice.state === 'held')
625
+ voice.state = 'live';
543
626
  }
627
+ // A projection reads a finished ramp as weight 0, and leaves the live voice's subjects be.
628
+ if (voice.parts !== null && !this.projecting)
629
+ for (const [subject, r] of voice.parts)
630
+ if (now - r.at >= r.over)
631
+ this.part(voice, subject, r.at + r.over);
544
632
  if (voice.state === 'fading' && voice.out) {
545
633
  const { at, over, rest, deadline } = voice.out;
546
634
  const spent = now - at;
@@ -555,33 +643,144 @@ class Mixer {
555
643
  else if (spent >= over)
556
644
  this.retire(voice, at + over);
557
645
  }
646
+ if (!this.projecting)
647
+ this.schedule(voice);
558
648
  }
649
+ this.stirred = false;
650
+ if (this.retired === 0) {
651
+ this.forget(now);
652
+ return;
653
+ }
654
+ this.retired = 0;
559
655
  let pruned = false;
560
- for (let i = this.voices.length - 1; i >= 0; i--) {
561
- const voice = this.voices[i];
562
- if (voice.state === 'done') {
563
- this.voices.splice(i, 1);
564
- if (this.opts.history)
565
- this.gone.push(voice);
566
- this.version++;
567
- if (voice.named === null)
568
- pruned = true;
569
- else
570
- this.unindex(voice);
571
- if (voice.spec.locus !== undefined)
572
- this.loci--;
573
- if (voice.spec.anchor !== undefined)
574
- this.anchored--;
656
+ let kept = 0;
657
+ const voices = this.voices;
658
+ for (let i = 0; i < voices.length; i++) {
659
+ const voice = voices[i];
660
+ if (voice.state !== 'done') {
661
+ voices[kept++] = voice;
662
+ continue;
575
663
  }
664
+ if (this.opts.history)
665
+ this.gone.push(voice);
666
+ else
667
+ this.parters.delete(voice);
668
+ this.unplay(voice);
669
+ this.changed(voice);
670
+ if (voice.named === null)
671
+ pruned = true;
672
+ else
673
+ this.unindex(voice);
674
+ if (voice.spec.locus !== undefined)
675
+ this.loci--;
676
+ if (voice.spec.anchor !== undefined)
677
+ this.anchored--;
576
678
  }
679
+ voices.length = kept;
577
680
  if (pruned)
578
681
  this.general = this.general.filter((v) => v.state !== 'done');
682
+ this.forget(now);
683
+ }
684
+ /** Lets go of the voices that have left and that history no longer reaches. */
685
+ forget(now) {
579
686
  const history = this.opts.history;
580
687
  if (history && this.gone.length > 0) {
581
688
  const reach = now - history.ms;
582
689
  this.gone = this.gone.filter((v) => v.doneAt >= reach);
583
690
  }
584
691
  }
692
+ /**
693
+ * The earliest mix time `moveTo` would change a voice: its start, its anchored out, the end of
694
+ * its last pass, the end of its fade or of a subject's ramp out; -Infinity where it must be
695
+ * visited every frame, Infinity where nothing will happen until a handle or anchor changes it.
696
+ * Early is safe, since a visit that finds nothing due schedules again; late is not, so anything
697
+ * that can bring it earlier calls `schedule`.
698
+ */
699
+ dueOf(voice) {
700
+ if (voice.state === 'done')
701
+ return Number.POSITIVE_INFINITY;
702
+ if (Number.isNaN(voice.opened))
703
+ return Number.NEGATIVE_INFINITY;
704
+ let due = Number.POSITIVE_INFINITY;
705
+ if (voice.parts !== null)
706
+ for (const r of voice.parts.values())
707
+ due = Math.min(due, r.at + r.over);
708
+ if (voice.state === 'pending')
709
+ return Math.min(due, Number.isNaN(voice.start) ? due : voice.start);
710
+ if (voice.state === 'fading') {
711
+ const out = voice.out;
712
+ if (out === null || out.rest)
713
+ return Number.NEGATIVE_INFINITY;
714
+ return Math.min(due, out.at + out.over);
715
+ }
716
+ due = Math.min(due, voice.outAt);
717
+ if (Number.isFinite(voice.span)) {
718
+ const end = voice.span + voice.latest;
719
+ // A held voice goes live again once its clock is back before its end: by a seek, by a
720
+ // subject staggered later than any before, or by running backwards, which is checked each
721
+ // frame while it can.
722
+ if (voice.state === 'held') {
723
+ if (voice.rate <= 0 || voice.ramp !== null)
724
+ return Number.NEGATIVE_INFINITY;
725
+ if (!Number.isNaN(this.now) && voice.elapsedAt(this.now) < end)
726
+ return Number.NEGATIVE_INFINITY;
727
+ }
728
+ if (voice.state === 'live')
729
+ due = Math.min(due, voice.timeAt(end));
730
+ }
731
+ return due;
732
+ }
733
+ /** Files a voice in the due queue at its current due, replacing any entry it had. */
734
+ schedule(voice) {
735
+ if (this.projecting)
736
+ return;
737
+ this.lanes?.voiceChanged(voice.id);
738
+ const token = ++voice.dueToken;
739
+ const due = this.dueOf(voice);
740
+ if (due === Number.POSITIVE_INFINITY)
741
+ return;
742
+ const heap = this.due;
743
+ heap.push({ at: Number.isNaN(due) ? Number.NEGATIVE_INFINITY : due, voice, token });
744
+ let i = heap.length - 1;
745
+ while (i > 0) {
746
+ const up = (i - 1) >> 1;
747
+ if (heap[up].at <= heap[i].at)
748
+ break;
749
+ [heap[up], heap[i]] = [heap[i], heap[up]];
750
+ i = up;
751
+ }
752
+ }
753
+ /** Takes every current entry due by `now` off the queue, in cue order. */
754
+ popDue(now) {
755
+ const heap = this.due;
756
+ const at = (k) => k < heap.length ? heap[k].at : Number.POSITIVE_INFINITY;
757
+ const out = [];
758
+ while (heap.length > 0 && heap[0].at <= now) {
759
+ const top = heap[0];
760
+ const last = heap.pop();
761
+ if (heap.length > 0) {
762
+ heap[0] = last;
763
+ let i = 0;
764
+ for (;;) {
765
+ const l = 2 * i + 1;
766
+ const r = l + 1;
767
+ const m0 = at(l) < at(i) ? l : i;
768
+ const m = at(r) < at(m0) ? r : m0;
769
+ if (m === i)
770
+ break;
771
+ [heap[m], heap[i]] = [heap[i], heap[m]];
772
+ i = m;
773
+ }
774
+ }
775
+ if (top.token === top.voice.dueToken && top.voice.state !== 'done') {
776
+ top.voice.dueToken++;
777
+ out.push(top.voice);
778
+ }
779
+ }
780
+ if (out.length > 1)
781
+ out.sort((a, b) => a.id - b.id);
782
+ return out;
783
+ }
585
784
  probe(subject, out) {
586
785
  const pose = this.fold(subject, out);
587
786
  this.keep(subject, pose, out);
@@ -632,24 +831,81 @@ class Mixer {
632
831
  tightest = c.key;
633
832
  }
634
833
  }
635
- let n = 0;
636
- for (const subject of subjects) {
637
- if (n >= room)
638
- throw new RangeError(`blits: pull's array for ${tightest} has room for ${room} subjects, and was given more`);
639
- const head = Number.isNaN(now) ? null : this.chain(subject, now);
640
- const laned = head !== null && lanes?.prepare(head.slot, subject, now, this.version) === true;
641
- if (laned && lanes.whole && !this.wantsPose) {
642
- lanes.write(head.slot, columns, n);
643
- }
644
- else {
834
+ // Any other iterable is read into an array first, no further than one past the room there is.
835
+ let list;
836
+ if (Array.isArray(subjects))
837
+ list = subjects;
838
+ else {
839
+ const taken = [];
840
+ for (const subject of subjects)
841
+ if (taken.push(subject) > room)
842
+ break;
843
+ list = taken;
844
+ }
845
+ if (list.length > room)
846
+ throw new RangeError(`blits: pull's array for ${tightest} has room for ${room} subjects, and was given more`);
847
+ const whole = lanes !== null && !this.wantsPose;
848
+ // An array read again in the same order reuses each position's chain head while it is current:
849
+ // all of them at once while nothing has relinked since the last pull, else one by one.
850
+ const was = this.pulled;
851
+ const heads = this.pulledHeads;
852
+ was.length = list.length;
853
+ heads.length = list.length;
854
+ if (this.pulledSlots.length < list.length) {
855
+ const slots = new Int32Array(Math.max(64, list.length));
856
+ slots.set(this.pulledSlots);
857
+ this.pulledSlots = slots;
858
+ }
859
+ const slots = this.pulledSlots;
860
+ const version = this.version;
861
+ const relinks = this.relinks;
862
+ const live = !Number.isNaN(now);
863
+ const current = live && this.pulledVersion === version && this.pulledRelinks === relinks;
864
+ // Every subject read from the lanes, at the positions it had last time, goes in one run.
865
+ const runs = live && whole;
866
+ try {
867
+ for (let n = 0; n < list.length; n++) {
868
+ if (runs) {
869
+ n = lanes.pullRun(slots, list, was, current ? null : heads, n, columns, now, this.version);
870
+ if (n === list.length)
871
+ break;
872
+ }
873
+ const subject = list[n];
874
+ let slot = -1;
875
+ if (live) {
876
+ if (current && was[n] === subject)
877
+ slot = slots[n];
878
+ else {
879
+ const kept = heads[n];
880
+ const head = was[n] === subject && kept !== undefined && kept.version === version
881
+ ? kept
882
+ : this.chain(subject, now);
883
+ was[n] = subject;
884
+ heads[n] = head;
885
+ slot = head.slot;
886
+ slots[n] = slot;
887
+ }
888
+ }
889
+ const laned = live && lanes?.prepare(slot, subject, now, this.version) === true;
890
+ if (laned && whole && lanes.whole) {
891
+ lanes.writeLater(slot, columns, n);
892
+ continue;
893
+ }
894
+ const head = live ? heads[n] : null;
645
895
  const folded = this.foldWith(subject, scratch, head, laned, false);
646
896
  const pose = (this.bounded.length === 0 ? folded : this.clamp(folded));
647
897
  this.keep(subject, pose, scratch);
648
898
  for (const c of columns)
649
899
  writeValue(c, pose[c.key], n);
650
900
  }
651
- n++;
652
901
  }
902
+ finally {
903
+ lanes?.flush();
904
+ }
905
+ // Every head is current only if nothing relinked or changed version while they were read.
906
+ const unchanged = live && this.version === version && this.relinks === relinks;
907
+ this.pulledVersion = unchanged ? version : Number.NaN;
908
+ this.pulledRelinks = relinks;
653
909
  }
654
910
  /** The kit slot, width, rest and bounds of every channel `pull` was handed an array for. */
655
911
  columnsOf(into) {
@@ -716,7 +972,14 @@ class Mixer {
716
972
  .map((v) => {
717
973
  const log = v.log;
718
974
  const copy = v.copy((subject) => this.recall(v, subject, t), last(log, t, (e) => e.sync) ?? log[0]);
719
- copy.state = t < v.start ? 'pending' : copy.out && copy.out.at <= t ? 'fading' : 'live';
975
+ copy.state =
976
+ t < v.start
977
+ ? 'pending'
978
+ : copy.out && copy.out.at <= t
979
+ ? 'fading'
980
+ : copy.holdsAfter && copy.elapsedAt(t) >= copy.span + copy.latest
981
+ ? 'held'
982
+ : 'live';
720
983
  if (then !== undefined)
721
984
  copy.setting.host = then;
722
985
  return copy;
@@ -806,20 +1069,99 @@ class Mixer {
806
1069
  get live() {
807
1070
  return this.voices.some((v) => v.state !== 'done');
808
1071
  }
1072
+ get inert() {
1073
+ return !this.stirred && this.voices.every((v) => this.still(v, this.now));
1074
+ }
1075
+ /** Whether a voice will change no pose from `now` on, short of a change made to it. */
1076
+ still(voice, now) {
1077
+ if (voice.state === 'done')
1078
+ return true;
1079
+ if (voice.state !== 'held' && voice.state !== 'live')
1080
+ return false;
1081
+ if (typeof voice.spec.weight === 'function' || voice.parts !== null)
1082
+ return false;
1083
+ const fadeIn = this.reducedNow ? 0 : (voice.fade.in ?? 0);
1084
+ if (fadeIn > 0 && now - this.sinceOf(voice, voice.latest) < fadeIn)
1085
+ return false;
1086
+ if (voice.state === 'held')
1087
+ return true;
1088
+ const motion = voice.motion;
1089
+ return motion !== undefined && voice.elapsedAt(now) >= voice.latest && motion.landed();
1090
+ }
809
1091
  mute(opts) {
810
1092
  for (const voice of this.voices)
811
1093
  this.beginFade(voice, { over: opts?.over });
812
1094
  }
813
1095
  drop(subject) {
814
1096
  const head = this.chains.get(subject);
1097
+ // Nothing that kept this head, such as `pull`'s remembered list, may take it as current again.
1098
+ if (head !== undefined) {
1099
+ head.version = Number.NaN;
1100
+ this.relinks++;
1101
+ }
815
1102
  if (head !== undefined && head.slot >= 0 && this.lanes !== null)
816
1103
  this.lanes.release(head.slot);
817
1104
  this.pose.delete(subject);
818
1105
  this.chains.delete(subject);
819
- for (const voice of [...this.voices, ...this.gone]) {
1106
+ this.stirred = true;
1107
+ // A record of it is only in a voice over every subject, one naming it, or one gone; a ramp out
1108
+ // of a voice that does not name it is in `parters`, and a patch's own state in `motions`.
1109
+ const forget = (voice) => {
820
1110
  voice.subjects.delete(subject);
821
- motionOf(voice.patch)?.release(subject);
1111
+ voice.motion?.release(subject);
1112
+ voice.parts?.delete(subject);
1113
+ voice.parted?.delete(subject);
1114
+ };
1115
+ for (const voice of this.general)
1116
+ forget(voice);
1117
+ const named = this.named.get(subject);
1118
+ if (named !== undefined)
1119
+ for (const voice of [...named])
1120
+ forget(voice);
1121
+ for (const voice of this.gone)
1122
+ forget(voice);
1123
+ for (const voice of this.parters) {
1124
+ forget(voice);
1125
+ if (voice.parts?.size === 0)
1126
+ voice.parts = null;
1127
+ if (voice.parted?.size === 0)
1128
+ voice.parted = null;
1129
+ if (voice.parts === null && voice.parted === null)
1130
+ this.parters.delete(voice);
1131
+ }
1132
+ const strays = this.strays.get(subject);
1133
+ if (strays !== undefined)
1134
+ for (const motion of strays)
1135
+ motion.release(subject);
1136
+ this.strays.delete(subject);
1137
+ for (const motion of this.strayAll)
1138
+ motion.release(subject);
1139
+ }
1140
+ /** Notes a motion patch that may hold state for a subject no voice playing it reaches. */
1141
+ stray(motion, subject) {
1142
+ const list = this.strays.get(subject);
1143
+ if (list === undefined)
1144
+ this.strays.set(subject, [motion]);
1145
+ else if (!list.includes(motion))
1146
+ list.push(motion);
1147
+ }
1148
+ /** A motion voice left the list: if its patch plays on, its subjects' state there may stray. */
1149
+ unplay(voice) {
1150
+ const motion = voice.motion;
1151
+ if (motion === undefined)
1152
+ return;
1153
+ const left = (this.playing.get(motion) ?? 1) - 1;
1154
+ if (left <= 0) {
1155
+ this.playing.delete(motion);
1156
+ this.strayAll.delete(motion);
1157
+ return;
822
1158
  }
1159
+ this.playing.set(motion, left);
1160
+ if (voice.named === null)
1161
+ this.strayAll.add(motion);
1162
+ else
1163
+ for (const subject of voice.named)
1164
+ this.stray(motion, subject);
823
1165
  }
824
1166
  drain(tag) {
825
1167
  const all = this.sent;
@@ -848,7 +1190,9 @@ class Mixer {
848
1190
  fits: (voice) => mix.fits(voice),
849
1191
  meet: (voice, subject) => mix.held(voice, subject, mix.now),
850
1192
  naming: (subject) => (mix.naming === 0 ? undefined : mix.named.get(subject)),
1193
+ slotOf: (subject) => mix.chains.get(subject)?.slot ?? -1,
851
1194
  envelope: (voice, since) => mix.envelope(voice, mix.now, since),
1195
+ parting: (voice, subject) => mix.parting(voice, subject, mix.now),
852
1196
  ready: (voice, subject, held, elapsed, pass, weight) => {
853
1197
  mix.prime(voice, held, mix.now, elapsed, pass);
854
1198
  voice.setting.weight = weight;
@@ -874,6 +1218,8 @@ class Mixer {
874
1218
  const patch = voice.patch;
875
1219
  if (voice.keeping)
876
1220
  return false;
1221
+ if (voice.state === 'pending' && voice.holdsBefore)
1222
+ return false;
877
1223
  if (typeof spec.weight === 'function')
878
1224
  return false;
879
1225
  if (spec.locus !== undefined || spec.from === 'current')
@@ -899,6 +1245,20 @@ class Mixer {
899
1245
  * What a motion patch asks for its subjects' voice time at the latest frame. It holds the mix and
900
1246
  * the voice weakly, so a patch the host keeps does not keep a mix it has let go of alive.
901
1247
  */
1248
+ static reviveHook(mix, voice) {
1249
+ return (subject) => {
1250
+ const m = mix.deref();
1251
+ if (m === undefined)
1252
+ return;
1253
+ m.stirred = true;
1254
+ const v = voice.deref();
1255
+ if (v === undefined)
1256
+ return;
1257
+ if (v.motion !== undefined && v.named !== null && !v.named.has(subject))
1258
+ m.stray(v.motion, subject);
1259
+ m.unpart(v, subject);
1260
+ };
1261
+ }
902
1262
  static frameHook(mix, voice) {
903
1263
  return (subject) => {
904
1264
  const m = mix.deref();
@@ -924,19 +1284,18 @@ class Mixer {
924
1284
  prime(voice, held, now, elapsed, pass) {
925
1285
  const setting = voice.setting;
926
1286
  setting.timestamp = now;
927
- const gap = now - held.stepped;
928
- const cap = this.opts.maxDt;
929
- setting.dt = this.reducedNow
930
- ? Number.POSITIVE_INFINITY
931
- : cap !== undefined && gap > cap
932
- ? cap
933
- : gap;
1287
+ setting.dt = this.capped(now - held.stepped);
934
1288
  setting.elapsed = elapsed;
935
1289
  setting.pass = pass;
936
1290
  setting.weight = 0;
937
1291
  setting.state = held.state;
938
1292
  setting.keep = held.keep;
939
1293
  }
1294
+ /** A gap as a `dt`: Infinity under reduced motion, and no more than `maxDt`. */
1295
+ capped(gap) {
1296
+ const cap = this.opts.maxDt;
1297
+ return this.reducedNow ? Number.POSITIVE_INFINITY : cap !== undefined && gap > cap ? cap : gap;
1298
+ }
940
1299
  /** The earliest voice time a read may still ask for, for a subject delayed `delay`. */
941
1300
  horizonFor(voice, delay, now) {
942
1301
  const history = this.opts.history;
@@ -1102,14 +1461,10 @@ class Mixer {
1102
1461
  outAt = out.at;
1103
1462
  else if (Number.isFinite(voice.outAt))
1104
1463
  outAt = voice.outAt;
1105
- else {
1106
- const period = voice.patch.period;
1107
- const passes = voice.passes;
1108
- if (period > 0 && Number.isFinite(passes)) {
1109
- const t = voice.timeAt(period * passes + voice.latest);
1110
- if (Number.isFinite(t))
1111
- outAt = Math.max(start, t);
1112
- }
1464
+ else if (!voice.holdsAfter && Number.isFinite(voice.span)) {
1465
+ const t = voice.timeAt(voice.span + voice.latest);
1466
+ if (Number.isFinite(t))
1467
+ outAt = Math.max(start, t);
1113
1468
  }
1114
1469
  if (mark === 'out')
1115
1470
  return voice.state === 'done' && outAt === undefined ? voice.doneAt : outAt;
@@ -1130,6 +1485,8 @@ class Mixer {
1130
1485
  }
1131
1486
  /** Records a change to a voice's controls under `history`, and lets go of what it no longer reaches. */
1132
1487
  noted(voice) {
1488
+ this.stirred = true;
1489
+ this.schedule(voice);
1133
1490
  const log = voice.log;
1134
1491
  if (log === null)
1135
1492
  return;
@@ -1212,6 +1569,7 @@ class Mixer {
1212
1569
  reaches: live.reaches,
1213
1570
  delay: live.delay,
1214
1571
  since: live.since,
1572
+ shown: live.shown,
1215
1573
  weight: 0,
1216
1574
  rested: false,
1217
1575
  bands: new Uint8Array(voice.slots.length),
@@ -1355,6 +1713,7 @@ class Mixer {
1355
1713
  get state() {
1356
1714
  return voice.state;
1357
1715
  },
1716
+ played: voice.played,
1358
1717
  get weight() {
1359
1718
  return voice.weight;
1360
1719
  },
@@ -1390,7 +1749,10 @@ class Mixer {
1390
1749
  mix.lanes?.refill();
1391
1750
  },
1392
1751
  fade(opts) {
1393
- mix.beginFade(voice, opts ?? {});
1752
+ if (opts !== undefined && 'subject' in opts)
1753
+ mix.fadeSubject(voice, opts.subject, opts.over);
1754
+ else
1755
+ mix.beginFade(voice, opts ?? {});
1394
1756
  },
1395
1757
  weightOf(subject) {
1396
1758
  if (voice.state === 'done')
@@ -1405,13 +1767,85 @@ class Mixer {
1405
1767
  done: voice.done,
1406
1768
  };
1407
1769
  }
1770
+ /** Starts one subject's ramp out of a voice, or takes it out at once for a ramp of 0. */
1771
+ fadeSubject(voice, subject, over) {
1772
+ if (voice.state === 'done' || voice.parted?.has(subject) || voice.parts?.has(subject))
1773
+ return;
1774
+ this.stirred = true;
1775
+ const ms = this.reduced ? 0 : (over ?? voice.fade.out ?? 0);
1776
+ const at = Number.isNaN(this.now) ? voice.start : this.now;
1777
+ if (ms === 0) {
1778
+ this.part(voice, subject, at);
1779
+ return;
1780
+ }
1781
+ voice.parts ??= new Map();
1782
+ voice.parts.set(subject, { at, over: ms });
1783
+ this.parters.add(voice);
1784
+ this.schedule(voice);
1785
+ this.lanes?.refill();
1786
+ }
1787
+ /** What a subject's own ramp out of a voice leaves of its weight this frame, 0..1. */
1788
+ parting(voice, subject, now) {
1789
+ const r = voice.parts?.get(subject);
1790
+ if (r === undefined)
1791
+ return 1;
1792
+ return envelope(0, { at: r.at, over: r.over, rest: false }, voice.ease, this.reducedNow, now, 0);
1793
+ }
1794
+ /**
1795
+ * Takes a subject out of one voice for good, as of mix time `at`: the voice forgets its record,
1796
+ * its lane position and a motion patch's state for it, and reaches it no more.
1797
+ */
1798
+ part(voice, subject, at) {
1799
+ voice.parts?.delete(subject);
1800
+ if (voice.parts?.size === 0)
1801
+ voice.parts = null;
1802
+ voice.parted ??= new Map();
1803
+ voice.parted.set(subject, at);
1804
+ this.parters.add(voice);
1805
+ this.forgetIn(voice, subject);
1806
+ const motion = voice.motion;
1807
+ if (motion !== undefined && motion.frame === voice.frame)
1808
+ motion.release(subject);
1809
+ }
1810
+ /** Brings a subject faded out of a voice back, to be met afresh on its next probe. */
1811
+ unpart(voice, subject) {
1812
+ if (voice.parted?.delete(subject) !== true)
1813
+ return;
1814
+ if (voice.parted.size === 0)
1815
+ voice.parted = null;
1816
+ this.forgetIn(voice, subject);
1817
+ const slot = this.chains.get(subject)?.slot ?? -1;
1818
+ if (slot >= 0)
1819
+ this.lanes?.rejoin(slot);
1820
+ }
1821
+ /** Drops a voice's record of a subject and relinks the subject's chain without it. */
1822
+ forgetIn(voice, subject) {
1823
+ const held = voice.subjects.get(subject);
1824
+ if (held !== undefined) {
1825
+ voice.subjects.delete(subject);
1826
+ if (held.reaches)
1827
+ voice.seen--;
1828
+ if (held.rested)
1829
+ voice.restedCount--;
1830
+ if (voice.holder === held)
1831
+ voice.holder = null;
1832
+ }
1833
+ const head = this.chains.get(subject);
1834
+ if (head === undefined)
1835
+ return;
1836
+ head.version = Number.NaN;
1837
+ this.relinks++;
1838
+ if (voice.laned && head.slot >= 0)
1839
+ this.lanes?.part(voice.id, head.slot);
1840
+ this.lanes?.refill();
1841
+ }
1408
1842
  beginFade(voice, opts, at) {
1409
1843
  if (voice.state === 'done' || voice.state === 'fading')
1410
1844
  return;
1411
1845
  const over = this.reduced ? 0 : (opts.over ?? voice.fade.out ?? 0);
1412
1846
  // A voice is linked into its subjects' chains once it plays, and a fading one plays.
1413
1847
  if (voice.state === 'pending')
1414
- this.version++;
1848
+ this.changed(voice);
1415
1849
  voice.state = 'fading';
1416
1850
  voice.out = {
1417
1851
  from: 1,
@@ -1430,13 +1864,18 @@ class Mixer {
1430
1864
  }
1431
1865
  /** Removes a voice, recording that it left at `at`, default now. */
1432
1866
  retire(voice, at) {
1433
- this.lanes?.invalidate();
1434
- const motion = motionOf(voice.patch);
1867
+ this.lanes?.touch(voice);
1868
+ const motion = voice.motion;
1435
1869
  if (motion !== undefined && motion.frame === voice.frame)
1436
1870
  motion.frame = noFrame;
1871
+ if (motion !== undefined && motion.revive === voice.revive)
1872
+ motion.revive = noRevive;
1437
1873
  voice.frame = null;
1874
+ voice.revive = null;
1438
1875
  voice.state = 'done';
1876
+ this.retired++;
1439
1877
  voice.doneAt = at ?? (Number.isNaN(this.now) ? Number.NEGATIVE_INFINITY : this.now);
1878
+ voice.play(false);
1440
1879
  voice.resolve();
1441
1880
  }
1442
1881
  /** The ramp a voice's own fade envelope applies this frame, 0..1. */
@@ -1450,16 +1889,13 @@ class Mixer {
1450
1889
  return held;
1451
1890
  const reaches = this.aims(voice, subject);
1452
1891
  const delay = reaches && voice.spec.stagger ? voice.spec.stagger(subject) : 0;
1453
- // When the voice clock reads `delay`, from where it is anchored now; during a ramp this assumes
1454
- // the rate it is ramping to.
1455
- const since = voice.rate > 0
1456
- ? voice.anchorNow + (delay - voice.anchorElapsed) / voice.rate
1457
- : voice.start + delay;
1892
+ const since = this.sinceOf(voice, delay);
1458
1893
  const kept = new Map();
1459
1894
  held = {
1460
1895
  reaches,
1461
1896
  delay,
1462
1897
  since,
1898
+ shown: this.shownOf(voice, since),
1463
1899
  weight: 0,
1464
1900
  rested: false,
1465
1901
  bands: new Uint8Array(voice.slots.length),
@@ -1485,12 +1921,43 @@ class Mixer {
1485
1921
  held.unknown = this.backward && voice.spec.from === 'current';
1486
1922
  }
1487
1923
  voice.subjects.set(subject, held);
1924
+ if (voice.state === 'pending') {
1925
+ voice.early ??= [];
1926
+ voice.early.push(held);
1927
+ }
1488
1928
  if (reaches)
1489
1929
  voice.seen++;
1490
- if (delay > voice.latest)
1930
+ if (delay > voice.latest) {
1491
1931
  voice.latest = delay;
1932
+ // A later end can take a held voice live again.
1933
+ if (voice.state === 'held')
1934
+ this.schedule(voice);
1935
+ }
1492
1936
  return held;
1493
1937
  }
1938
+ /**
1939
+ * When the voice clock reads `delay`, from where it is anchored now; during a ramp this assumes
1940
+ * the rate it is ramping to.
1941
+ */
1942
+ sinceOf(voice, delay) {
1943
+ return voice.rate > 0
1944
+ ? voice.anchorNow + (delay - voice.anchorElapsed) / voice.rate
1945
+ : voice.start + delay;
1946
+ }
1947
+ shownOf(voice, since) {
1948
+ return voice.holdsBefore && voice.opened < since ? voice.opened : since;
1949
+ }
1950
+ /** A voice has gone live: the records it made while pending count from where it started. */
1951
+ started(voice) {
1952
+ const early = voice.early;
1953
+ if (early === null)
1954
+ return;
1955
+ voice.early = null;
1956
+ for (const held of early) {
1957
+ held.since = this.sinceOf(voice, held.delay);
1958
+ held.shown = this.shownOf(voice, held.since);
1959
+ }
1960
+ }
1494
1961
  /** The weight a voice gives a subject this frame, with the setting already filled in. */
1495
1962
  weigh(voice, subject, now, held) {
1496
1963
  this.sending.voice = voice;
@@ -1503,7 +1970,8 @@ class Mixer {
1503
1970
  if (signal.input && !was)
1504
1971
  this.record(held, base);
1505
1972
  }
1506
- return clampWeight(base * this.envelope(voice, now, held.since));
1973
+ const fade = this.envelope(voice, now, held.shown);
1974
+ return clampWeight(base * (voice.parts === null ? fade : fade * this.parting(voice, subject, now)));
1507
1975
  }
1508
1976
  /**
1509
1977
  * One live voice's delta for one subject this frame, through its record for the subject, or null
@@ -1515,9 +1983,19 @@ class Mixer {
1515
1983
  return null;
1516
1984
  if (voice.out?.rest && held.rested)
1517
1985
  return null;
1518
- const elapsed = voice.elapsedAt(now) - held.delay;
1519
- if (elapsed < 0)
1520
- return null;
1986
+ let elapsed = voice.elapsedAt(now) - held.delay;
1987
+ // A held subject's clock stands still at the edge it holds: -1 before, 1 after, 0 playing.
1988
+ let still = 0;
1989
+ if (elapsed < 0) {
1990
+ if (!voice.holdsBefore)
1991
+ return null;
1992
+ elapsed = 0;
1993
+ still = -1;
1994
+ }
1995
+ else if (voice.holdsAfter && elapsed > voice.span) {
1996
+ elapsed = voice.span;
1997
+ still = 1;
1998
+ }
1521
1999
  const period = voice.patch.period;
1522
2000
  const phase = phaseAt(elapsed, period, voice.passes);
1523
2001
  const pass = passAt(elapsed, period, voice.passes);
@@ -1538,12 +2016,16 @@ class Mixer {
1538
2016
  this.recordHost(now);
1539
2017
  reading.horizon = this.horizonFor(voice, held.delay, now);
1540
2018
  const tick = this.opts.stepMs;
1541
- if (voice.patch.step && held.probed !== now) {
2019
+ if (voice.patch.step && held.probed !== now && still >= 0) {
2020
+ // Held after, it steps once more to where its last pass ended, and no further.
2021
+ const to = still === 1 ? voice.timeAt(held.delay + voice.span) : now;
1542
2022
  if (tick !== undefined && tick > 0 && !this.reducedNow)
1543
- this.tick(voice, subject, held, tick);
1544
- else if (now !== held.stepped) {
2023
+ this.tick(voice, subject, held, tick, to);
2024
+ else if (still === 0 ? now !== held.stepped : to > held.stepped) {
2025
+ if (still === 1)
2026
+ setting.dt = this.capped(to - held.stepped);
1545
2027
  voice.patch.step(held.state, setting.dt, subject, setting);
1546
- held.stepped = now;
2028
+ held.stepped = to;
1547
2029
  }
1548
2030
  }
1549
2031
  let delta;
@@ -1591,13 +2073,13 @@ class Mixer {
1591
2073
  * counted from when its delay ran out, and leaves the remainder for the next sample. Counting
1592
2074
  * rather than adding keeps the grid where it is however many samples it is reached through.
1593
2075
  */
1594
- tick(voice, subject, held, tick) {
2076
+ tick(voice, subject, held, tick, to) {
1595
2077
  const now = this.now;
1596
2078
  const setting = voice.setting;
1597
2079
  const frameDt = setting.dt;
1598
2080
  let n = held.ticks;
1599
2081
  // The epsilon keeps an interval like 1000 / 120 from landing a hair short of a whole count.
1600
- const due = Math.floor((now - held.since) / tick + 1e-9);
2082
+ const due = Math.floor((to - held.since) / tick + 1e-9);
1601
2083
  const cap = this.opts.maxDt;
1602
2084
  if (cap !== undefined) {
1603
2085
  const most = Math.floor(cap / tick);
@@ -1714,7 +2196,8 @@ class Mixer {
1714
2196
  /**
1715
2197
  * The first of this subject's records, linked through every live voice that reaches it. A voice
1716
2198
  * is asked once, through the record it keeps for the subject anyway; a pending one is linked when
1717
- * it goes live, since `target` is asked on first sight, and sight only comes once a voice plays.
2199
+ * it goes live, since `target` is asked on first sight, and sight only comes once a voice plays
2200
+ * or holds before.
1718
2201
  */
1719
2202
  chain(subject, now) {
1720
2203
  const was = this.chains.get(subject);
@@ -1723,7 +2206,8 @@ class Mixer {
1723
2206
  let first = null;
1724
2207
  let prev = null;
1725
2208
  const link = (voice) => {
1726
- if (voice.state !== 'live' && voice.state !== 'fading')
2209
+ const state = voice.state;
2210
+ if (state === 'done' || (state === 'pending' && !voice.holdsBefore))
1727
2211
  return;
1728
2212
  const held = this.held(voice, subject, now);
1729
2213
  if (!held.reaches)
@@ -1761,10 +2245,32 @@ class Mixer {
1761
2245
  }
1762
2246
  /** Whether a voice reaches a subject by what its spec says: the subjects it names, or its `target`. */
1763
2247
  aims(voice, subject) {
2248
+ // A read back to before a subject left still finds it reached.
2249
+ const left = voice.parted?.get(subject);
2250
+ if (left !== undefined && !(this.now < left))
2251
+ return false;
1764
2252
  if (voice.named !== null)
1765
2253
  return voice.named.has(subject);
1766
2254
  return voice.spec.target ? voice.spec.target(subject) : true;
1767
2255
  }
2256
+ /**
2257
+ * A voice joined or left the list, or started or stopped waiting: the chains it is linked into
2258
+ * are relinked, every subject's for a voice over all of them and only its own for one naming
2259
+ * its subjects, and lanes take it on or off.
2260
+ */
2261
+ changed(voice) {
2262
+ this.lanes?.touch(voice);
2263
+ if (voice.named === null) {
2264
+ this.version++;
2265
+ return;
2266
+ }
2267
+ for (const subject of voice.named) {
2268
+ const head = this.chains.get(subject);
2269
+ if (head !== undefined)
2270
+ head.version = Number.NaN;
2271
+ }
2272
+ this.relinks++;
2273
+ }
1768
2274
  /** Files a voice under each subject it names, or among the voices that name none. */
1769
2275
  index(voice) {
1770
2276
  if (voice.named === null) {