Sygaldry
|
Copyright 2023 Travis J. West, https://traviswest.ca, Input Devices and Music Interaction Laboratory (IDMIL), Centre for Interdisciplinary Research in Music Media and Technology (CIRMMT), McGill University, Montréal, Canada, and Univ. Lille, Inria, CNRS, Centrale Lille, UMR 9189 CRIStAL, F-59000 Lille, France
SPDX-License-Identifier: MIT
Although many components of digital musical instruments are commonly employed across different designs, their implementation and representation in computer programs tends to vary in incompatible ways. In helpers/endpoints.lili
we have defined a particular set of helper classes that allow components and endpoints to be conveniently annotated with useful metadata that can guide binding authors in how to interpret these entities. This document describes a set of abstract concepts that components and endpoints are expected to adhere to, as well as providing generic subroutines that allow the signals and metadata from these entities to be accessed, even when the underlying representation of this information may vary. It is recommended to read this document second, after helpers/endpoints.lili
.
In the interminable future, we can imagine that the whole music technology community might band together to define a set of standard concepts that are used to build digital musical applications, enabling widespread compatibility between implementations of components and runtime environments. We aspire towards this goal, but acknowledge the limited time and resources available to this project's development, and compromise on the goal of totally generic compatibility for the sake of completing other necessary objectives with the available resources. This means that the concepts and accessors defined here are expected to be fairly minimal, and the only representations that they are intended to support as those used in this project. Nevertheless, these reprsentations are developed with widespread applicability in mind, and it is hoped that the overall approach can still demonstrate the advantages of this generic component-oriented development model.
As discussed in endpoints.lili
, there are three main methods of associating information with an endpoint or component: through its member functions, through its member variables, and through its member types (including enumerations). In all three cases, the format of the information is as boundless as what can be represented by a C++ structure with its own methods, data, and types. However, in most practical cases, it is sufficient to use methods and enumerators, and their the names of these things within a class. Further, of all the built in types available, in most cases it suffices to use only string literals, signed and unsigned integers of 32 or 64 bits, floats, doubles, and booleans, returned from methods. For a few common special cases, it makes sense to use structures of the above types, and it is also helpful to make use of std::optional
as described in endpoints.lili
. For our purposes, we focus on these resources. Eventually, it will be useful to augment the above with complex numbers, vectors, and even matrices and tensors as primitive types, as well as function objects and other means of passing around subroutines and the thread of execution. But as a starting point, the basic representations described are enough to achieve a great deal of useful work.
As concepts are a relatively new addition to C++, there aren't necessarily as strong conventions established for their typography. Here we adopt the following approximate rules of thumb: A concept that establishes a type T
as having a certain property is written in lower snake case, e.g. has_name<T>
. A concept that establishes T as being alike to certain other type or typical API is written the same, e.g. tuple_like
. Finally, if a concept establishes that a certain T is, for all intents and purposes, an instance of a certain sort of object, we use camel case, as in OccasionalValue
or Component
. As in the rest of the project, a trailing (or leading, but this should be changed to trailing) underscore is used to signify a private implementation detail that the user should likely not access.
Recap:
has_name
tuple_like
PersistentValue
not_yours_
Accessors for names and other textual metadata are defined in concepts/metadata.lili
A range is represented by a structure with min, max, and init member variables of the same underlying type. An entity is considered to have a range if it possesses a static member function that returns such a structure. The use of std::decay_t
and the accessor subroutines follow the logic seen in the previous section.
A persistent value is one that should remain the same between invocations of a component's main subroutine. For now, we assume that a persistent value endpoint will be implemented such that is has a value
variable or method that returns something that has a type that the endpoint can be treated as an instance of due to its conversion and assignment operators.
We first model the notion that an endpoint can be treated as an instance of its value type with the concept similar_to
, which asserts that for an endpoint type T
with value type Y
, T
can be converted to a Y
, initialized from one, or assigned from one, and that the result of said assignment can be used to assign another endoint of the same type.
We could just as well have used the standard concepts convertible_to
, constructible_from
, and assignable_from
, but these make more strict assertions that don't necessarily align with every hack and workaround that component authors may use to meet the described requirements. For instance, the fact that assignment operators are inherited from the persistent<T>
base class helper in the endpoint helpers defined in endpoints.lili
is in violation of the requirements of assignable_from
, which requires the assignment to T
to return a reference to T
(and not one of its bases).
Indeed, the stated requirements are even perhaps too strict for our purposes. We are willing to fly fast and loose here, so our final similar_to
concept also ignores any cv-ref qualifications on the types it checks. This makes it easier to use the concept with decltype
, which in a function will faithfully report the cv-ref qualifications of an argument type even if those break our concepts.
We check the validity of our concept by making sure that a float
is similar to a float
, and that a const char *
is not.
Recalling our earlier summary, we say that an endpoint has a value if it has a member variable value
or a member function value()
whose return type the endpoint can be treated as an instance of. We additionally require that persistent values be default initializable, e.g. so that binding authors do not have to fuss over passing such values any constructor arguments.
An endpoint is considered to be an occasional value if it has pointer semantics and can be initialized by an instance of its value type, such as a std::optional
. We also rely on a definition that we will examine later when we define concepts for our bang
type.
Note that occasional values are not values in the sense defined above. They do not behave as though they are instances of their value type, since their current state is meant to be checked before accessing their values.
Our bang
concept essentially just checks that the type has an enum value called bang
in its scope, and that it is a flag, a concept we also required for our OccasionalValue
concept.
The Flag
concept represents a value that is only occasionally updated and needs to signal that it has been updated. Components that expect such values can act depending on whether they have been updated in a given runtime tick, and the runtime is expected to clear the flags at the end of each tick.
We initially required that we could convert a flag type to bool, and that the boolean interpretation of a default constructed value of the type is false
. This is true of std::optional
, bool
, and also pointer types. We assumed that by assigning a value of this type to a default-constructed value that we could set its boolean interpretation to false
.
This turned out to be overly simple, as detailed in the design of the occasional
template in sygah-endpoints: Endpoints Helpers. It becomes a burden for the design of the endpoint helpers to have to propagate behavior through constructors, and conversion to bool also becomes ambiguous in cases where an endpoint should have value semantics with an underlying value type that can be implicitly converted to bool. We added a concept UpdatedFlag
that reflects the API of our current occasional
endpoint helper.
For a flag to be considered Clearable
, we require that it model OccasionalValue
or Bang
; these are presently the only supported types with event-like semantics, and that should therefore be cleared by the platform. The ClearableFlag
mechanism support binding authors in detecting and serving this requirement. A client using e.g. the bng
endpoint helper may prefer to use a different, perhaps more expressive, method of clearing its state. But for a binding author, it's useful to have a way to clear a flag without having to know anything about it other than that it is a valid flag.
When generically clearing a flag, we first check if it is an UpdatedFlag
; this is important, since the UpdatedFlag
concept is not mutually exclusive with the BoolishFlag
concept (an UpdatedFlag
may also be a BoolishFlag
e.g. if the type has value semantics with an underlying value type that can be converted to bool), but has a very different API for being cleared. Similarly for checking the state of the flag, and for setting it.
In case the accepted implementations of the above value types even changes, we encourage binding authors to access values using the following generic subroutines. value_of
returns an appropriately const
qualified reference to the value of an endpoint, allowing the binding author to read and write the value. To allow setting of OccasionalValue
s that don't already have a value, set_value
is provided, which handles OccasionalValue
s appropriately and is also usable with PersistentValue
s.
We define a new concept Value
as the union of occasional and persistent values. The value_of
subroutine itself is reasonably straightforward given the above concepts. The only thing that might be surprising is that the const
version of the function is implemented in terms of the non-const
version. This is considered a reasonable and idiomatic way of avoiding repeating ourselves, but we should remain suspicious of this function in case our assumptions about const-correctness ever seem to be violated. This is unlikely to ever be an issue though.
The set_value
function requires a special case to handle setting an array from a single value; this is required e.g. to be able to initialize an array uniformly from a single initial value. The means of catching this case relies on the assumption that array values have a fill
function such as is provided by std::array
.
As well as numerical values, we also have helpers for string values and single-dimensional arrays of values. We provide a basic concept to help identify them. We consider a value to be string_like
if we can assign to it from a string literal. We consider a value to be array_like
if the endpoint's value is subscriptable, it has a (presumed _consteval
) static method size()
that returns a std::size_t
. Dynamically sized arrays are not currently supported.
We can introduce arbitrary symbols into the scope of a class through a bare enum
declaration e.g. enum { bang };
. We provide accessors for checking if a symbol with a known meaning is defined in a class's scope.
As seen above, some endopints have a range that includes their minimum, maximum, and initial values. The following function provides a generic way to set an endpoint to its initial value.