Reading the server: buses, taps and takes

Most of what a script reads off the server it asks for and gets back at once: a buffer's samples, a query's reply, a summary built from either. This chapter is the other kind — what the server keeps sending, because what is being watched changes faster than anything could ask.

WhatPathFor
a control bus/bus_stream → periodic snapshotsmeters, read-outs, slow traces
an audio bus/bus_tapStream → windows of samplesoscilloscopes, phasescopes, spectra
a take as it records/buffer_stream → the overview as it is writtena picture that grows with the sound

The GUI host reads all three on its own — that is why a GuiDef naming a bus, a tap or a take draws with no script at all, and the drawing is always its: a client names what to look at (plot, scope, a meter/scope/waveform widget) and the host paints it. What is here is the same three paths opened to your script, for everything else a program does with the data — a read-out, a decision, a summary it hands on, a test.

Everything lives in the clausters.data module:

from clausters.data import BusStream, TapStream, RecordingStream

Each stream is its own OSC client: it sends its subscription out of a receiver socket of its own and the reports come back there, on the responder thread. That is what makes the callbacks work at all — the server handle's reply path is pulled, so a subscription sent over it would have nobody listening — and it is also why the server's one subscription per client rule costs nothing here: two streams, or one beside a server.stream_buses(...) call you make yourself, replace nothing. Keep a handler to storing and reading, never a round trip, which is the rule every OscFunc callback follows.

Control buses

level = Bus.control(server=server)
# ... something writes it: a synth's out_ctl, a knob bound to it, bus.set

buses = BusStream.open(server, [level], period_ms=33)
buses.on_snapshot(lambda values, s: report(values[0]))   # ~30 times a second

values is an array('f') in the order you asked for, always holding the newest snapshot; buses.value(level) reads one by handle and gives nan for a bus this stream does not watch. When the read-out goes away, buses.stop() cancels the subscription — the buses themselves are untouched, since a stream only ever reads.

The server puts a ceiling on how many buses one subscription may list (--max-stream-buses), and the number is per carrier: a snapshot is one message and is never split across replies, so the same server answers a client over the shared ring and one over TCP with two different figures. A script that watches a great deal reads it rather than assuming it — server.query_info().max_stream_buses — and a request over the ceiling is refused whole, leaving whatever subscription was already there.

A stream is a latest value, not a history: a script that needs one keeps it.

history = collections.deque([0.0] * 512, maxlen=512)
buses.on_snapshot(lambda values, s: history.append(values[0]))

To see the history rather than hold it, name the view instead — a scope widget at rate="control" plots a control bus's recent past, and the host keeps the window.

Audio buses

A control bus carries one value per block; an analysis needs the samples. A control bus lives permanently in the server's shared segment, so it can always be read; an audio bus does not, so the server records the ones it is asked for. You never name the recording: you name the bus.

taps = TapStream.open(server, [bus], frames=2048, period_ms=33)
taps.on_data(lambda bus, w: report(max(abs(s) for s in w.samples)))

Opening the stream is what starts the recording, and taps.stop() is what ends it. The server has a finite number of rings to record into (8 by default), shared with whatever the GUI host is drawing — it counts watchers, so several views of one bus cost one ring, and a stream that cannot get one fails loudly rather than falling silent. frames is clamped to the carrier's bound and to half the ring, so a window may come back shorter than asked, and a bus whose recording has not filled one yet sends nothing at all.

Each window arrives with end_position — the total samples ever recorded for that bus at the window's end — so consecutive windows can be placed on the bus's own timeline: they overlap or gap by exactly the position delta, never by a guess about the period.

A stereo pair reads as one interleaved window, which is what a correlation and a phase figure take:

from clausters._native import correlation, lissajous
from clausters.render import channels

left, right = channels(taps.interleaved(bus, 2), 2)   # adjacent buses b and b+1
points = lissajous(left, right)                       # (x, y) per frame
r = correlation(left, right)                          # -1 … +1, or None

To see the bus, name it instead: scope(bus) opens an oscilloscope window on it, and a scope widget in a GuiDef puts one inside a window you compose. The framing and the trigger that make a periodic signal stand still are the host's, over the same tap — there is nothing to compute here, and nothing that would agree with the host by coincidence.

A take as it records

A recording is the one thing that answers no question. A RecordBuf fills a buffer block by block from the audio thread, which is the one place that must never send a message, so what the writer publishes instead is how far it has got — into the server's shared memory, where a peer that maps the segment reads it directly and everybody else reads nothing. /buffer_stream is that reading for whoever cannot map: the server sends the overview of the frames that appeared, at about a hundredth of the audio's bandwidth.

take = Buffer.alloc(10 * 48000, 1, server=server)
stream = RecordingStream.open(server, [take])
stream.on_report(lambda bufnum, s: print(s.written(bufnum), "frames"))

Synth("record_something", {"buf": take.bufnum}, server=server)

Each take's cache is allocated at the buffer's full length and empty, so the axis does not move while it fills; written is how far the reports have got, and past it the cache is the silence the buffer is — read up to it and the two stay apart, which is what a waveform's fills does for the picture. stream.peaks(take) is the cache itself, the same bytes peaks_cache builds from samples, so it goes wherever one of those goes.

Only the overview arrives: inside one bucket there is one figure, so a script that needs the detail reads the take back with take.get_samples() once it is finished.

To watch a take fill, name it: a waveform widget with fills over the take's buffer follows the same frontier from the host's side, drawn to where the writer has got and no further. This class is for a script that wants the summary itself; and when what you want is the file a picture maps rather than the bytes, peaks_cache_stream_file grows one on disk that a waveform(cache=...) reads as it fills.