# Simple Music Notation Processor ## What is it? SMNP is a command line tool aimed on doing music stuff using custom domain-specific language. You are able to create music (both monophonic and polyphonic) using simple notation and then synthesize it with custom settings, plot the signal, evaluate DFT on it and so on. Music tools can be used not only to create music but also to prepare kind of scenarios of ear trainings like recognizing intervals etc. Apart of that developed domain-specific language offers you tools known from most popular programming languages like conditional statements, loops, variable mechanism, functions (including defining custom ones) etc. ## For what? You might ask whether such kind of tool including design of custom language isn't over-engineering of ear-training problem. There are a lot of ear-training tools developed even on mobile platforms which can make them more convenient to use because of their portability feature. The reasons for this tool are: * I'm Java developer and I just wanted to get know more Python, which I used to use at the college. And well, I'm starting from the assumption, that te best thing that you can do to learn new programming language is... creating a new one with it. ;-) Besides, I'm kind of interested in technical side of programming languages that I'm using at work, so designing and implementing a custom language from scratch would be a nice experience. * I'm musician also and no one of available tools is suitable for me. I'm church organist and most of my work is based on dialogue between me and priest. He can sing melodies in different keys and it requires me to answer keeping the same key. My tool allows me to create scenarios that can pick one key randomly and play melody waiting for my answer (basing on input from microphone). * As a musician I'm also keen on physic nature of sounds and relations between them. All audio stuff (except hardware layer) implemented to this tool is created from scratch. It means that the tool synthesises sound by itself and makes use of 3rd party library only to send compiled wave to speakers. ## Disclaimers 1. Readability of the code and its structure is one of most important things related to educational aspect of the project. And in spite of having huge negative impact on efficiency of the tool, according to one of the assumptions it has much higher priority. So don't be surprised if the tool turns out to be extremely slow or ineffective. 2. I try writing consistent code and sticking with some convention of programming of course, however, as a Java developer I like Java guidelines and I really don't like PEP8 (especially snakecased identifiers). Just don't be surprised if you see Java-like code - it has been intentionally written. ## SMNP language As mentioned before, SMNP introduces new language. All language evaluation engine (tokenizer, parser and evaluator) is implemented without any 3rd party libraries and frameworks, just using vanilla Python. ### About language SMNP language is interpreted, high-level, formal language with syntax mostly inspired by Java language and - according to Chomsky's hierarchy - *context-free* grammar. #### Type system SMNP is dynamic language, because there is no any static analyser between parsing and evaluating stages. It means that any type-mismatching will be raised on runtime, when control flow reacheas the point of error. For example: ``` function foo(integer n) { println(n); } x = false; if (x) { foo("hello"); } ``` As long as `x == false`, the code will be executed without any errors, even though `foo` function expects `integer` argument and is being called with `string`. However if you switch `x` value to `true`, error will be raised when control flow reaches `foo` function invocation with wrong argument's type. Even though there is no real definition of *strongly-typed language*, we can say SMNP is strongly-typed, because there are no any implicit type conversions. You always have to provide correct type, even for string concatenation which accepts only `string` values: ``` # Incorrect "My number is: " + 14; # Correct "My number is: " + 14.toString(); ``` ### Comments SMNP language allows you to make comments in the code. It can be done with `#` character, like: ``` # This is is a comment ``` There is no syntax for multiline comment, but you can of course do something like this: ``` # This is # a multiline # comment ``` Note that because of hash-beginning comments you can put a *shebang* at the very first of your code making it more convenient to execute: ``` #!/usr/bin/smnp println("Hello, world!"); # And now add executable flag (chmod +x) # to the file and execute it like any other # script/program from the shell ``` ### Delimiter SMNP language doesn't require you to delimit instructions, however it is still possible and highly recommended, because it helps you to get rid of code ambiguity. Example: ``` size = [1, 2, 3].size (size - 1) as i ^ print(i) ``` Execution of this code is interrupted with error, because SMNP parser tries to interpret `size` property as method `size(size - 1)`. As long as lists don't have `size` method (but they have `size` property), error will be raised and you will be able to fix problem. However ambiguity could be a less obvious and you can stick with debugging code having no idea what is going wrong. To remove ambiguity you can end each instruction with semicolon `;`: ``` size = [1, 2, 3].size; (size - 1) as i ^ print(i); # 01 ``` All code snippets of present document follows the convention of ending each instruction with semicolon. ### Basic types SMNP language introduces 9 data types and *void* which is a *quasi-type* because of it special meaning. Most of the types is known from other popular programming languages, like Java etc. #### integer `integer` is numeric data type and is suitable to signed integers, like -6, 0, 1, 14, 23164 etc. Example: ``` a = 1; b = -15; sum = a + b; # Produces also an integer println(sum); # -14 ``` ###### Methods: * `toString()` [string] - returns a string representation of integer: `14.toString() == "14"` ###### Unary operators: * `-` - negates value ###### Binary operators: * `+` - sum * `-` - difference * `*` - product * `/` - quotient * `**` - power * `==` - equals * `!=` - not equals * `>` - greater than * `>=` - greater or equals * `<` - less than * `<=` - less or equal #### float `float` is numeric data type suitable for non-integer values, like: -3.4, 0.01, 3.14, 12.0043 etc. Example: ``` pi = 3.14; r = 12.5; area = pi*r**2; println(area); # 490.625 ``` ###### Methods: * `toString()` [string] - returns a string representation of float: `1.4.toString() == "1.4"` ###### Unary operators: see `integer` ###### Binary operators: see `integer` #### string `string` type is suitable to any sort of texts enabling you to create string of any characters, including Unicode. For now, strings can be delimited only with double quote (`"`). Example: ``` text = "Hello, world!"; println(text); # Hello, world! println(-text); # !dlrow ,olleH ``` ###### Properties * `length` [integer] - length of string: `"hello".length == 5` ###### Methods * `join(list l)` [string] - join all elements of list `l` with given string as delimiter: `":".join(["1", "2", "3", "4"]) == "1:2:3:4"` * `toString()` [string] - returns itself ;-) ###### Unary operators * `-` - reverse string: `-"Hey!" == "!yeH"` (why not? ;-)) ###### Binary operators * `+` - concatenate strings: `"he" + "llo" == "hello"` * `==` - equals * `!=` - not equals #### bool `bool` data type allows you to perform basic Boolean logic operations introducing two constant values: `true` and `false`. Example: ``` _2b = false; println(_2b or not _2b); # true ``` ###### Methods: * `toString()` [string] - returns a string representation of bool: `true.toString() == "true"` ###### Unary operators * `not` - negate value: `not false == true` ###### Binary operators * `and` - logical conjunction: `true and false == false` * `or` - logical alternative: `true or false == true` * `==` - equals * `!=` - not equals #### note `note` is basic data type allowing you to compose music sheets. It is a base music unit that represents a sound, i.e. its pitch and duration. Note literal is written with the following syntax: ``` '@' PITCH [OCTAVE] [:DURATION [d]] ; where PITCH := (c|d|e|f|g|a) [b|#] | h# | b | (C|D|E|F|G|A) [b|#] | H# | B OCTAVE := 1-9 DURATION := /non-negative integer/ ``` Duration number means the denominator (`n`) of fraction `1/n`, i.e.: * `1` stands for whole note * `2` stands for half note * `4` stands for quarter note * `8` stands for eighth note * `16` stands for sixteenth note * and so on You can also put a `d` character after duration number to add a ***d**ot* to note, like: * `2d` stands for half note and quarter note (dotted half note) * `4d` stands for quarter note and eighth note (dotted quarter note) * `16d` stands for sixteenth note and thirty-second note (dotted sixteenth note) * and so on Default octave is `4` (1 Line) and default duration is `4` (quarter note). Examples (note that pitch is case-insensitive): * `@c` is quarter note with pitch *c'* * `@F5:2` is half note with pitch *f''* * `@g#3:4d` is dotted quarter note with pitch *g♯* * `@Ab6:16` is sixteenth note with pitch *a♭'''* * `@b2:1` is whole note with pitch *B* (*H♭*) * `@C#1:32d` is dotted thirty-second note with pitch *C♯,* **Note:** note literal syntax cannot include any whitespace character. ###### Properties * `pitch` [string] - pitch of note: `@d#5:2d.pitch == "DIS"` * `octave` [integer] - octave of note: `@d#5:2d.octave == 5` * `duration` [integer] - duration of note: `@d#5:2d.duration == 2` * `dot` [bool] - does note is dotted: `@d#5:2d.dot == true` ###### Methods * `withOctave(integer octave)` [note] - factory method that copies note with new `octave` value: `@c.withOctave(5) == @c5` * `withDuration(integer duration)` [note] - factory method that copies note with new `duration` value: `@c.withDuration(2) == @c:2` * `withDot(bool dot)` [note] - factory method that copies note with new `dot` value: `@c.withDot(true) == @c:4d` * `toIntRepr()` [integer] - convert note's pitch and octave to unique integer value * `transpose(integer semitones)` [note] - copy note and transpose it with given number of semitones: `@c.transpose(2) == @d` * `toString()` [string] - returns a string representation of note: `@g#.toString() == "G#"` ###### Binary operators * `==` - equals * `!=` - not equals #### sound `sound` is a wrapper for external music file, like `ogg`, `mp3` etc. This is the only type that isn't possible to create syntactically. Instead of that it can be instantiated with constructor-like function, as at the example below: ``` myMusic = Sound("Music/Piano/Chopin/NocturneOp9No2.ogg"); myMusic.play(); ``` ###### Properties * `file` [string] - a sound source file * `fs` [integer] - sampling rate ###### Methods * `play()` [void] - play a loaded sound file * `toString()` [string] - returns a string representation of sound: `Sound("/../../music.ogg").toString == "/../../music.ogg"` ###### Binary operators * `==` - equals * `!=` - not equals #### list `list` is an ordered container able to store objects with different types. List is created within square brackets (`[` and `]`) with items separated by comma (`,`). Lists can be nested, which means they can contain another lists that can contain yet another lists and so on. Example: ``` myList = [1, "hello", @Ab:2d, true, 14.0, [ "even", "other", list!"], [], {}]; println(myList.size); # 9 println([14].size); # 1 println([].size); # 0 println(myList.contains(1)); # true println(myList.contains(2)); # false ``` ###### Properties * `size` [integer] - a number of elements in list ###### Methods * `get(integer index`) [?] - returns item of list with given index (**note:** indices start from **0**) * `contains(element)` [bool] - test if list does contain given element * `toString()` [string] - returns a string representation of list: `[1, 2].toString() == "[1, 2]` ###### Unary operators * `-` - reverse lists (just because!): `-[1, 2, 3, 4] == [4, 3, 2, 1]` ###### Binary operators * `+` - join lists: `[1, 2] + [3, 4] == [1, 2, 3, 4]` * `==` - equals * `!=` - not equals #### map `map` is unordered container able to store pairs `key-value` with different types of both key and value. Syntactically map is a set of pairs `key-value` separated with comma (`,`) and placed between braces (`{` and `}`). Single `key-value` pair is created from two items separated with arrow operator `->`. Similarly to lists, maps can also be nested. Keys of course must be unique for single map object. Even though value of pair can be arbitrary expression, key should be explicit literal, like integer, note, bool value, type and string. Lists and other maps can't be used as keys. Note that if string key doesn't have any whitespaces, there is no need to use quotes around it. Example: ``` myMap = { 1 -> "hello", @c -> "world", true -> false, "hey" -> 14, hey2 -> "key without quotes!", empty -> {}, theList -> [1, 2, [], { inside -> ":-)" }] }; println(myMap.size); # 4 println(myMap.get(@c)); # world println(myMap.get("hey")); # 14 println(myMap.get("hey2")); # key without quotes! ``` ###### Properties * `size` [integer] - number of map entries (pairs) * `keys` [list] - list of keys: `{ a -> 1, b -> 2 }.keys == ["a", "b"]` * `values` [list] - list of values: `{ a -> 1, b -> 2}.values == [1, 2]` ###### Methods * `containsKey(key)` [bool] - test if map does contain pair with given key * `containsValue(value)` [bool] - test if map does contain pair with given value * `contains(key, value)` [bool] - test if map does contain pair with given key **and** given value * `toString()` [string] - returns a string representation of map: `{ a -> 1, b -> 2}.toString() == "{'a' -> '1', 'b' -> '2'}"` ###### Binary operators * `+` - join maps: `{ a -> 1 } + { b -> 2 } == { a -> 1, b -> 2 }` * `==` - equals * `!=` - not equals #### type `type` represents all available data types including itself. It is mostly used to get meta information about other values in code. Example: ``` myNumber = 14; println(typeOf(myNumber) == integer); # true println(typeOf(myNumber) == bool); # false println(typeOf(myNumber) == note); # false println(typeOf(typeOf(myNumber)) == type); # true println(typeOf(type)); # type ``` ###### Methods * `toString()` [string] - returns a string representation of type: `integer.toString() == "integer"` ###### Binary operators * `==` - equals * `!=` - not equals #### void `void` is special data type introduced to distinguish functions returning a value from functions that don't return anything. It can't be used in any way and the only possibility to do something with that is getting the type of it or getting a string representation of it. Example: ``` void; # It actually does nothing println(void.toString()); # void println(typeOf(void)); # type ``` ### Variables SMNP language's variable mechanism allows you to store some data in memory which could be used in the later stages of code. Even though SMNP language has a data types, declaring new variables doesn't require you to explicitly put a type before declaration as in the case of most popular languages, like Java etc. Hence it is not possible to declare variable without initialization. You always have to initialize your variable when you are declaring it. Initialization can be done with assignment operator (`=`). The right hand side should be expression (value, function call or even other variable) but the left hand side can be only variable identifier. ``` myVar; # It is not a declaration myVar = 2; # It is a declaration. From now you are able to use variable myVar 3 = 2; # error! var2 = foo(); # Correct var2 = myVar; # Also correct ``` Variables are nothing but references to objects (values) and their don't have any type. In other words you are able to assign value of different type to already initialized variable. ``` myVar = 2; println(myVar); # 2 myVar = "Hello, world!"; println(myVar); # Hello, world! ``` Because assignment is an expression which returns a value being assigned it is possible to perform multiple initialization. ``` a = b = c = 10; # It is like a = (b = (c = 10)) println(a == b and b == c); # true ``` SMNP language doesn't have any equivalent of Python's `del` instructions, so you aren't able to delete already created variable. The only way is using scopes. #### Scopes Scopes are strongly related to blocks. Block is a set of statements and expressions bounded on both sides with `{` and respectively `}`. Even though it is not required and you can write whole code in one line, it is highly recommend to use any kind of indentations for each block, including blocks nested inside. ``` println("I'm outside any block"); { println("I'm in the first-level block"); { println("I'm in second-level block"); } println("Greetings from first-level block again"); } ``` In context of variable mechanism it is important to know that variables declared inside block are available to each instructions following declaration **in the same block** and for each nested block placed after variable declaration. ``` var1 = "top-level"; { var2 = "first-level"; println(var1); # "top-level" println(var3); # error! { var3 = "second-level"; println(var1); # "top-level" println(var2); # "first-level" println(var3); # "second-level" } println(var2); # "first-level" println(var3); # error! } println(var2); # error! println(var1); # "top-level" ``` #### Identifiers It is also important to know what is identifier in context of SMNP language. Identifier is a string that contains only letters (both lowercase and uppercase), numbers and `_` character. Identifier must not start with number and must not be any of reserved words (keywords). List of keywords: * `and` * `or` * `not` * `integer` * `string` * `float` * `note` * `bool` * `type` * `list` * `map` * `function` * `return` * `extend` * `import` * `throw` * `from` * `with` * `if` * `else` * `as` ``` # Valid identifiers i var myVar my_var var20 x_YZ _vArIaBlE _if _return returns # Invalid identifiers 19i foo[ bar@ return !@#%$ if as ``` #### Immutability It's a good place to say that all values in SMNP code are **immutable**. That means you are not able to e.g. change pitch of existing note, change arbitrary letter of string, put object to list and so on. You are able to produce note basing on given one with new pitch or join two or more lists but these operations produces new objects and leave values unmodified. ``` a = [1, 2]; b = [3, 4]; c = a + b; println(a == [1, 2]); # true println(b == [3, 4]); # true println(c == [1, 2, 3, 4]); # true a = @c; b = a.withDuration(2); println(a == @c); # true println(b == @c:2); # true ``` ### Operators precedence SMNLP language's operators have their unique priorities which determine operations' order in case of ambiguity. | Operator(s) | Precedence | |----------------:|:----------:| | `-` (unary) | 1 | | `.` | 2 | | `**` | 3 | | `not` | 4 | | `*` `/` | 5 | | `+` `-` (binary)| 6 | | `==` `!=` | | | `<=` `>=` | 7 | | `<` `>` | | | `and` | 8 | | `or` | 9 | | `^` | 10 | Remember, that in spite of operators precedence you can always force priority using parentheses: ``` a = -2+2; b = -(2+2); println(a); # 0 println(b); # -4 ``` ### Methods and functions Function is a code snippet that takes some arguments and produces result. Both arguments and result can be optionals. Method in turn is a special function that is called in behalf of some object. Function (and method of course) can be invoked using its name and optional arguments separated with commas (`,`) bounded on both sides with parentheses (`(` and `)`). Even if no arguments are passed you have to put empty arguments list: `()`. Some functions are available out-of-the-box as they are implemented to SMNP tool. Set of such functions is called *standard library* (you can read more about standard library in later section). You are able also to create custom functions and methods (it'll be covered in next section). As you can see on below examples implemented functions and methods mechanism is really similar to other popular languages, like Java or Python. Examples: ``` # Invoking function 'println' without any arguments println(); # Invoking function 'println' with 1 argument println("Hello, world!"); # Hello, world # Invoking function 'println' with 2 argument println(1, 2); # 12 # Invoking function 'println' with 2 argument # Invoked function produces also result which # is being assigned to variable 'newNote1' newNote1 = transpose(2, @c); # Method equivalent of function above newNote2 = @c.transpose(2); # Another example of method firstElementOfList = [14, 3, 20, -4].get(0); println(newNote1 == newNote2); # true ``` Note, that some functions or methods don't return anything. In this case expecting any returned value can throw an exception. ``` # 'println' is example of function, that doesn't return anything # in this case following instruction will raise an error println(println()); # error # other examples x = println(); # error println([1, 2, 3, println(), 5, 6]); # error ``` #### Function/method signature Signature is feature of both functions and methods that makes them unique. Signature consists of function/method name, function/method arguments and applicable type in case of method. It is a good place to say, that methods technically are just simple functions with one additional argument placed at the beginning of arguments list. Because of that we can say that signature consists only of name and arguments list. This is also the reason of a little bit meaningless errors that you can get when you are trying to call method with invalid arguments' types: ``` # correct x = [1, 2, 3].get(0); # invalid x = [1, 2, 3].get(@c); ``` in this case you'll get following error (note first argument type of expected signatures): ``` Invocation Error (...) Expected signature: get(list, integer) or get(map, ) Found: get(list, note) ``` ### Custom methods and functions SMNP language introduces possibility to define custom functions and methods. They can be invoked later in the same way as regular functions/methods provided by SMNP standard library. Functions and methods can be defined only at top level of the code, which means they cannot be nested in any blocks. #### Function definition Functions can be defined with `function` keyword, like it is shown on following example: ``` function multipleBy2(number) { return 2*number; } # Correct invocations x = multipleBy2(2); # x = 4 x = multipleBy2(14); # x = 28 x = multipleBy2("hey"); # in spite of correctness it will cause an error # because string argument doesn't support '*' operator # Incorrect invocations x = multipleBy2(1, 2); # it'll cause Invocation Error because of signatures mismatch x = multipleBy2(); # as above ``` Thanks to `return` keyword you can produce an output value basing on e.g. passed arguments. Example above takes one arbitrary argument, multiplies it by 2 and then returns a result. ##### Explicit argument types Because arbitrary argument can be passed, the `multipleBy2` function can throw an error in case of trying to multiple e.g. note or string, that don't support `*` operator. In this case you can put a constraint to the argument which accepts only chosen types: ``` function multipleBy2(integer number) { return 2*number; } # Correct invocations x = multipleBy2(2); # x = 4 x = multipleBy2(14); # x = 28 # Incorrect invocations x = multipleBy2(1, 2); # it'll cause Invocation Error because of signatures mismatch x = multipleBy2(); # as above x = multipleBy2("hey"); # as above ``` Function `multipleBy2` will work only with integer argument now. Any attempt to invoke it with no-integer values will cause Invocation Error related to mismatched signatures. Of course you are still able to create functions without any arguments or mix their types: ``` function noArgs() { println("Hello, I don't accept any arguments."); println("Also see that I don't return anything!"); } function mixedArguments(integer a1, note a2, a3) { println("See, " + a1.toString() + " is an integer!"); println("And " + a2.toString() + " is a note."); println("Type of third argument is " + typeOf(a3).toString()); } # Correct invocations mixedArgument(1, @c, "hey"); mixedArguments(14, @Gb:4d, true); # Incorrect invocations mixedArgument(@c, @c, @c); mixedArgument(1, 1, 1); ``` ##### Functions returning nothing Notice also that functions presented in previous example don'tactually return anything. Technically they **do** return a `void` type and it is transparent to SMNP user. You should remember, that you can expect value produced by function only and only if `return` statement is declared in invoked function and the statement **has been called**. Otherwise function being invoked will return `void` type which cannot act as expression. ##### Ambiguous arguments SMNP language allows you also to declare more than one argument that functions can accept. Accepted types are separated with commas (`,`) and are bounded on both sides with `<` and `>` characters. Example: ``` function foo( x, sound y, z) { # 'x' can be either string, bool, integer or float # whereas 'y' can be only a sound # and 'z' can be of any available type } # Correct invocations foo("hey", Sound("..."), [1, 2, true, [@c], { a -> 1, b -> 2 }]); foo(true, Sound("..."), integer); foo(14, Sound("..."), @c#:16d); foo(1.4, Sound("..."), 3.14); # Incorrect invocations foo(integer, Sound("..."), 10); foo("hey", 20, 10); foo([1, 2, 3], Sound("..."), 10); ``` ##### Specified lists SMNP language allows you to specify what objects can be contained in lists. It can be done with construction similar to previous one. Accepted types are separated with commas (`,`), bounded on both sides with `<` and `>` characters and placed next to `list` keyword: ``` function foo(list x) { # 'x' can be only list of integers } # Correct invocations foo([1, -2, 3]); foo([0]); foo([]); # Incorrect invocations foo(1); foo(2, 3); foo([ 1, 2, @c ]); ``` You are still able to use multiple type as constraints: ``` function foo(list x) { # 'x' can be only list of integers and notes, nothing else } # Correct invocations foo([1, @c, 4]); foo([]); foo([0]); foo([@d#]); # Incorrect invocations foo([1, @c, true]); foo(@c, 1); foo({ @c -> 1 }); ``` List specifier can be empty or even be absent. In this case list can contain any available types: ``` function foo(list<> x) { # 'x' can be only a list containing arbitrary objects } # the same function can be implemented as function foo(list x) { # ... } # Correct invocations foo([1, @c, true]); foo([]); foo([integer, float, @g#:32d, false, [[[]]], { a -> 1, @d -> 2, true -> {} }]); # Incorrect invocations foo({}); foo([1, @c, true], 1); foo(true); ``` Specifiers can be nested - you are able to create e.g. list of lists of lists of integers etc. ``` function foo(list>> x) { } # Correct invocations foo([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]); # Incorrect invocations foo([[[1, 2], [3, 4]], [[5, 6], [7, 8]], 9]); ``` Of course ambiguous arguments can include specified lists: ``` function foo( x) { # 'x' can be integer, note or list containing only integers and notes } # Correct invocations foo(@c); foo(1); foo([]); foo([2, @G]); foo([@h]); # Incorrect invocations foo(1.0); foo([true]); ``` ##### Specified maps Maps can be also specified. All rules related to specified lists are also applicable for maps. The only difference is map supports **2** specifiers: first one for keys and second one for values. Example: ``` function foo(map x) { # 'x' can be only a map with strings as keys and notes as values } function bar(map<> x) { # 'x' can be only a map with strings as keys and arbitrary values } function xyz(map x) { # 'x' can be only a map with strings and booleans as keys and integers and notes as values } function abc(map<> x) { # 'x' can be only a map with arbitrary keys and integers with booleans as values } # Correct invocations foo({ c -> @c, d -> @d }); bar({ a -> @c, b -> 1, c -> true, d -> map, e -> { x -> [] }); xyz({ a -> 1, true -> @c, false -> 2, b -> @d }); abc({ a -> true, 1 -> false, @c -> 10, true -> 14 }); # Incorrect invocations foo({ c -> @c, @d -> @d, @c -> "hey" }); bar({ a -> @c, @d -> @c }); xyz({ integer -> @c, 2 -> 3, float -> bool }); abc({ a -> true, false -> @c }); ``` ##### Optional arguments SMNP language allows you also to use default arguments. Optional arguments are special kind of arguments that can have default value and are totally optional to pass during invocation. Syntax of optional arguments is similar to variable assignment syntax. Example: ``` function foo(x = 10) { # 'x' can be of any available type. # You can override default value but you don't have to. # In this case default value will be used. return x; } # Correct invocations y = foo(); # y = 10 y = foo(10); # y = 10 y = foo(true); # y = true ``` All kinds of arguments mentioned so far can be optional: ``` function foo(x = 1, integer y = 14, > z = [[1, @c], [@d]]) { } # Correct invocations foo(); foo(2); foo(10, 11); foo(-2, 33, @c); foo(-2, 33, [[1, @d, @f#], [4, 3], [@c, @d, @e]); foo(0, 0, []); foo(0, 0, [[]]); # Incorrect invocations foo(true); foo(1, 0.5); foo(10, 2, 13); foo(0, 0, [], 3); ``` As you can see, you can have as many optional arguments as you want. Just remember, that after first declared optional argument you can't use regular arguments in the same signature anymore. ``` # Correct signatures function foo(a, b, c = 0) {} function bar(a, b = "hello", c = true) {} function xyz(a = integer, b = [12.5], c = @c) {} # Incorrect signatures (will throw an error) function abc(a = 0, b) {} function def(a, b, c = 0, d = true, e, f = @G#) {} ``` In other words, optional arguments should be declared at the tail of function signature. ##### Varargs SMNP language also supports functions' signatures with arbitrary length. This is exactly how `println` or `print` functions work. You can pass as many arguments as you want and they still are able to handle it. SMNP language provides a special operator `...` that enables you to have function with variable number of arguments. Remember that: * you can have only one vararg at the signature * vararg should be placed at the very end of signature * you can't mix varargs with optional arguments in one signature. After invocation, all matched arguments are collected into one list which is passed as vararg. Example: ``` function foo(a, b, c...) { # 'c' is a list of arguments passed after 'a' and 'b' return c; } # Correct invocations: x = foo(0, 1); # x = [] x = foo(1, 2, 3, 4); # x = [3, 4] x = foo(true, false, @c, [3.14, 5, integer], float); # x = [@c, [3.14, 5, integer], float] # Incorrect invocations: foo(); foo(true); ``` Same as optional arguments, vararg can work with any previously mentioned kinds of arguments (except optional arguments of course). ``` function foo(a, note b, map> c...) { # after two arguments you can pass any number of # maps with strings as keys and list of integers and notes as values } # Correct invocations: foo(1, @c, { a -> [@d], b -> [@c, @g] }, { a -> [], b -> [@e] }); foo(1, @c, {}, {}, {}, {}, {}, {}, {}, {}); # Incorrect invocations: foo(); foo(1, @c, { a -> [@d], b -> [@c, @g] }, { a -> [], b -> @e }); ``` #### Method definition All rules related to defining custom functions are applicable to methods either. The difference is method can be invoked in a context of some object. That's why methods can be defined only as a part of some type. ##### `extend` statement SMNP language introduces `extend` statement which allows you to add custom methods to existing types. All you need is to precise desired type and define function which will be working as new method. Following listing presents a syntax of `extend` statement: ``` extend integer as i with function multipleBy(integer x) { return i*x; } # From now you can use new method as follows: x = 10.multipleBy(2); # x = 20 y = x.multipleBy(4); # y = 80 ``` As mentioned before, all rules related to functions are applicable here, so there is no need to cover `function multipleBy(...) {...}` statement again. Code from listing above defines new method of `integer` type. Note, that thanks to `integer as i` instruction, object that holds the context of method is available through variable with name `i`. This works similar to arguments passed to function and actually there are no differences between using them in function's body. Even though type actually works as additional argument passed to function, there is a slight difference between them. As mentioned before, you can use such features in function signature as optional arguments, ambiguous arguments, varargs etc. For obvious reasons these are not available for declaring type. Type being extended must be declared only as a simple data type or map/list with optional specifier. Also you have always provide an identifier by which you can refer to the type (`as` part). Examples: ``` # Correct extend integer as x with function a() {} extend list as x with function b() {} extend list<> as x with function c() {} extend list> as x with function d() {} extend map as x with function e() {} extend map<><> as x with function f() {} extend map<>>>> as x with function g() {} # Incorrect extend as x with function h() {} extend as x with function i() {} extend <> as x with function j() {} extend integer with function k() {} ``` Because extending statement supports list/map specifiers, you can define methods that are applicable only for list/map with specified content: ``` # Extends all lists, no matter of content extend list as l with function foo() { println("foo()"); } # Extends only list extend list as l with function bar() { println("bar()"); } l1 = [1, 2, 3, 4]; l2 = [1, 2, @c, 4]; l3 = ["a", "b", "c", "d"]; l1.foo(); # foo() l2.foo(); # foo() l3.foo(); # foo() l1.bar(); # bar() l2.bar(); # error! l3.bar(); # error! ``` Because SMNP doesn't allow you to extend multiple types in one instruction, if you want extend for example `list` and `list` (but not `list`) you have to do it separately: ``` # This won't work at all extend , list> as l with function foo() { # ... } # Instead of that you can do: extend list as l with function foo() { # ... } extend list as l with function foo() { # ... } ``` Note that there is no signature collision problem, because technically first argument is different in both methods. ##### `extend` block In case of extending type with more than one function you can use block of methods. Example: ``` extend integer { function multipleBy2() { return 2*i; } function multipleBy4() { return 4*i; } } # Construction above is equivalent of following code: extend integer as i with function multipleBy2() { return 2*i; } extend integer as i with function multipleBy4() { return 4*i; } ``` #### Overloading function/methods Unfortunately functions/methods overloading is not supported in custom functions/methods so far. However you can notice that some functions and methods coming from SMNP standard library core are overloaded, like `get()` method, some constructor-like methods, `wait()` function and so on. That's because these functions are implemented right in Python and there still lacks function/method overloading mechanism directly in SMNP language. However you can still use ambiguous arguments and just check in function's body their types by hand using `typeOf()` function: ``` function myFunction( arg) { if (typeOf(arg) == integer) { println("You have passed integer"); } else { println("You have passed note!"); } } myFunction(1); # You have passed integer myFunction(@c); # You have passed note myFunction("hey"); # error! ``` ### Imports SMNP language allows you to split code into multiple files and then join them together using `import` statement. The statement loads file, executes its code and appends newly created context into main file. It means that all imperative code (code that produces immediately result, like function calls) will be executed just once, whereas declarative part (code that appends something to context, like variable assignments or function definitions) will be appended to main context, so that you will be able to take advantage of defined functions and variables. Example: ``` #################### file_a.mus #################### function foo() { println("Hello, world!"); } var = 14; println("I'm a file_a.mus!"); ``` ``` ##################### main.mus ##################### import "file_a.mus"; # I'am a file_a.mus! foo(); # Hello, world! println(var); # 14 ``` It is important to know, that `import` is just simple statement that loads external file and executes its code. Thus there is no any protection from some known dependency problems, like circular dependencies or repeatedly imported files. ### `if` and `if-else` statement SMNP language has support for good old condition statement and syntax is inspired by Java language. #### `if` statement The simplest form consists only of `if` keyword followed by condition bounded on both sides with parentheses (`(` and `)`) and statement (or block of code): ``` if (2 > 3) println("It gonna never be displayed!"); # or (using block of code) if (2 > 3) { println("It gonna never be displayed!"); } ``` Just like in Java condition **must be** of `bool` type. ``` # Correct examples if (true) println(); if (false) println(); if (not false) println(); if (0 == 0) println(); if ("hello".length > 0) println(); if (true and not true or not false and true) println(); if (true and (not true or not false) and true) println(); # Incorrect examples if () println(); if (0) println(); if (1) println(); if ("hello") println(); if (true.toString()) println(); ``` #### `if-else` statement You can use additional `else` clause which is always executed when condition is resolved to `false`. Example: ``` if (2 > 3) println("It gonna never be displayed!"); else println("but this always will be displayed"); # or you can use blocks of code to increase readability if (2 > 3) { println("It gonna never be displayed!"); } else { println("but this always will be displayed"); } ``` Remember that only one condition statement branch (`if` or `else`) can be executed in single statement. There is no possibility to execute both clauses: ``` x = 1; if (x > 0) { println(true); } else { println(false); } if (x < 0) { println(false); } else { println(true); } ``` Above example will print twice `true`. First statement's condition is met, so `if` clause did execute, but second statement's condition is resolved to false so `else` clause did execute. #### `if-else-if-else-if-...` statement Above constructions are sufficient to create `if-else-if-...` pseudo-construction. Thanks to it you are able to put additional execution branches to existing condition statement. *Pseudo* in this case means that there is no special parsing rules nor evaluators related to this construction. It is just a combination of `if` and `if-else` statements presented above. Example: ``` x = 3; if (x == 0) { println("x is zero"); } else if (x == 1) { println("x is one"); } else if (x == 2) { println("x is two"); } else if (x == 3) { println("x is three"); } else { println("x is neither zero, one, two nor three"); } # technically this construction is: if (x == 0) { println("x is zero"); } else { if (x == 1) { println("x is one"); } else { if (x == 2) { println("x is two"); } else { if (x == 3) { println("x is three"); } else { println("x is neither zero, one, two nor three"); } } } } ``` Thanks to its tree-like construction control flow falls through all conditions. A branch first matched branch is executed and following conditions are not checked anymore: ``` x = 0 if (x == 1) { println(); # Condition is resolved to false } else if (x == 0) { println(); # Condition is met } else if (x == 0) { println(); # Condition is also met, } # but it hasn't been even checked because above # branch has been already matched and executed ``` ### Loop statement / expression SMNP language provides a loop statement which also acts as expression. The statement has been totally designed from scratch and is not similar to any known loop statements from other languages. Loop allows you to repeat some instructions with counter set programmatically. All loops are created through loop operator (`^`). #### Simple loop The simplest loop consists only of counter, loop operator and instruction or block of instructions. Example: ``` # the simplest loop 3 ^ print("Money "); # Money Money Money # the same using block of code 3 ^ { print("Money "); } # Output: Money Money Money ``` Of course you can use any expression as counter, like variable or function call: ``` function provideCounter(integer x) { return x * 2; } provideCounter(5) ^ print("a"); # aaaaaaaaaa ``` You can define a counter variable whose value is increased with each iteration using `as` clause (note, that values start from 0): ``` 3 as i ^ print(i, " "); # 0 1 2 ``` #### *while* loop You can create a loop which is controlled by logic condition. It can be done just putting a `bool` value as a counter. Example: ``` end = false; i = 0; not end ^ { if (i == 3) { end = true; } i = i + 1; } println(end); # true println(i); # 4 ``` Note that condition-controlled loop doesn't support `as` clause. #### *For-each* loop This kind of loop works both with lists and maps. ##### List You can put a list as a counter. In this case statement will be executed for each element of list. You can obtain an access to element through `as` clause like in previous examples. Example: ``` x = [@c, @d, @e]; x ^ print("Money "); # Money Money Money x as n ^ print(n, " "); # C D E ``` You can put additional parameter into `as` clause. In this case the first parameter will be just iterator that is increased after each loop iteration. The second parameter will be value of passed list. If you are using more than one parameter in `as` clause, you have to enclose them between parentheses. Example: ``` [@c, @d, @e] as (i, n) ^ println(i, ". ", n); # Output: # 0. C # 1. D # 2. E ``` ##### Map Map is also supported to act as an counter of loop. In this case statement will be executed for each value of passed map. Example: ``` x = { a -> @c, b -> @d, c -> @e }; x ^ print("Money "); # Money Money Money ``` You are also able to access keys and values of iterated map using parameters in `as` clause. If you pass only one parameter, it will contain a value of map. If you pass two parameters, the first one will contain a key and the second one respectively a value. If you pass three parameters, the first one will be an iterator increased by one with all iteration, the second one will be key and the third one will be value. Example: ``` myMap = { first -> true, second -> [@c, @d, @e], third -> 14 }; myMap as value ^ println(value); # Output: # true # [C, D, E] # 14 myMap as (key, value) ^ println(key, ": ", value); # Output: # first: true, # second: [C, D, E] # third: 14 myMap as (i, key, value) ^ println(i, ". ", key, ": ", value); # Output: # 0. first: true, # 1. second: [C, D, E] # 2. third: 14 ``` #### Filters Loops in SMNP language also support filtering. The filtering can be done with `%` clause, which contains a condition that must be met to execute iteration. `%` clause is placed at the very last of loop statement. Example: ``` 10 as i ^ println(i) % mod(i, 2) == 0; # Output: # 0 # 2 # 4 # 6 # 8 ``` Filtering can be useful with simple counting loops like above, but it is really powerful with *for-each* loops. You can pass further only elements of list/map that met defined conditions. It is even more useful with special feature of loops that is covered in next section. #### Loop expression Really strength of designed loops relies on the fact, that all kinds of them actually can work as *expressions*. It means that you can use any kind of loop as e.g. function argument, because they can produce a value. They work similar to Python's list comprehension and actually produce a new list. One important condition you have to met in order to use loop as expression is to put expression as a loop's statement. Because of that in loop expression you can't use block of code. The statement must be a single instruction. Example: ``` x = [1, 2, 3, 4]; # 'y' holds numbers multipied by 2 y = x as i ^ i * 2; # y = [2, 4, 6, 8] # 'z' holds squares of even numbers z = x as i ^ i ** 2 % mod(i, 2) == 0; # y = [4.0, 16.0] ``` Note, that loop statement is right associative. That means nested loop statements are being accumulated in right hand branch. Take look at example: ``` 2 ^ 4 ^ 6 ^ print(); # is exactly the same as 2 ^ (4 ^ (6 ^ print())); ``` is parsed to following abstract syntax tree: ``` ^ / \ 2 ^ / \ 4 ^ / \ 6 print("a") ``` It could be a flaw if you would like to create a mapping chain using loop statements (like streams in Java 8 or LINQ in .NET), because it requires from operator to be left associative, so that first instruction would produce value and pass it further. It can be of course achieved with parentheses: ``` data = ["lorem", "ipsum", "dolor", "sit", "amet"]; output = (((((data as d ^ d % d.length > 3) as d ^ d.length) as d ^ d * 2) as d ^ d + 1) as d ^ d % d == 11); println(output); # [11, 11, 11] ``` As you can see it actually works but is not convenient and readable. ### Throwing errors Even though SMNP language doesn't have any sophisticated error handling mechanism, you are able to raise errors using `throw` statement. The `throw` construction is inspired by Java language, however instead of throwing exceptions SMNP language allows you only to throw string values. When control flow meet `throw` statement, program execution is immediately interrupted and Execution Error is raised with message passed to `throw` statement. Example: ``` function divide(integer a, integer b) { if (b == 0) { throw "You are trying to divide by 0!"; } return a / b; } ``` If you try to invoke e.g. `divide(2, 0)` the Execution Error will be thrown with message, that you are trying to divide by 0. SMNP language does not support any equivalent of `try-catch` statements known from other languages, so you are actually unable to implement more advanced multi-layer error handling system, because the language is not aimed on building complex systems and there is simply no need to implement sophisticated error handling system in simple music . ### Standard library SMNP is provided with builtin standard library which consists of some fundamental functions and methods that increase language's functionality. SMNP don't require you to do anything in order to use anything from its standard library. Just put function/method name with proper arguments and that's it. Technically SMNP's standard library (*stdlib*) consists of 2 layers: native library and language library. #### Native library Native library contains functions and methods that are implemented **directly in Python**. Generally this part of *stdlib* introduces fundamental functions and methods that: * enables communication between SMNP and operating system, like: `print`, `read`, `exit` etc. * are related to audio module, like: `wave`, `synth`, `wait`, `play` etc. * are strictly bound to runtime environment, like: `type`, `debug` etc. * make use of Python libraries, like: `rand`, `fft`, `plot` etc. * are constructor-like functions, like: `Integer`, `Map`, `Note` etc. Native functions and methods are located in `smnp/module` module. #### Language library Language library contains functions and methods that are implemented **in SMNP language itself**. Some of SMNP assumptions points to move as many code as possible from native library to language library, even at cost of efficiency. This library contains such functions and methods as: `flat`, `transpose`, `mod`, `tuplet`, `sample`, `random`, `join` and so on. Language library is a single file named `main.mus` and located in `smnp/library/code` module. #### *stdlib* documentation Documentation of both native and language library is included in the `STDLIB.md` file of present repository. ## SMNP language interpreter SMNP language interpreter consists of three parts composed to pipeline: * *tokenizer* (or *lexer*) * *parser* * *evaluator* All of these components participate in processing and executing passed code, producing output that can be consumed by next component. ### Tokenizer Tokenizer is the first component in code processing pipeline. Input code is directly passed to tokenizer which splits it to several pieces called *tokens*. Each token contains of main properties, such as *value* and related *token type* (or *tag), for example: * literal: `"Hello, world!"` is token with value `Hello, world!` and token type `STRING` * literal: `abc123` is token with value `abc123` and token type `IDENTIFIER * etc. Apart from mentioned data, each token also includes some metadata, like location of value in code (both line and column). You can check what tokens are produced for arbitrary input code using `--tokens` flag, for example: ``` $ smnp --tokens --dry-run -c "[1, 2, 3] as i ^ println(\"Current: \" + i.toString());" [Current(0): {OPEN_SQUARE, '[', (0, 0)} {OPEN_SQUARE, '[', (0, 0)}, {INTEGER, '1', (0, 1)}, {COMMA, ',', (0, 2)}, {INTEGER, '2', (0, 4)}, {COMMA, ',', (0, 5)}, {INTEGER, '3', (0, 7)}, {CLOSE_SQUARE, ']', (0, 8)}, {AS, 'as', (0, 10)}, {IDENTIFIER, 'i', (0, 13)}, {CARET, '^', (0, 15)}, {IDENTIFIER, 'println', (0, 17)}, {OPEN_PAREN, '(', (0, 24)}, {STRING, 'Current: ', (0, 25)}, {PLUS, '+', (0, 37)}, {IDENTIFIER, 'i', (0, 39)}, {DOT, '.', (0, 40)}, {IDENTIFIER, 'toString', (0, 41)}, {OPEN_PAREN, '(', (0, 49)}, {CLOSE_PAREN, ')', (0, 50)}, {CLOSE_PAREN, ')', (0, 51)}, {SEMICOLON, ';', (0, 52)}] ``` Tokenizer tries to match input with all available patterns, sticking with rule *first-match*. That means if there is more than one patterns that match input, only first will be applied. This is why you can't for example name your variables or functions/methods with keywords. Take a look at the output of following command: ``` $ smnp --tokens --dry-run -c "function = 14;" [Current(0): {FUNCTION, 'function', (0, 0)} {FUNCTION, 'function', (0, 0)}, {ASSIGN, '=', (0, 9)}, {INTEGER, '14', (0, 11)}, {SEMICOLON, ';', (0, 13)}] Syntax Error [line 1, col 10] Expected function/method name, found '=' ``` The first token has type of `FUNCTION`, not `IDENTIFIER` which is expected for assignment operation. All tokenizer-related code is located in `smnp/token` module. ### Parser LL(1) Parser is the next stage of code processing pipeline. It takes input from tokenizer and tries to compose a tree (called AST, which stands for **a**bstract **s**yntax **t**ree) basing on known rules, which are called *productions*. As long as tokenizer defines language's *alphabet*, i.e. a set of available terminals, parser defines *grammar* of that language. It means that tokenizer can for example detect unknown character or sequence of characters meanwhile parser is able to detect unknown constructions built with known tokens. A good example is last snippet from above section related to tokenizer: ``` $ smnp --tokens --dry-run -c "function = 14;" [Current(0): {FUNCTION, 'function', (0, 0)} {FUNCTION, 'function', (0, 0)}, {ASSIGN, '=', (0, 9)}, {INTEGER, '14', (0, 11)}, {SEMICOLON, ';', (0, 13)}] Syntax Error [line 1, col 10] Expected function/method name, found '=' ``` You can see, that tokenizer has successfully done his job, but parser throw a syntax error saying that it does not know any production that could (directly or indirectly) match `FUNCTION ASSIGN INTEGER SEMICOLON` sequence. You can check AST produced for arbitrary input code using `--ast` flag, for example: ``` $ smnp --ast --dry-run -c "[1, 2, 3] as i ^ println(\"Current: \" + i.toString());" Program (line 0, col 0) └─Loop (line 1, col 1) ├─List (line 1, col 1) │ ├─IntegerLiteral (line 1, col 2) │ │ └'1' │ ├─IntegerLiteral (line 1, col 5) │ │ └'2' │ └─IntegerLiteral (line 1, col 8) │ └'3' ├─Operator (line 1, col 16) │ └'^' ├─FunctionCall (line 1, col 18) │ ├─Identifier (line 1, col 18) │ │ └'println' │ └─ArgumentsList (line 1, col 25) │ └─Sum (line 1, col 38) │ ├─StringLiteral (line 1, col 26) │ │ └'Current: ' │ ├─Operator (line 1, col 38) │ │ └'+' │ └─Access (line 1, col 41) │ ├─Identifier (line 1, col 40) │ │ └'i' │ ├─Operator (line 1, col 41) │ │ └'.' │ └─FunctionCall (line 1, col 42) │ ├─Identifier (line 1, col 42) │ │ └'toString' │ └─ArgumentsList (line 1, col 50) ├─LoopParameters (line 1, col 14) │ └─Identifier (line 1, col 14) │ └'i' └─NoneNode (line 0, col 0) ``` Technically SMNP does have **LL(1)** parser implemented. The acronym means: * input is read from **L**eft to right * parser produces a **L**eft-to-right derivation * parser uses one lookahead token. Even though this kind of parsers is treated as the least sophisticated, in most cases they do the job and are enough even for more advanced use cases. SMNP language parser has some fundamental helper function that provides something like construction blocks that are used in right production rules implementations. SMNP language parser actually is a combination of sub-parsers that are able to parse subset of language. For example `smnp/ast/atom.py` file contains parsers related to parsing atomic values, like literals and so on (note also that *expression* with parentheses on both sides is treated like atom). The file defines `AtomParser` which is actually a function that takes list of tokens and produces AST. In this case `AtomParser` is able to parse only token sequences mentioned before, like string literals, integer literals etc. But `AtomParser` is used by `UnitParser` located in `smnp/ast/unit.py`. `UnitParser` introduces another production rules, like unary minus (`-`) operator and dot (`.`) operator (which is used to accessing fields and methods). In the first case, production is implemented using `Parser.allOf()` helper function which implements *and*-operation (conjunction) performed between symbols. ```python # smnp/ast/unit.py minusOperator = Parser.allOf( Parser.terminal(TokenType.MINUS, createNode=Operator.withValue), Parser.doAssert(AtomParser, "atom"), createNode=MinusOperator.withValues, name="minus" ) # this implements following production rule: # minusOperator ::= "-" atom ``` Next production rule of `UnitParser` produces `atom2` symbol using `Parser.oneOf()` helper function which implements *or*-operation (alternative) performed between symbols: ```python atom2 = Parser.oneOf( minusOperator, AtomParser, name="atom2" ) # which implements following production rule: # atom2 ::= minusOperator | atom ``` The last production rule of `UnitParser` is related to `unit` symbol and implements dot operator: ```python Parser.leftAssociativeOperatorParser( atom2, [TokenType.DOT], Parser.doAssert(atom2, "atom"), createNode=lambda left, op, right: Access.withValues(left, op, right), name="unit" ) # which implements following production rule: # unit ::= atom2 | atom2 "." atom2 # (leftAssociativeOperator() is function that implements production rule of left-associative operator) ``` `UnitParser` is then used by `FactorParser` (`smnp/ast/factor.py`) which defines power operator (`**`) and logic negation operator (`not`). `FactorParser` is used by `TermParser` (`smnp/ast/term.py`) and `TermParser` is used in turn by `ExpressionParser` (`smnp/ast/expression.py`). It is also worth paying attention to the impact of cascade of production rules on operators precedence. As you can see, almost each parser is cascadingly composed from another parsers with consistently increased supported subset of SMNP language. Thanks to the design, parsers are quite easy to debug and test. All parser-related code is located in `smnp/ast` module. ### Evaluator Evaluator is the last stage of SMNP language processing pipeline and also is the heart of all SMNP tool, which takes AST as input and performs programmed operations. Similar to implemented parser, evaluator works *recursively* because of processing tree-like structure. Evaluator's architecture is similar to parser's one. Evaluator consists of smaller evaluators which are able to evaluate small part of AST's node types. Because evaluator introduces as *runtime* term, it also works on special object called *environment*. The *environment* object contains some runtime information, like available functions (both coming from *stdlib* and user-defined), scopes (stack of defined variables), call stack and some meta information, like source of code. This object is passed through all evaluators along with AST and its subtrees. Example of `and` and `or` operators evaluators: ```python # smnp/runtime/evaluators/logic.py class AndEvaluator(Evaluator): @classmethod def evaluator(cls, node, environment): left = expressionEvaluator(doAssert=True)(node.left, environment).value right = expressionEvaluator(doAssert=True)(node.right, environment).value return Type.bool(left.value and right.value) class OrEvaluator(Evaluator): @classmethod def evaluator(cls, node, environment): left = expressionEvaluator(doAssert=True)(node.left, environment).value right = expressionEvaluator(doAssert=True)(node.right, environment).value return Type.bool(left.value or right.value) ``` Above evaluators are bound to `And` and respectively `Or` AST nodes in `smnp/runtime/evaluators/expression.py` file, which defines `ExpressionEvaluator`: ```python result = Evaluator.oneOf( # (...) Evaluator.forNodes(AndEvaluator.evaluate, And), Evaluator.forNodes(OrEvaluator.evaluate, Or), AtomEvaluator.evaluate )(node, environment) ``` The `ExpressionEvaluator` uses `Evaluator.oneOf()` helper function which tries to evaluate given node using one of defined evaluators. `Evaluator.forNodes()` helper function in turn wraps evaluator to condition which checks if node is one of given node types that are accepted by evaluator. `ExpressionEvaluator` among with some other evaluators is used in main evaluator in `smnp/runtime/evaluator.py` file. Evaluator can be disabled using `--dry-run` flag. All evaluator-related code is located in `smnp/runtime` module. ### Interpreter `Interpreter` actually isn't an another language processing stage, rather it is a facade that composes each stage in one pipeline, accepting a raw SMNP code as input. It also imports each function and method from SMNP modules (`smnp/module` module, which is actually a *SMNP native library*) and includes it to passed `environment` object. As long as environment containing functions and methods that come from *SMNP language library* is created in `smnp/main.py` file, it is passed as argument to `_interpret()` function as `baseEnvironment` and extends newly created `environment` object with its functions and methods. From now `environment` object contains both *SMNP native library*'s and *SMNP language library*'s functions and methods. Snippet below contains `_interpret()` function that composes pipeline of stages mentioned before: ```python # smnp/program/interpreter.py # (...) from smnp.module import functions, methods # (...) class Interpreter: # (...) @staticmethod def _interpret(lines, source, printTokens=False, printAst=False, execute=True, baseEnvironment=None): environment = Environment([{}], functions, methods, source=source) if baseEnvironment is not None: environment.extend(baseEnvironment) try: tokens = tokenize(lines) if printTokens: print(tokens) ast = parse(tokens) if printAst: ast.print() if execute: evaluate(ast, environment) return environment except RuntimeException as e: e.environment = environment e.file = environment.source raise e ``` That's the main flow of SMNP code execution. ## Audio module Audio module consists so far of 3 elements including 2 output modules (which produces a song) and 1 input module (which work is based on microphone input). ### `sound` type `sound` type was totally covered in previous chapter, so there is nothing to say more. You are able to load music and play it via `play()` method. Technically this type uses *SoundFile* library which is responsible for loading and playing music, so `sound` type actually is a wrapper for Python class provided by *SoundFile* framework. ### Synthesising module In oppose to `sound` type, synthesising module is **designed and implemented from scratch**. The only 3rd party code is provided with two frameworks: * *SoundDevice* library, which actually is an abstract layer above hardware and allows SMNP to pass custom data to sound card in order to play music through speakers * *Numpy* framework, which provides a special kind of list (*Numpy*'s array) that is the only container type accepted by *SoundDevice*. #### Synthesising process Synthesising process consists of two stages: compiling notes and playing wave. Depending on your needs the stages can be performed separately in different functions or merged to just one call. First of all you need to compile notes in order to have wave (which is of `list` type) using `wave` function. Then already compiled wave can be finally synthesised with `synth` function. ``` notes = [@c, @d, @e, @f]; compiledWave = wave(notes); synth(compiledWave); ``` Above code is really simple, so you can get rid of `wave` function invocation and pass notes directly to `synth` function: ``` synth([@c, @d, @e, @f]); ``` And that's it! Both above codes will play following notes: ![cdef](img/notes/cdef.png) ##### Rests Rest is nothing but `integer` placed among the notes. Value of number determines rest's length and works similar to duration part of note literal. For example: * `1` - whole rest * `2` - half rest * `4` - quarter rest * `8` - eighth rest * `16` - sixteenth rest * `32` - thirty-second rest * `64` - sixty-fourth rest * etc. As long as rests are represented as integers, they don't supports dots which make them longer with half of value, however you dot can be specified as another rest with half value of previous one, for example: dotted half pause = `[2, 4]`. Example of using rests: ``` notes = [@c, @d, 4, @f, @g, @a, 4, @c5]; synth(notes); ``` This code will play following notes: ![cd4fga8c](img/notes/cd4fga8c.png) ##### Polyphony You can have as many note staffs as you want and you are still able to merge them to one wave, that can be played later. Both `wave` function and `synth` function accepts multiple lists of notes as arguments. If you pass more than one list, all of them will be merged and normalised to achieve polyphonic effect. Let's say you have following voices: * ![twinkle1](img/notes/twinkle1.png) * ![twinkle2](img/notes/twinkle2.png) Notes above can be merged to one wave, as follows: ``` twinkle1 = [@c, @c, @g, @g, @a, @a, @g:2]; twinkle2 = [@c, @c, @e, @e, @f, @f, @e:2]; twinkle = wave(twinkle1, twinkle2); synth(twinkle); # or just synth(twinkle1, twinkle2); ``` The result is: ![twinkle3](img/notes/twinkle3.png) **Remember**, that in order to achieve the effect you have to pass `list` arguments, even if they will consist just of one element. Example: ``` synth([@c], [@e], [@g], [@c5]); synth(@c, @e, @g, @c5); ``` The first line represents following notes: ![poly1](img/notes/poly1.png) but the second line is not polyphonic anymore: ![poly2](img/notes/poly2.png) ##### Tuplets Tuplets can be achieved with `tuplet` function, that comes from *stdlib*. First argument of function determines how many notes you can put, the second one determines how many notes will be replaced and the last argument is vararg containing desired notes. Example: ``` notes = [@g:2, @d5:2] + tuplet(3, 2, @c5:8, @h:8, @a:8) + [@g5:2, @d5] synth(notes) ``` the code will play following notes: ![starwars](img/notes/starwars.png) Instead of 2 eighth notes (the second argument of `tuplet` function) we are going to have 3 eighth notes (the first argument of `tuplet` function). Because `tuplet` function produces a list of notes, and both `synth` and `wave` functions expect flat lists (i.e. lists that don't contain another lists) there is need to concatenate 3 lists in order to have just one. Another option is to include `tuplet` function to list in order to have nested lists and then use `flat` function, as follows: ``` notes = [@g:2, @d5:2, tuplet(3, 2, @c5:8, @h:8, @a:8), @g5:2, @d5] synth(flat(notes)) ``` #### Synthesising configuration Both `wave` and `synth` function have overloaded version that accepts a config as first argument. The config value is actually of `map<>` type and can include following keys: * `bpm: integer` * `tuning: integer` * `overtones: list` * `attack: float` * `decay: float` Parameters above will be covered in this section. All keys included to config map that don't match presented parameters will be ignored. Remember, that the config argument is just additional argument and are rules presented in previous section are applicable here as well. Example: ``` w = wave({ bpm -> 270 }, @c, @d, @e, @f); w2 = wave({ bpm -> 60, tuning -> 432 }, [@e, @f, @e, @d, @e:2, 2], transpose(-12, [@g, @a, @g, @g, @g:2, 2]), transpose(-12, [@c, @c, @c:4d, @h3:8, @c:2, 2]) ); ``` ##### Tempo Tempo can be adjusted with `bpm` property. BPM means beats per minute and determines (in this case) how many quarter notes will be played during single minute. For example: * 60 bpm - 60 quarter notes per minute - one quarter note per second * 120 bpm - 120 quarter notes per minute - two quarter notes per second * and so on. ``` notes = noteRange(@c, @c5, "diatonic"); # C Major scale synth({ bpm -> 60 }, notes); # notes will be being played during 8s synth({ bpm -> 120 }, notes); # notes will be being played during 4s synth({ bpm -> 240 }, notes); # notes will be being played drugin 2s ``` ##### Tuning `tuning` is the property that determines frequency of `@a` sound. Typically it is set to 440Hz or 432Hz, but it can be any positive value. Using this property you are able to adjust tuning of all available notes. Example: ``` a4 = @a:128; plot(wave({ tuning -> 440, overtones -> [1.0] }, a4)); plot(wave({ tuning -> 127, overtones -> [1.0] }, a4)); ``` ![a_440](img/plots/a_440.png) ![a_127](img/plots/a_127.png) Plots above presents fundamental tone's sine wave of the same note: `@a` in different tuning systems. You can hear the difference executing following code: ``` notes = noteRange(@c, @c5, "diatonic"); # C Major scale synth({ tuning -> 440 }, notes); synth({ tuning -> 512 }, notes); ``` ##### Overtones Overtones allows you to specify the sound of single note being compiled. In fact each note can be composed from sine waves with greater and greater frequencies. `overtones` parameter is a list of float values, that represents a quotient of influence each overtone to output wave's amplitude. First value at the list is related to fundamental tone. Second one is related to first overtone (second harmonic), the third one is related to second overtone (third harmonic) and so on. You can read more about overtones at [Wikipedia](https://en.wikipedia.org/wiki/Overtone). Sum of list of overtones' values must be less or equal to 1. To disable overtone simply put `0.0` at proper place of list. For example sound with `overtones = [0.7, 0.0, 0.3]` consists only of fundamental tone with amplitude equal to 0.7 of total amplitude and third harmonic with amplitude equal to 0.3 of total amplitude. No other overtones are enabled. Examples (with related plots): 1st example (1 overtone - ideal pitchfork sound) ``` # v 1st w = wave({ overtones -> [1.0] }, @a:64) # a = 440 Hz plot(w) plot(fft(w)) ``` ![a_1overtone](img/plots/a_1overtone.png) ![a_1overtone_fft](img/plots/a_1overtone_fft.png) 2nd example (2 overtones) ``` # v 1st v 3rd w = wave({ overtones -> [0.7, 0.0, 0.3] }, @a:64) plot(w) plot(fft(w)) ``` ![a_2overtones](img/plots/a_2overtones.png) ![a_2overtones_fft](img/plots/a_2overtones_fft.png) 3rd example (3 overtones) ``` # v 1st v 3rd v 14th w = wave({ overtones -> [0.5, 0.0, 0.3] + (10 ^ 0.0) + [0.2] }, @a:64) plot(w) plot(fft(w)) ``` ![a_3overtones](img/plots/a_3overtones.png) ![a_3overtones_fft](img/plots/a_3overtones_fft.png) ##### Decay `decay` parameter defines the tail of sound. Value of the parameter determines how fast the sound is going to be "blanked". The greater value of `decay` parameter, the sound will be blanked faster. If `decay` parameter is set to 0, sound will not change its magnitude anymore. Example (with proper plots): ``` a4 = @a:16; plot(wave({ decay -> 0, overtones -> [1.0] }, a4)); plot(wave({ decay -> 0.5, overtones -> [1.0] }, a4)); plot(wave({ decay -> 1, overtones -> [1.0] }, a4)); plot(wave({ decay -> 5, overtones -> [1.0] }, a4)); plot(wave({ decay -> 10, overtones -> [1.0] }, a4)); ``` ![decay0](img/plots/decay0.png) ![decay05](img/plots/decay05.png) ![decay1](img/plots/decay1.png) ![decay5](img/plots/decay5.png) ![decay10](img/plots/decay10.png) ##### Attack `attack` parameter is the last one supported by config map and in oppose to `decay` determines how fast the sound is going to reach full magnitude. The greater value of `attack`, the sound will reach full magnitude faster. If `attack` parameter is set to 0, the effect will be disabled and full magnitude will be reached immediately. Example (with proper plots): ``` a4 = @a:16; plot(wave({ attack -> 0, decay -> 0, overtones -> [1.0] }, a4)); plot(wave({ attack -> 1, decay -> 0, overtones -> [1.0] }, a4)); plot(wave({ attack -> 5, decay -> 0, overtones -> [1.0] }, a4)); plot(wave({ attack -> 10, decay -> 0, overtones -> [1.0] }, a4)); plot(wave({ attack -> 100, decay -> 0, overtones -> [1.0] }, a4)); ``` ![attack0](img/plots/attack0.png) ![attack1](img/plots/attack1.png) ![attack5](img/plots/attack5.png) ![attack10](img/plots/attack10.png) ![attack100](img/plots/attack100.png) ##### Mixing parameters You are able to mix parameters and achieve really interesting results to emulate for example piano, guitar, string ensemble etc. For example, you can achieve this sound's shape: ![example_config](img/plots/example_config.png) using following code: ``` config = { attack -> 2, decay -> 7, overtones -> [0.5, 0.0, 0.0, 0.0, 0.3, 0.0, 0.2] }; plot(wave(config, @a:64)); synth(config, [@c:1, @d:1, @e:1, @f:1], [2, @c5:1, @h:2, @c:1, @a:1]); ``` It sounds really magically, isn't it? Feel free to mix parameters in order to achieve desired sound and enjoy it! ##### Default values If you don't pass any of supported parameters or even use the simplest signature of `wave` or `synth` function, following default values will be assigned to each parameter: * `bpm = 120` * `tuning = 440` * `overtones = [0.4, 0.3, 0.1, 0.1, 0.1]` * `decay = 4` * `attack = 100` which causes following sound's shape: ``` plot(wave(@a:64)) ``` ![default_config](img/plots/default.png) ### Listening module Listening module is the only implemented module that processes input coming from microphone. Moreover the module contains only one function which is `wait`. The function accepts 2 integer arguments: `soundLevel` and `silenceLevel` and is useful to ear training. The goal of `wait` function is to hang program execution and wait for incoming sound. Only when the sound sounds and then stops, the program's execution will be resumed. Function can be in three states. Let's call them: * `WAIT_FOR_SOUND` * `WAIT_FOR_SILENCE` * `RESUME`. Now it is possible to show how it works using finite states machine: ![wait_fsm](img/schemas/wait_fsm.png) In other words, `wait` function waits for sound, which means it hangs program execution and waits for exceeding `soundLevel` parameter by input level, which is the first argument of `wait` function. After that it waits for sound's stop (i.e. input level must be less than `silenceLevel`, which is the second argument of `wait` function). Only then `wait` function ends its work and resumes program's execution. If you are not sure about what `soundLevel` or `silenceLevel` values you need to use, you can run SMNP program with `-m` flag in order to test in real time what kind of noise produces what level. Instead of calling function with two arguments, you can invoke it without any argument. In that case: ``` wait() ``` is equivalent of ``` wait(300, 10) ``` ## Command-line interface SMNP is provided as command-line tool and so far doesn't support any graphic interface. The simplest way to use SMNP is to type `smnp `, where `` is a file containing SMNP language code. You can put more files than one, just remember to split them with spaces. You are also able to execute SMNP language inline, which can be useful in some kinds of batch scripts. It can be done using `-c` option, like on following example: ``` $ smnp -c "println(\"Hello, world!\")" Hello, world! $ ``` Remember to escape all quotes if you are typing string literals in this mode. Apart from this options mentioned so far, SMNP provides you another ones, which you can list using `smnp -h` command. All of them are also covered below: * `-c ` - allows you to execute code passed as argument instead of reading it from file * `-m` - allows you to test input level coming from microphone * `-h` - displays simple manual of SMNP usage with all options and flags described * `-v` - displays version of SMNP Options planned to be implemented: * `-C ` (not implemented yet) - allows you to override default file that contains common configuration of SMNP tool * `-P =` (not implemented yet) - allows you to pass properties that you will be able to obtain from code Because SMNP acts also as educational tool on field of parsing formal languages, there are also implemented some flags allowing you to see what is going on at each language processing stage: * `--tokens` - prints tokenizer's output for passed code * `--ast` - *pretty-prints* abstract syntax tree as parser's output for passed code * `--dry-run` - runs language-processing tools without involving evaluator ## Installation To install SMNP: 1. Make sure you have already installed *PortAudio* in your OS (it is required by Audio Module to send data frames to your sound device) 2. Clone this repository and enter to it 3. Run `pip install .` inside root repository folder