Bug or by design? Ppar pulling an event from upstream for every internal/parallel stream

When answering a related question I noticed that Ppar pulls one event from its upstream for every of its internal, parallelized streams, i.e. compare:

(p = Ppar([
	Pbind(\degree, Pkey(\melody), \dur, 0.2),
	Pbind(\degree, Pkey(\melody), \ctranspose, 24, \dur, 0.2)
])  <> Pbind (\melody, Pshuf([1,2,3,4], inf)) );

// with

(p = Ppar([
	Pbind(\degree, Pkey(\melody), \dur, 0.2),
	Pbind(\degree, Pkey(\melody), \ctranspose, 24, \dur, 0.2)
]) <> Pbind (\melody, Pstutter(2, Pshuf([1,2,3,4], inf))) );

I first thought the “double pull” problem is in Pkey, but it’s actually in PPar:

(p = Ppar([
	Pbind(\degree, Pkey(\melody), \dur, 0.2),
	Pbind(\degree, Pkey(\melody2), \ctranspose, 24, \dur, 0.2)
]) <> Pbind (\melody, Pshuf([1,2,3,4], inf), \melody2, Pkey(\melody)));

With the example immediately above \melody and \melody2 are “in sync” internally in each Pbind, but the two Pbinds inside the Ppar see different values relative to the other Pbind, e.g.

-> [ ( \melody: 3, \degree: 3, \dur: 0.2, \delta: 0.0, 
  \melody2: 3 ), ( \melody: 4, \degree: 4, \dur: 0.2, \ctranspose: 24, 
  \delta: 0.2, \melody2: 4 ) ]
-> [ ( \melody: 2, \degree: 2, \dur: 0.2, \delta: 0.0, 
  \melody2: 2 ), ( \melody: 1, \degree: 1, \dur: 0.2, \ctranspose: 24, 
  \delta: 0.2, \melody2: 1 ) ]

So, is this behavior of Ppar to pull (from upstream) a new event for each of its parallelized sub-stream as intended, or can it be considered a bug?

After thinking about it a bit more, it’s probably difficult to avoid this behavior/semantics because Ppar doesn’t know which of its (internal) sub-streams will “go next”, so it can’t meaningfully buffer whatever events it receives from its (data) upstream in order to “round-robin” it.

It’s actually pretty confusing what Ppar does precisely (besides roughly sync-ing its sub-streams) when the sub-streams have different durs, e.g.

(p = Ppar([
	Pbind(\dur, 0.2, \x, Pseries()),
	Pbind(\ctranspose, 24, \dur, 0.4, \y, Pseries())
]))

r = p.asStream
r.nextN(2, ()) // a few times

Output

-> [ ( \delta: 0.0, \dur: 0.2, \x: 0 ), ( \y: 0, \delta: 0.2, \dur: 0.4, \ctranspose: 24 ) ]
-> [ ( \delta: 0.2, \dur: 0.2, \x: 1 ), ( \y: 1, \delta: 0.0, \dur: 0.4, \ctranspose: 24 ) ]
-> [ ( \delta: 0.2, \dur: 0.2, \x: 2 ), ( \delta: 0.2, \dur: 0.2, \x: 3 ) ]
-> [ ( \y: 2, \delta: 0.0, \dur: 0.4, \ctranspose: 24 ), ( \delta: 0.2, \dur: 0.2, \x: 4 ) ]
-> [ ( \delta: 0.2, \dur: 0.2, \x: 5 ), ( \delta: 2.2204460492503e-16, \dur: 0.2, \x: 6 ) ]
-> [ ( \y: 3, \delta: 0.2, \dur: 0.4, \ctranspose: 24 ), ( \delta: 0.2, \dur: 0.2, \x: 7 ) ]
-> [ ( \delta: 2.2204460492503e-16, \dur: 0.2, \x: 8 ), ( \y: 4, \delta: 0.2, \dur: 0.4, \ctranspose: 24 ) ]

It seems to use delta to override dur (sometimes with zero-length, i.e. delta = 0, or nearly so, events) so one probably needs to be careful not to provide delta on its own substreams… But those zero-length events probably pull/consume data/events from upstream. I would have guessed PpolyPar was created in part because of this kind of issues with Ppar, but it also seems to output events with near zero deltas, which probably pull data/events from upstream too.

(p = PpolyPar([
	[\dur, 0.2, \x, Pseries()],
	[\dur, 0.4, \y, Pseries(), \ctranspose, 24]
], \default!2))

r = p.asStream
r.nextN(2, ()) // a few times

Output

-> [ ( \instrument: default, \group: Group(3199), \dur: 1e-06, \addToCleanup: [ a Function ], 
  \cleanup: an EventStreamCleanup, \type: on ), ( \instrument: default, \group: Group(3200), \dur: 1e-06, \addToCleanup: [ a Function ], 
  \cleanup: an EventStreamCleanup, \type: on ) ]
-> [ ( \synths: 0, \dur: 0.2, \args: [ x ], \x: 0, 
  \delta: 1e-06, \type: set, \id: [ Group(3199) ] ), ( \synths: 1, \dur: 0.4, \args: [ y, ctranspose ], \ctranspose: 24, 
  \y: 0, \delta: 0.199999, \type: set, \id: [ Group(3200) ] ) ]
-> [ ( \synths: 0, \dur: 0.2, \args: [ x ], \x: 1, 
  \delta: 0.2, \type: set, \id: [ Group(3199) ] ), ( \synths: 0, \dur: 0.2, \args: [ x ], \x: 2, 
  \delta: 9.9999999997324e-07, \type: set, \id: [ Group(3199) ] ) ]
