diff --git a/csoundengine/abstractrenderer.py b/csoundengine/abstractrenderer.py index 3d09e9c..d01bea5 100644 --- a/csoundengine/abstractrenderer.py +++ b/csoundengine/abstractrenderer.py @@ -18,6 +18,10 @@ class AbstractRenderer(ABC): + """ + Base class for live rendering (:class:`~csoundengine.session.Session`) and + offline rendering (:class:`~csoundengine.offline.Renderer`) + """ @abstractmethod def renderMode(self) -> str: @@ -135,6 +139,7 @@ def automate(self, @abstractmethod def hasBusSupport(self) -> bool: + """Does this renderer have bus support?""" raise NotImplementedError @abstractmethod @@ -164,6 +169,9 @@ def includeFile(self, path: str) -> None: @abstractmethod def schedEvent(self, event: Event) -> SchedEvent: + """ + Schedule the given event + """ raise NotImplementedError def schedEvents(self, events: Sequence[Event]) -> SchedEventGroup: diff --git a/csoundengine/baseschedevent.py b/csoundengine/baseschedevent.py index 998bed8..bbcbcdf 100644 --- a/csoundengine/baseschedevent.py +++ b/csoundengine/baseschedevent.py @@ -7,6 +7,7 @@ from ._common import EMPTYSET, EMPTYDICT from typing import Sequence +from typing_extensions import Self class BaseSchedEvent(ABC): @@ -321,3 +322,38 @@ def show(self) -> None: else: print(repr(self)) + # @classmethod + # def cropEvents(cls, events: list[Self], start=0., end=0.) -> list[Self]: + # """ + # Crop the events so that no event exceeds the given limits + # + # Args: + # events: a list of SchedEvents + # start: the min. start time for any event + # end: the max. end time for any event + # + # Returns: + # a list with the cropped events + # """ + # scoreend = max(ev.end for ev in events) + # if end == 0: + # end = scoreend + # cropped = [] + # for ev in events: + # if ev.end < start or ev.start > end: + # continue + # + # if start <= ev.start <= ev.end: + # cropped.append(ev) + # else: + # xstart, xend = emlib.mathlib.intersection(start, end, ev.start, ev.end) + # if xstart is not None: + # if xend == float('inf'): + # dur = -1 + # else: + # dur = xend - xstart + # ev = ev.clone(start=xstart, dur=dur) + # cropped.append(ev) + # return cropped + + diff --git a/csoundengine/config.py b/csoundengine/config.py index 0b41b09..334626d 100644 --- a/csoundengine/config.py +++ b/csoundengine/config.py @@ -110,35 +110,36 @@ def _validateFigsize(cfg: dict, key: str, val) -> bool: doc='size limit when writing tables as f score statements via gen2. If a table ' 'is bigger than this size, it is saved as a datafile as gen23 or wav') _('dynamic_pfields', True, - doc='If True, use pfields for dynamic parameters (named args starting with k)') + doc='If True, use pfields for dynamic parameters (named args starting with k). ' + 'Otherwise, dynamic controls are implemented via a global table') _('fail_if_unmatched_pargs', False, - doc='Fail if the # of passed pargs doesnt match the # of pargs'), + doc='Fail if the number of passed arguments doesnt match the number of defined arguments'), _('set_sigint_handler', True, doc='Set a sigint handler to prevent csound crash with CTRL-C') _('disable_signals', True, doc='Disable atexit and sigint signal handler') -_('generalmidi_soundfont', '') +_('generalmidi_soundfont', '', + doc='Default soundfont used for general midi rendering') _('suppress_output', True, doc='Suppress csound´s debugging information') _('unknown_parameter_fail_silently', True, doc='Do not raise if a synth tries to set an unknown parameter') _('define_builtin_instrs', True, doc="If True, a Session with have all builtin instruments defined") -_('sample_fade_time', 0.05, - doc="Fade time when playing samples via a Session") +_('sample_fade_time', 0.02, + doc="Fade time (in seconds) when playing samples via a Session") _("prefer_udp", True, - doc="If true and a server was defined prefer UDP over the API for communication") + doc="If true and a udp server was defined, prefer UDP over the API for communication") _("start_udp_server", False, doc="Start an engine with udp communication support") -_('associated_table_min_size', 16, - doc="Min. size of the param table associated with a synth") _('num_audio_buses', 64, doc="Num. of audio buses in an Engine/Session") _('num_control_buses', 512, - doc="Num. of control buses in an Engine/Session") + doc="Num. of control buses in an Engine/Session. This sets the upper limit to the " + "number of simultaneous control buses in use") _('html_theme', 'light', choices={'dark', 'light'}, - doc="Style to use when displaying syntax highlighting") + doc="Style to use when displaying syntax highlighting in jupyter") _('html_args_fontsize', '12px', doc="Font size used for args when outputing html (in jupyter)") _('synth_repr_max_args', 12, @@ -175,7 +176,8 @@ def _validateFigsize(cfg: dict, key: str, val) -> bool: _('max_dynamic_args_per_instr', 10, range=(2, 512), - doc='Max. number of dynamic parameters per instr') + doc='Max. number of dynamic parameters per instr. This applies only if dynamic args ' + 'are implemented via a global table') _('session_priorities', 10, range=(1, 99), diff --git a/csoundengine/offline.py b/csoundengine/offline.py index 4490f92..3cc27ed 100644 --- a/csoundengine/offline.py +++ b/csoundengine/offline.py @@ -1,38 +1,3 @@ -""" -Offline rendering is implemented via the class :class:`~csoundengine.offline.Renderer`, -which has the same interface as a :class:`~csoundengine.session.Session` and -can be used as a drop-in replacement. - -Example -======= - -.. code-block:: python - - from csoundengine import * - from pitchtools import * - - renderer = Renderer(sr=44100, nchnls=2) - - renderer.defInstr('saw', r''' - kmidi = p5 - outch 1, oscili:a(0.1, mtof:k(kfreq)) - ''') - - events = [ - renderer.sched('saw', 0, 2, kmidi=ntom('C4')), - renderer.sched('saw', 1.5, 4, kmidi=ntom('4G')), - renderer.sched('saw', 1.5, 4, kmidi=ntom('4G+10')) - ] - - # offline events can be modified just like real-time events - events[0].automate('kmidi', (0, 0, 2, ntom('B3')), overtake=True) - - events[1].set(delay=3, kmidi=67.2) - events[2].set(kmidi=80, delay=4) - renderer.render("out.wav") - -""" - from __future__ import annotations import os @@ -76,8 +41,6 @@ __all__ = ( "Renderer", - "SchedEvent", - "SchedEventGroup", "RenderJob" ) @@ -267,7 +230,7 @@ def __init__(self, """Csd structure for this renderer (see :class:`~csoundengine.csoundlib.Csd`""" self.controlArgsPerInstr = dynamicArgsPerInstr or config['max_dynamic_args_per_instr'] - """The max. number of dynamic controls per instr""" + """The maximum number of dynamic controls per instr""" self.instrs: dict[str, Instr] = {} """Maps instr name to Instr instance""" @@ -275,6 +238,9 @@ def __init__(self, self.numPriorities: int = priorities """Number of priorities in this Renderer""" + self.soundfileRegistry: dict[str, TableProxy] = {} + """A dict mapping soundfile paths to their corresponding TableProxy""" + self._idCounter = 0 self._nameAndPriorityToInstrnum: dict[tuple[str, int], int] = {} self._instrnumToNameAndPriority: dict[int, tuple[str, int]] = {} @@ -316,7 +282,6 @@ def __init__(self, self._stringRegistry: dict[str, int] = {} self._includes: set[str] = set() self._builtinInstrs: dict[str, int] = {} - self._soundfileRegistry: dict[str, TableProxy] = {} prelude = offlineorc.prelude(controlNumSlots=self._dynargsNumSlices, controlArgsPerInstr=self._dynargsSliceSize) @@ -1342,7 +1307,7 @@ def readSoundfile(self, if path == "?": path = _state.openSoundfile() - tabproxy = self._soundfileRegistry.get(path) + tabproxy = self.soundfileRegistry.get(path) if tabproxy is not None: logger.warning(f"Soundfile '{path}' has already been added to this project") if not force and tabproxy.skiptime == skiptime: @@ -1356,7 +1321,7 @@ def readSoundfile(self, tabproxy = TableProxy(tabnum=tabnum, parent=self, numframes=info.nframes, sr=info.samplerate, nchnls=info.channels, path=path, skiptime=skiptime) - self._soundfileRegistry[path] = tabproxy + self.soundfileRegistry[path] = tabproxy return tabproxy def playSample(self, @@ -1500,7 +1465,7 @@ def automate(self, if automStart > event.start or automEnd < event.end: pairs, delay = internal.cropDelayedPairs(pairs=pairs, delay=delay, - start=automStart, end=automEnd) + start=automStart, end=automEnd) if not pairs: logger.warning("There is no intersection between event and automation data") return 0. @@ -1745,40 +1710,6 @@ def playPartials(self, # ------------------ end Renderer --------------------- -def cropScore(events: list[SchedEvent], start=0., end=0.) -> list[SchedEvent]: - """ - Crop the score so that no event exceeds the given limits - - Args: - events: a list of ScoreEvents - start: the min. start time for any event - end: the max. end time for any event - - Returns: - a list with the cropped events - """ - scoreend = max(ev.end for ev in events) - if end == 0: - end = scoreend - cropped = [] - for ev in events: - if ev.end < start or ev.start > end: - continue - - if start <= ev.start <= ev.end: - cropped.append(ev) - else: - xstart, xend = emlib.mathlib.intersection(start, end, ev.start, ev.end) - if xstart is not None: - if xend == float('inf'): - dur = -1 - else: - dur = xend - xstart - ev = ev.clone(start=xstart, dur=dur) - cropped.append(ev) - return cropped - - def _checkParams(params: Iterator[str], dynamicParams: set[str], obj=None) -> None: for param in params: if param not in dynamicParams: diff --git a/csoundengine/schedevent.py b/csoundengine/schedevent.py index 4d46a49..bec434d 100644 --- a/csoundengine/schedevent.py +++ b/csoundengine/schedevent.py @@ -32,6 +32,12 @@ class SchedAutomation: to which this automation belongs""" overtake: bool = False + """ + If True, use the current value of the parameter as initial value, + diregarding the value in the automation line + + This is also done if the first value in the automation line is NAN + """ class SchedEvent(BaseSchedEvent): diff --git a/csoundengine/session.py b/csoundengine/session.py index f1bcbfd..8b10c24 100644 --- a/csoundengine/session.py +++ b/csoundengine/session.py @@ -2,175 +2,6 @@ A :class:`Session` provides a high-level interface to control an underlying csound process. A :class:`Session` is associated with an :class:`~csoundengine.engine.Engine` (there is one Session per Engine) - -.. contents:: - :depth: 3 - :local: - :backlinks: none - -Overview --------- - -* A Session uses instrument templates (:class:`~csoundengine.instr.Instr`), which - enable an instrument to be instantiated at any place in the evaluation chain. -* An instrument template within a Session can also declare default values for pfields -* Session instruments can declare named controls. These are dynamic parameters which - can be modified and automated over the lifetime of an event - (:meth:`Synth.set `, - :meth:`Synth.automate `) - -1. Instrument Templates -~~~~~~~~~~~~~~~~~~~~~~~ - -In csound there is a direct -mapping between an instrument declaration and its order of evaluation. Within a -:class:`Session`, on the other hand, it is possible to declare an instrument ( -:class:`~csoundengine.instr.Instr`) and instantiated it at any order, -making it possibe to create chains of processing units. - -.. code-block:: python - - s = Engine().session() - # Notice: the filter is declared before the generator. If these were - # normal csound instruments, the filter would receive an instr number - # lower and thus could never process audio generated by `myvco` - Instr('filt', r''' - Schan strget p5 - kcutoff = p6 - a0 chnget Schan - a0 moogladder2 a0, kcutoff, 0.9 - outch 1, a0 - chnclear Schan - ''').register(s) - - # The same can be achieved via Session.defInstr: - - s.defInstr('myvco', r''' - kfreq = p5 - kamp = p6 - Schan strget p7 - a0 = vco2:a(kamp, kfreq) - chnset a0, Schan - ''') - synth = s.sched('myvco', kfreq=440, kamp=0.1, Schan="chan1") - - # The filter is instantiated with a priority higher than the generator and - # thus is evaluated later in the chain. - filt = s.sched('filt', priority=synth.priority+1, kcutoff=1000, Schan="chan1") - -2. Named pfields with default values -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -An :class:`~csoundengine.instr.Instr` (also declared via :meth:`~Session.defInstr`) -can define default values for its pfields via ``pset``. When scheduling an event the -user only needs to fill the values for those pfields which differ from the given default. -Notice that **p4 is reserved and cannot be used** - -.. code:: - - s = Engine().session() - s.defInstr('sine', r''' - pset p1, p2, p3, 0, 0.1, 1000 - iamp = p5 - kfreq = p6 - a0 = oscili:a(iamp, kfreq) - outch 1, a0 - ''') - # We schedule an event of sine, iamp will take the default (0.1) - synth = s.sched('sine', kfreq=440) - # pfields assigned to k-variables can be modified by name - synth.set(kamp=0.5) - -3. Dynamic Controls -~~~~~~~~~~~~~~~~~~~ - -An Instr can define a number of **named controls**, similar to pfields. These -controls must have a valid csound name (starting with 'k') and can -define a default value. They provide a more efficient way of controlling -dynamic parameters and are the default method to communicate with a running -event. - -.. note:: - - Dynamic controls are implemented as one big table. Each time an instr with - dynamic controls is scheduled, it is assigned a slice within that table. - (the slice number is passed as p4). On the python side that table can be - accesses directly, making it very efficient to set it from python without - needing to call csound at all. - -The same instr as above can be defined using dynamic controls as follows: - -.. code:: - - s = Engine().session() - s.defInstr('sine', r''' - outch 1, oscili:a(kamp, kfreq) - ''', - args={'kamp': 0.1, 'kfreq': 1000}) - - synth = s.sched('sine', kfreq=800) - # Dynamic controls can also be modified via set - synth.set(kfreq=1000) - # Automation can be applied with 'automate' - synth.automate('freq', (0, 0, 2, 440, 3, 880), overtake=True) - -**csoundengine** generates the needed code to access the table slots: - -.. code-block:: csound - - i__slicestart__ = p4 - i__tabnum__ chnget ".dynargsTabnum" - kamp tab i__slicestart__ + 0, i__tabnum__ - kfreq tab i__slicestart__ + 1, i__tabnum__ - - -4. Inline arguments -~~~~~~~~~~~~~~~~~~~ - -An :class:`~csoundengine.instr.Instr` can set arguments (both init args -and dynamic args) as an inline declaration: - -.. code-block:: python - - s = Engine().session() - s.defInstr('sine', r''' - |iamp=0.1, kfreq=1000| - a0 = oscili:a(kamp, kfreq) - outch 1, a0 - ''') - -Notice that the generated code used pfields for init-time parameters -and dynamic controls for k-time arguments. - -.. code-block:: csound - - iamp = p5 - i__tabnum__ chnget ".dynargsTabnum" - kfreq tab i__slicestart__ + 0, i__tabnum__ - a0 = oscili:a(iamp, kfreq) - outch 1, a0 - - -All dynamic (k-rate) parameters can be modulated after the note has started -(see `:meth:~maelzel.synth.Synth.set`). Also notice that parameters start -with ``p5``: ``p4`` is reserved. In fact, declaring an :class:`Instr` which -uses ``p4`` will raise an exception. - - -5. User Interface -~~~~~~~~~~~~~~~~~ - -A :class:`~csoundengine.synth.Synth` can be modified interactively via -an auto-generated user-interface. Depending on the running context -this results in either a gui dialog (within a terminal) or an embedded -user-interface in jupyter. - -.. figure:: assets/synthui.png - -UI generated when using the terminal: - -.. figure:: assets/ui2.png - """ from __future__ import annotations @@ -506,9 +337,15 @@ def __hash__(self): return id(self) def isRendering(self) -> bool: + """Is an offline renderer attached to this session?""" return self._offlineRenderer is not None - def isRedirected(self) -> bool: + def hasHandler(self) -> bool: + """ + Does this session have a handler to redirect actions? + + .. seealso:: :meth:`Session.setHandler` + """ return self._handler is not None def stop(self) -> None: @@ -527,6 +364,7 @@ def _dispatcher(self): print("error", task) def hasBusSupport(self) -> bool: + """Does the underlying engine have bus support?""" return self.engine.hasBusSupport() def getSynthById(self, token: int) -> Synth | None: @@ -751,34 +589,16 @@ def _registerInstrAtPriority(self, instrname: str, priority=1) -> int: def setHandler(self, handler: SessionHandler | None ) -> SessionHandler | None: + """ + Set a SessionHandler for this session + + This is used internally to delegate actions to an offline renderer + when this session is rendering. + """ prevhandler = self._handler self._handler = handler return prevhandler - # def setSchedCallback(self, callback: Callable[[Event], SchedEvent] - # ) -> Callable | None: - # """ - # Set the schedule callback - # - # A Session can be set to bypass its schedule mechanism. When the schedule callback - # is set, the function given will be called whenever :meth:`Session.sched` is called - # and the Session will not perform any other action than calling this callback. - # - # This is used, for example, to create a context manager under which a series - # of events need to be scheduled but instead of scheduling one by one all are - # first collected, they are initialized and then scheduled all at once (with - # the engine's time locked) in order to keep them in synch. - # - # Args: - # callback: the callback to set. The signature is the same as :meth:`Session.sched` - # - # Returns: - # the old callback, or None if no callback was set - # """ - # oldcallback = self._schedCallback - # self._schedCallback = callback - # return oldcallback - def defInstr(self, name: str, body: str, @@ -1175,7 +995,7 @@ def _automateBus(self, bus: busproxy.Bus, pairs: Sequence[float], def schedEvents(self, events: Sequence[Event]) -> SynthGroup: """ - Schedule multiple SessionEvents + Schedule multiple events Args: events: the events to schedule @@ -1183,23 +1003,28 @@ def schedEvents(self, events: Sequence[Event]) -> SynthGroup: Returns: a SynthGroup with the synths corresponding to the given events """ + sync = False for event in events: - self.prepareSched(instr=event.instrname, - priority=event.priority, - block=False) - self.engine.sync() + _, needssync = self.prepareSched(instr=event.instrname, + priority=event.priority, + block=False) + if needssync: + sync = True + if sync: + self.engine.sync() with self.engine.lockedClock(): synths = [self.schedEvent(event) for event in events] return SynthGroup(synths) def schedEvent(self, event: Event) -> Synth: """ - Schedule a SessionEvent + Schedule an event - A SessionEvent can be generated to store a Synth's data. + An Event can be generated to store a Synth's data. Args: - event: a SessionEvent + event: the event to schedule. An :class:`csoundengine.event.Event` + represents an unscheduled event. Returns: the generated Synth @@ -1220,6 +1045,8 @@ def schedEvent(self, event: Event) -> Synth: ... >>> synth.stop() + .. seealso:: :class:`csoundengine.synth.Synth`, :class:`csoundengine.schedevent.SchedEvent` + """ kws = event.kws or {} synth = self.sched(instrname=event.instrname, @@ -1331,14 +1158,14 @@ def rendering(self, handler = _RenderingSessionHandler(renderer=renderer) self.setHandler(handler) - def atexit(r: offline.Renderer, _outfile=outfile): - r.render(outfile=_outfile, endtime=endtime, encoding=encoding, + def atexit(r: offline.Renderer, outfile: str, session: Session) -> None: + r.render(outfile=outfile, endtime=endtime, encoding=encoding, starttime=starttime, openWhenDone=openWhenDone, tail=tail, verbose=verbose) - self._offlineRenderer = None - self.setHandler(None) + session._offlineRenderer = None + session.setHandler(None) - renderer._registerExitCallback(atexit) + renderer._registerExitCallback(lambda renderer: atexit(r=renderer, outfile=outfile, session=self)) self._offlineRenderer = renderer return renderer @@ -1495,17 +1322,6 @@ def sched(self, args=args, whenfinished=whenfinished, relative=relative, kws=kwargs) return self._handler.schedEvent(event) - # if self._schedCallback: - # event = Event(instrname=instrname, - # delay=delay, - # dur=dur, - # priority=priority, - # args=args, - # whenfinished=whenfinished, - # relative=relative, - # kws=kwargs) - # return self._schedCallback(event) - if self.isRendering(): raise RuntimeError("Session blocked during rendering") @@ -1712,11 +1528,12 @@ def readSoundfile(self, The life-time of the returned TableProxy object is not bound to the csound table. If the user needs to free the table, this needs to be done manually by calling - :meth: + :meth:`csoundengine.tableproxy.TableProxy.free` + Args: path: the path to a soundfile. **"?" to open file via a gui dialog** - chan: the channel to read, or 0 to read all channels into a - (possibly) stereo or multichannel table + chan: the channel to read, or 0 to read all channels into a multichannel table. + Within a multichannel table, samples are interleaved force: if True, the soundfile will be read and added to the session even if the same path has already been read before.# delay: when to read the soundfile (0=now) @@ -2141,6 +1958,7 @@ def playSample(self, ixfade=crossfade)) def makeRenderer(self, sr=0, nchnls: int | None = None, ksmps=0, + addTables=True, addIncludes=True ) -> offline.Renderer: """ Create a :class:`~csoundengine.offline.Renderer` (to render offline) with @@ -2155,6 +1973,12 @@ def makeRenderer(self, sr=0, nchnls: int | None = None, ksmps=0, the default defined in the config nchnls: the number of output channels. If not given, nchnls is taken from the session + addTables: if True, any soundfile read via readSoundFile will be made + available to the renderer. The TableProxy corresponding to that + soundfile can be queried via :attr:`csoundengine.offline.Renderer.soundfileRegistry`. + Notice that data tables will not be exported to the renderer + addIncludes: + add any ``#include`` file declared in this session to the created renderer Returns: a Renderer @@ -2182,8 +2006,15 @@ def makeRenderer(self, sr=0, nchnls: int | None = None, ksmps=0, dynamicArgsSlots=self._dynargsNumSlots) for instrname, instrdef in self.instrs.items(): renderer.registerInstr(instrdef) + if addIncludes: + for include in self._includes: + renderer.includeFile(include) + if addTables: + for path in self._pathToTabproxy: + renderer.readSoundfile(path) return renderer + def _defBuiltinInstrs(self): for csoundInstr in builtinInstrs: self.registerInstr(csoundInstr) diff --git a/csoundengine/synth.py b/csoundengine/synth.py index a8a4b6a..8a090df 100644 --- a/csoundengine/synth.py +++ b/csoundengine/synth.py @@ -926,14 +926,3 @@ def wait(self, pollinterval: float = 0.02, sleepfunc=time.sleep) -> None: """ internal.waitWhileTrue(self.playing, pollinterval=pollinterval, sleepfunc=sleepfunc) - -""" -class FutureSynth(AbstrSynth): - def __init__(self, event: sessionevent.SessionEvent, engine: Engine): - super().__init__(start=event.delay, dur=event.dur, engine=engine) - self.event = event - self._session = self.engine.session() - - def set(self, param='', value: float = 0., delay=0., **kws) -> None: - self.session. -""" diff --git a/docs/Installation.rst b/docs/Installation.rst index e229158..782a6b1 100644 --- a/docs/Installation.rst +++ b/docs/Installation.rst @@ -10,5 +10,10 @@ the same:: Dependencies ------------ -* **csound6** (>= **6.16**): ``_ - (support for *csound7* is still experimental) +**csoundengine** depends on **csound** itself being installed. At the moment the minimal +version of csound supported is **6.16**. **csound 7**, even if not officially released +yet, is supported and tested regularly. + +For **macos** and **windows**, **csound** can be installed by the provided installers +(``_). For linux, the installation via +the distribution's package manager is the recommended way. diff --git a/docs/Reference.rst b/docs/Reference.rst index bd8f4e9..b7b7ca0 100644 --- a/docs/Reference.rst +++ b/docs/Reference.rst @@ -2,9 +2,6 @@ Reference ========= -Overview --------- - **Engine** An :class:`~csoundengine.engine.Engine` wraps a live csound process and provides a simple way to define instruments, schedule events, load soundfiles, etc., for realtime audio processing / synthesis. @@ -21,31 +18,39 @@ Overview The same instrument templates can be reused for :ref:`offline (non-real-time) rendering` +**Instr** + An :class:`~csoundengine.instr.Instr` is an instrument template. It defines the + csound code and its parameters and default values. A concrete csound instrument is + created only when an :class:`~csoundengine.instr.Instr` is scheduled. + The main difference with a csound ``instr`` is that an + :class:`~csoundengine.instr.Instr` can be scheduled at any level within the + evaluation chain. Similar to plugins in a DAW, ``Instr`` can be organized to build + processing chains of any depth. + **Synth** - Within a :class:`~csoundengine.session.Session`, each scheduled event is wrapped in a - :class:`~csoundengine.synth.Synth`. A :class:`Synth` has methods for setting and automating/modulating - parameters, querying audio inputs and outputs and can auto-generate a user-interface to interact with - its parameters in real-time. + A :class:`~csoundengine.synth.Synth` wraps an event scheduled within a + :class:`~csoundengine.session.Session`. It has methods for setting and + automating/modulating parameters, querying audio inputs and outputs and + can auto-generate a user-interface to interact with its parameters in real-time. .. _offlineintro: **Offline Rendering** + Both an :class:`~csoundengine.engine.Engine` and its associated :class:`~csoundengine.session.Session` are concieved to run in real-time. For offline rendering **csoundengine** provides the :class:`~csoundengine.offline.Renderer` class, which has the same interface as a :class:`~csoundengine.session.Session` but collects all scheduled events, - soundfiles, automation, etc. and renders everything in non-real-time (and probably much faster). + soundfiles, automation, etc. and renders everything offline (and probably much faster). A :class:`~csoundengine.offline.Renderer` is a drop-in replacement for a :class:`~csoundengine.session.Session`. It can also be used to generate a csound project to be further edited and/or rendered by the csound executable. See :ref:`offlinemod` --------------- -Table of Contents ------------------ - .. toctree:: :maxdepth: 1 + :hidden: csoundengine session diff --git a/docs/baseevent.rst b/docs/baseevent.rst deleted file mode 100644 index 07e84ff..0000000 --- a/docs/baseevent.rst +++ /dev/null @@ -1,5 +0,0 @@ -BaseEvent -========= - -.. autoclass:: csoundengine.baseevent.BaseEvent - :members: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index d3895f0..4e3dbab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -100,10 +100,11 @@ napoleon_use_rtype = True html_theme_options = { - 'navigation_depth': 3, + # 'navigation_depth': 3, 'use_fullscreen_button': False, 'use_download_button': False, - 'show_navbar_depth': 2 + "show_toc_level": 2 + # 'show_navbar_depth': 2 } html_css_files = [ diff --git a/docs/configkeys.rst b/docs/configkeys.rst index 39c626a..de104fe 100644 --- a/docs/configkeys.rst +++ b/docs/configkeys.rst @@ -90,23 +90,47 @@ A4: | Between 410 - 460 | *Frequency for A4* +.. _config_numthreads: + +numthreads: + | Default: **1** -- ``int`` + | *Number of threads to use for realtime performance. This is an experimental feature if csound and might not necessarily result in better performance* + +.. _config_rec_numthreads: + +rec_numthreads: + | Default: **0** -- ``int`` + | *Number of threads to use when rendering online. If not given, the value set in `numthreads` is used* + .. _config_check_pargs: check_pargs: | Default: **False** -- ``bool`` | *Check number of pargs passed to instr* +.. _config_max_pfields: + +max_pfields: + | Default: **1900** -- ``int`` + | *The size limit for pfields in an event* + .. _config_offline_score_table_size_limit: offline_score_table_size_limit: | Default: **1900** -- ``int`` | *size limit when writing tables as f score statements via gen2. If a table is bigger than this size, it is saved as a datafile as gen23 or wav* +.. _config_dynamic_pfields: + +dynamic_pfields: + | Default: **True** -- ``bool`` + | *If True, use pfields for dynamic parameters (named args starting with k). Otherwise, dynamic controls are implemented via a global table* + .. _config_fail_if_unmatched_pargs: fail_if_unmatched_pargs: | Default: **False** -- ``bool`` - | *Fail if the # of passed pargs doesnt match the # of pargs* + | *Fail if the number of passed arguments doesnt match the number of defined arguments* .. _config_set_sigint_handler: @@ -114,10 +138,17 @@ set_sigint_handler: | Default: **True** -- ``bool`` | *Set a sigint handler to prevent csound crash with CTRL-C* +.. _config_disable_signals: + +disable_signals: + | Default: **True** -- ``bool`` + | *Disable atexit and sigint signal handler* + .. _config_generalmidi_soundfont: generalmidi_soundfont: | Default: **''** -- ``str`` + | *Default soundfont used for general midi rendering* .. _config_suppress_output: @@ -140,14 +171,14 @@ define_builtin_instrs: .. _config_sample_fade_time: sample_fade_time: - | Default: **0.05** -- ``float`` - | *Fade time when playing samples via a Session* + | Default: **0.02** -- ``float`` + | *Fade time (in seconds) when playing samples via a Session* .. _config_prefer_udp: prefer_udp: | Default: **True** -- ``bool`` - | *If true and a server was defined prefer UDP over the API for communication* + | *If true and a udp server was defined, prefer UDP over the API for communication* .. _config_start_udp_server: @@ -155,12 +186,6 @@ start_udp_server: | Default: **False** -- ``bool`` | *Start an engine with udp communication support* -.. _config_associated_table_min_size: - -associated_table_min_size: - | Default: **16** -- ``int`` - | *Min. size of the param table associated with a synth* - .. _config_num_audio_buses: num_audio_buses: @@ -171,14 +196,14 @@ num_audio_buses: num_control_buses: | Default: **512** -- ``int`` - | *Num. of control buses in an Engine/Session* + | *Num. of control buses in an Engine/Session. This sets the upper limit to the number of simultaneous control buses in use* .. _config_html_theme: html_theme: | Default: **light** -- ``str`` | Choices: ``dark, light`` - | *Style to use when displaying syntax highlighting* + | *Style to use when displaying syntax highlighting in jupyter* .. _config_html_args_fontsize: @@ -192,12 +217,24 @@ synth_repr_max_args: | Default: **12** -- ``int`` | *Max. number of pfields shown when in a synth's repr* +.. _config_synth_repr_show_pfield_index: + +synth_repr_show_pfield_index: + | Default: **False** -- ``bool`` + | *Show the pfield index for named pfields in a Synths repr* + .. _config_synthgroup_repr_max_rows: synthgroup_repr_max_rows: | Default: **4** -- ``int`` | *Max. number of rows for a SynthGroup repr. Use 0 to disable* +.. _config_synthgroup_html_table_style: + +synthgroup_html_table_style: + | Default: **font-size: smaller** -- ``str`` + | *Inline css style applied to the table displayed as html for synthgroups* + .. _config_jupyter_synth_repr_stopbutton: jupyter_synth_repr_stopbutton: @@ -243,7 +280,7 @@ timeout: .. _config_sched_latency: sched_latency: - | Default: **0.0** -- ``float`` + | Default: **0.05** -- ``float`` | *Time delay added to any event scheduled to ensure that simultameous events arenot offset by scheduling overhead* .. _config_datafile_format: @@ -253,6 +290,33 @@ datafile_format: | Choices: ``gen23, wav`` | *Format used when saving a table as a datafile* +.. _config_max_dynamic_args_per_instr: + +max_dynamic_args_per_instr: + | Default: **10** -- ``int`` + | Between 2 - 512 + | *Max. number of dynamic parameters per instr* + +.. _config_session_priorities: + +session_priorities: + | Default: **10** -- ``int`` + | Between 1 - 99 + | *Number of priorities within a session* + +.. _config_dynamic_args_num_slots: + +dynamic_args_num_slots: + | Default: **10000** -- ``int`` + | Between 10 - 9999999 + | *Number of slots for dynamic parameters. args slices. Dynamic args are implemented as a big array divided in slices. This parameter sets the max. number of such slices, and thus the max number of simultaneous events with named args which can coexist. The size of the allocated table will be size = num_dynamic_args_slices * max_instr_dynamic_args. For 10000 slots, theamount of memory is ~0.8Mb* + +.. _config_instr_repr_show_pfield_pnumber: + +instr_repr_show_pfield_pnumber: + | Default: **False** -- ``bool`` + | *Add pfield number when printing pfields in instruments* + .. _config_spectrogram_colormap: spectrogram_colormap: @@ -282,10 +346,3 @@ spectrogram_maxfreq: spectrogram_window: | Default: **hamming** -- ``str`` | Choices: ``hamming, hanning`` - -.. _config_dependencies_check_timeout_days: - -dependencies_check_timeout_days: - | Default: **7** -- ``int`` - | Between 1 - 365 - | *Elapsed time (in days) after which dependencies will be checked* diff --git a/docs/instr.rst b/docs/instr.rst index ac69992..9d87774 100644 --- a/docs/instr.rst +++ b/docs/instr.rst @@ -1,27 +1,54 @@ Instr – Instrument Templates ============================ -An Instr is a template used to schedule a concrete instrument at a :class:`~csoundengine.session.Session` -or a :class:`~csoundengine.offline.Renderer`. -It must be registered to be used. +An :class:`~csoundengine.instr.Instr` is a template used to schedule a concrete instrument at +a :class:`~csoundengine.session.Session` or a :class:`~csoundengine.offline.Renderer`. It must +be registered to be used (see :meth:`~csoundengine.session.Session.registerInstr`) or created +via :meth:`~csoundengine.session.Session.defInstr`. -Example -------- +**Example** .. code-block:: python - s = Engine().session() - Instr('sine', r''' + from csoundengine import * + + s = Session() + + s.defInstr('sine', r''' kfreq = p5 kamp = p6 a0 = oscili:a(kamp, kfreq) outch 1, a0 - ''').register(s) + ''') + synth = s.sched('sine', kfreq=440, kamp=0.1) ... synth.stop() +**Named arguments / Inline arguments** + +An :class:`Instr` can define named arguments and assign default values to any argument. +Named arguments can be created either by using the supported csound syntax, using +``ivar = p5`` or ``kvar = p5``. Default values can be assigned via ``pset`` (https://csound.com/manual/pset.html) +or through the ``args`` argument to :class:`Instr` or :meth:`~csoundengine.session.Session.defInstr`. +Alternatively inline arguments can be used: + +An inline args declaration can set both parameter name and default value: + +.. code-block:: python + + s = Engine().session() + Instr('sine', r''' + |iamp=0.1, kfreq=1000| + a0 = oscili:a(iamp, kfreq) + outch 1, a0 + ''').register(s) + synth = s.sched('sine', kfreq=440) + synth.stop() + + + .. autoclass:: csoundengine.instr.Instr :members: :autosummary: diff --git a/docs/offline.rst b/docs/offline.rst index ed92d77..afce809 100644 --- a/docs/offline.rst +++ b/docs/offline.rst @@ -3,13 +3,98 @@ Offline Rendering ================= +Offline rendering is implemented via the :class:`~csoundengine.offline.Renderer` class, +which has the same interface as a :class:`~csoundengine.session.Session` and +can be used as a drop-in replacement. + + +**Example** + +.. code-block:: python + + from csoundengine import * + from pitchtools import * + + renderer = Renderer(sr=44100, nchnls=2) + + renderer.defInstr('saw', r''' + kmidi = p5 + outch 1, oscili:a(0.1, mtof:k(kfreq)) + ''') + + events = [ + renderer.sched('saw', 0, 2, kmidi=ntom('C4')), + renderer.sched('saw', 1.5, 4, kmidi=ntom('4G')), + renderer.sched('saw', 1.5, 4, kmidi=ntom('4G+10')) + ] + + # offline events can be automated just like real-time events + events[0].automate('kmidi', (0, 0, 2, ntom('B3')), overtake=True) + + events[1].set(delay=3, kmidi=67.2) + events[2].set(kmidi=80, delay=4) + renderer.render("out.wav") + + +It is possible to create a :class:`~csoundengine.offline.Renderer` out of an existing +:class:`~csoundengine.session.Session`, by calling :meth:`session.makeRenderer `. +This creates a :class:`~csoundengine.offline.Renderer` with all :class:`~csoundengine.instr.Instr` and resources +(tables, include files, global code, etc.) in the :class:`~csoundengine.session.Session` already defined. + +.. code-block:: python + + from csoundengine import * + session = Session() + session.defInstr('test', ...) + table = session.readSoundfile('path/to/soundfile') + session.sched('test', ...) + session.playSample(table) + + # Render offline + renderer = session.makeRenderer() + renderer.sched('test', ...) + renderer.playSample('path/to/soundfile') + renderer.render('out.wav') + +A more convenient way to render offline given a live :class:`~csoundengine.session.Session` is to use the +:meth:`~csoundengine.session.Session.rendering` method: + +.. code-block:: python + + from csoundengine import * + session = Session() + session.defInstr('test', ...) + table = session.readSoundfile('path/to/soundfile') + + with session.rendering('out.wav') as r: + r.sched('test', ...) + r.playSample(table) + +---------------------- + +Renderer +======== + +.. autoclass:: csoundengine.offline.Renderer + :members: + :autosummary: + + +---------------------- + +**RenderJob** + +.. autoclass:: csoundengine.offline.RenderJob + :members: + :autosummary: -.. automodapi:: csoundengine.offline .. toctree:: :maxdepth: 1 :hidden: - baseevent + schedevent + + diff --git a/docs/schedevent.rst b/docs/schedevent.rst new file mode 100644 index 0000000..4831108 --- /dev/null +++ b/docs/schedevent.rst @@ -0,0 +1,2 @@ + +.. automodapi:: csoundengine.schedevent diff --git a/docs/session.rst b/docs/session.rst index 9e65aa1..57dccbc 100644 --- a/docs/session.rst +++ b/docs/session.rst @@ -1,33 +1,191 @@ Session – High-level interface ============================== -.. automodule:: csoundengine.session - :autosummary: +.. contents:: + :depth: 3 + :local: + :backlinks: none +Overview +-------- -------------------------- +* A Session uses instrument templates (:class:`~csoundengine.instr.Instr`), which + enable an instrument to be instantiated at any place in the evaluation chain. +* An instrument template within a Session can also declare default values for pfields +* Session instruments can declare named controls. These are dynamic parameters which + can be modified and automated over the lifetime of an event + (:meth:`Synth.set `, + :meth:`Synth.automate `) -Session Class -------------- +1. Instrument Templates +~~~~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: csoundengine.session.Session - :members: - :autosummary: +In csound there is a direct +mapping between an instrument declaration and its order of evaluation. Within a +:class:`Session`, on the other hand, it is possible to declare an instrument ( +:class:`~csoundengine.instr.Instr`) and instantiated it at any order, +making it possibe to create chains of processing units. + +.. code-block:: python + + s = Engine().session() + # Notice: the filter is declared before the generator. If these were + # normal csound instruments, the filter would receive an instr number + # lower and thus could never process audio generated by `myvco` + Instr('filt', r''' + Schan strget p5 + kcutoff = p6 + a0 chnget Schan + a0 moogladder2 a0, kcutoff, 0.9 + outch 1, a0 + chnclear Schan + ''').register(s) + + # The same can be achieved via Session.defInstr: + + s.defInstr('myvco', r''' + kfreq = p5 + kamp = p6 + Schan strget p7 + a0 = vco2:a(kamp, kfreq) + chnset a0, Schan + ''') + synth = s.sched('myvco', kfreq=440, kamp=0.1, Schan="chan1") + + # The filter is instantiated with a priority higher than the generator and + # thus is evaluated later in the chain. + filt = s.sched('filt', priority=synth.priority+1, kcutoff=1000, Schan="chan1") + +2. Named pfields with default values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An :class:`~csoundengine.instr.Instr` (also declared via :meth:`~Session.defInstr`) +can define default values for its pfields via the ``pset`` opcode +(http://www.csounds.com/manual/html/pset.html). When scheduling an event the user only +needs to fill the values for those pfields which differ from the given default. +Notice that **p4 is reserved and cannot be used** + +.. code-block:: python + + + s = Engine().session() + s.defInstr('sine', r''' + pset p1, p2, p3, 0, 0.1, 1000 + iamp = p5 + kfreq = p6 + a0 = oscili:a(iamp, kfreq) + outch 1, a0 + ''') + # We schedule an event of sine, iamp will take the default (0.1) + synth = s.sched('sine', kfreq=440) + # pfields assigned to k-variables can be modified by name + synth.set(kamp=0.5) + +3. Dynamic Controls +~~~~~~~~~~~~~~~~~~~ + +An Instr can define a number of **named controls**, similar to pfields. These +controls must have a valid csound name (starting with 'k') and can +define a default value. They provide a more efficient way of controlling +dynamic parameters and are the default method to communicate with a running +event. + +.. note:: + + Dynamic controls are implemented in two different ways. One is by using + regular pfields and having direct access to thses pfields via the ``pwrite`` + opcode; another method is by reading from a control table. In the latter case, + each time an instr with dynamic controls is scheduled, it is assigned a slice + within that table (the slice number is passed as p4). On the python side that + table can be accesses directly, making it very efficient to set it from python + without needing to call csound at all. + +The same instr as above can be defined using dynamic controls as follows: + +.. code:: + + s = Engine().session() + s.defInstr('sine', r''' + outch 1, oscili:a(kamp, kfreq) + ''', + args={'kamp': 0.1, 'kfreq': 1000}) + + synth = s.sched('sine', kfreq=800) + # Dynamic controls can also be modified via set + synth.set(kfreq=1000) + # Automation can be applied with 'automate' + synth.automate('freq', (0, 0, 2, 440, 3, 880), overtake=True) + +**csoundengine** generates the needed code to access the table slots: + +.. code-block:: csound + + i__slicestart__ = p4 + i__tabnum__ chnget ".dynargsTabnum" + kamp tab i__slicestart__ + 0, i__tabnum__ + kfreq tab i__slicestart__ + 1, i__tabnum__ + + +4. Inline arguments +~~~~~~~~~~~~~~~~~~~ + +An :class:`~csoundengine.instr.Instr` can set arguments (both init args +and dynamic args) as an inline declaration: + +.. code-block:: python + + s = Engine().session() + s.defInstr('sine', r''' + |iamp=0.1, kfreq=1000| + a0 = oscili:a(kamp, kfreq) + outch 1, a0 + ''') + +Notice that the generated code used pfields for init-time parameters +and dynamic controls for k-time arguments. + +.. code-block:: csound + + iamp = p5 + i__tabnum__ chnget ".dynargsTabnum" + kfreq tab i__slicestart__ + 0, i__tabnum__ + a0 = oscili:a(iamp, kfreq) + outch 1, a0 + + +All dynamic (k-rate) parameters can be modulated after the note has started +(see `:meth:~maelzel.synth.Synth.set`). Also notice that parameters start +with ``p5``: ``p4`` is reserved: declaring an :class:`Instr` which +uses ``p4`` will raise an exception. + + +5. User Interface +~~~~~~~~~~~~~~~~~ + +A :class:`~csoundengine.synth.Synth` can be modified interactively via +an auto-generated user-interface. Depending on the running context +this results in either a gui dialog (within a terminal) or an embedded +user-interface in jupyter. + +.. figure:: assets/synthui.png + +UI generated when using the terminal: + +.. figure:: assets/ui2.png ------------------------- -SessionEvent Class ------------------- +Session Class +============= -.. autoclass:: csoundengine.session.SessionEvent +.. autoclass:: csoundengine.session.Session :members: :autosummary: - ------------------------- -Bus ---- +Bus Class +========= .. autoclass:: csoundengine.busproxy.Bus :members: diff --git a/docs/synth.rst b/docs/synth.rst index c23f3cb..09ac2ce 100644 --- a/docs/synth.rst +++ b/docs/synth.rst @@ -69,6 +69,8 @@ Generated ui inside jupyter: .. figure:: assets/synthui.png +------------------------------------- + SynthGroup ========== diff --git a/setup.py b/setup.py index 6e688e0..102e666 100755 --- a/setup.py +++ b/setup.py @@ -48,6 +48,7 @@ def package_files(directory): "progressbar2", "xxhash", "docstring-parser", + "typing_extensions", "ctcsound7>=0.4.6", "sndfileio>=1.9.4",