-> [ ( \synths: 1, \dur: 0.4, \args: [ y, ctranspose ], \ctranspose: 24, 
  \y: 1, \delta: 0.199999, \type: set, \id: [ Group(3200) ] ), ( \synths: 0, \dur: 0.2, \args: [ x ], \x: 3, 
  \delta: 0.2, \type: set, \id: [ Group(3199) ] ) ]
-> [ ( \synths: 0, \dur: 0.2, \args: [ x ], \x: 4, 
  \delta: 9.9999999991773e-07, \type: set, \id: [ Group(3199) ] ), ( \synths: 1, \dur: 0.4, \args: [ y, ctranspose ], \ctranspose: 24, 
  \y: 2, \delta: 0.199999, \type: set, \id: [ Group(3200) ] ) ]

Moral of the story seem to be that parallel programming/patterns seem to need idempotent upstream events, or else the results can be quite unpredictable. because it’s hard to say how many times they upstream will get pulled.)

I would say, by design.

There is a 1:1 correspondence between events generated by Ppar sub-streams and events coming out of Ppar. Ppar makes no attempt to merge multiple events into one output event (nor could it, as there’s no assumption that the sub-streams will be time-coordinated in any way).

In Pchain(a, b), every a event must be populated first by a b event… and the events are necessarily separate… so they necessarily pull independently from b.

The case where all sub-streams follow exactly the same timing would be a special case of Ppar. I can see the value of the behavior you had expected, but I think that would require a separate pattern class that doesn’t exist yet, perhaps like PUnifiedChain([array, of, patterns], dur_pattern, source_pattern) that enforces unified timing.

hjh

I see scztt has tried to solve that here with a PtimeClutch, but I suspect one (addionally) needs to account for near-zero length events with some tolerance. I’ve tried it with different length/durs and it seems to work properly, i.e. doesn’t “lose sync”, e.g. on \melody in the next example:

(p = Ppar([
	Pbind(\degree, Pkey(\melody), \dur, 0.2),
	Pbind(\degree, Pkey(\melody), \ctranspose, 24, \dur, 0.4)
]) <> PtimeClutch(Pbind (\melody, Pshuf([1,2,3,4], inf)), 1e-06));

r = p.asStream
r.nextN(2, ()) // several times

Output

-> [ ( \melody: 1, \degree: 1, \dur: 0.2, \delta: 0.0 ), ( \melody: 1, \degree: 1, \dur: 0.4, \delta: 0.2, 
  \ctranspose: 24 ) ]
-> [ ( \melody: 4, \degree: 4, \dur: 0.2, \delta: 0.2 ), ( \melody: 4, \degree: 4, \dur: 0.4, \delta: 0.0, 
  \ctranspose: 24 ) ]
-> [ ( \melody: 2, \degree: 2, \dur: 0.2, \delta: 0.2 ), ( \melody: 2, \degree: 2, \dur: 0.2, \delta: 0.2 ) ]
-> [ ( \melody: 3, \degree: 3, \dur: 0.4, \delta: 0.0, 
  \ctranspose: 24 ), ( \melody: 3, \degree: 3, \dur: 0.2, \delta: 0.2 ) ]
-> [ ( \melody: 1, \degree: 1, \dur: 0.2, \delta: 0.2 ), ( \melody: 1, \degree: 1, \dur: 0.2, \delta: 2.2204460492503e-16 ) ]
-> [ ( \melody: 4, \degree: 4, \dur: 0.4, \delta: 0.2, 
  \ctranspose: 24 ), ( \melody: 4, \degree: 4, \dur: 0.2, \delta: 0.2 ) ]
-> [ ( \melody: 2, \degree: 2, \dur: 0.2, \delta: 2.2204460492503e-16 ), ( \melody: 2, \degree: 2, \dur: 0.4, \delta: 0.2, 
  \ctranspose: 24 ) ]
-> [ ( \melody: 3, \degree: 3, \dur: 0.2, \delta: 0.2 ), ( \melody: 3, \degree: 3, \dur: 0.2, \delta: 2.2204460492503e-16 ) ]
-> [ ( \melody: 1, \degree: 1, \dur: 0.4, \delta: 0.2, 
  \ctranspose: 24 ), ( \melody: 1, \degree: 1, \dur: 0.2, \delta: 0.2 ) ]
-> [ ( \melody: 4, \degree: 4, \dur: 0.4, \delta: 0.0, 
  \ctranspose: 24 ), ( \melody: 4, \degree: 4, \dur: 0.2, \delta: 0.2 ) ]
-> [ ( \melody: 2, \degree: 2, \dur: 0.2, \delta: 0.2 ), ( \melody: 2, \degree: 2, \dur: 0.4, \delta: 4.4408920985006e-16, 
  \ctranspose: 24 ) ]
-> [ ( \melody: 3, \degree: 3, \dur: 0.2, \delta: 0.2 ), ( \melody: 3, \degree: 3, \dur: 0.2, \delta: 0.2 ) ]

Another interesting idea might be a version of Pchain that imagines each subpattern proceeding independently in time. If, in Pchain(a, b), a proceeds in half beats and b proceeds in beats, then 2 a events would get the same b values. The tricky thing there is, what is the master rhythm? A composite of all possible time points? Or (the solution currently used in TidalCycles and my own live-coding framework) that one of them (perhaps a) determines the output rhythm and b is subordinate (skipping some b values if b is faster)? Perhaps both ways, in two classes.

It would be good to have something like that, though it would involve some design decisions.

hjh

1 Like

I think @scztt has tried something like that too

Fairly long discussion here on that: Time-aware merging of two Event Pattern streams

From that it looks like @totalgee’s version won out with some (proposed) changes

1 Like