Synthtax is a small, typed, imperative music programming language designed for creating and manipulating sound. This minimal language is able to supports some programming constructs such as loops, conditionals, and function calls. Synthtax provides a mechanism to generate and modify sound, specifically, oscillators and ADSR.
At first the language is implemented as a C++ transpiler using the C++ programming language and ANTLR. The newer version can generate LLVM IR rather than C++ code.
I initially developed Synthtax as my final project in Synthesizer course in university. It helped me put into practice some digital signal processing concepts that I learned in the course, and also learn more about compilers, even though more on compilers :) Now, by extending the compiler to generate LLVM IR, I am able to deepen my understanding of IR and backend code generation.
- Demo
- Grammar and Syntax
- Installation
- How to build and run
- How Synthtax works
- What was learned
- Future Development
- Reference
- Resources
demo-lang.mp4
demo-osc.mp4
Synthtax has a relatively simple grammar and syntax. Here are some of the key features of the language:
Type | Description |
---|---|
void |
C++ equivalent: void |
int |
C++ equivalent: int |
float |
C++ equivalent: float |
char |
C++ equivalent: char Rule: '\'' [a-zA-Z_.] '\'' |
string |
C++ equivalent: std::string Rule: '"' [a-zA-Z_0-9.]* '"' |
osc |
Oscillator object |
env |
ADSR object |
Declaring a variable is similar to C++
Identifier rule: [a-zA-Z_]+
Example:
int a = 2;
float b = 3.5;
string c = "hello";
osc d = Osc("sine", 440.0, 1.0, 1.0);
Print an expression with print
and println
Example:
print "hello";
print (2 + 3);
println 'a';
Functions are defined with return type, followed by the function name, and parameters if any.
Example:
int add(a: int, b: int) {
return a + b;
}
There are if
and else
only.
Example:
if (2 < 3) {
print "yes";
} else {
print "no";
}
Loop with while
Example:
int i = 0;
while (i < 5) {
i = i + 1;
}
println i;
Create an oscillator with type osc
and the Osc()
function.
Constructors:
osc Osc(type: string, frequency: float, amplitude: float)
osc Osc(type: string, frequency: float, amplitude: float, duration: float)
osc Osc(type: string, frequency: float, amplitude: float, duration: float, sound: std::vector<float>)
An Osc()
function accepts one of the 3 types: sine
, triangle
, sawtooth
Example:
osc a = Osc("sine", 440.0, 1.0, 1.0);
Use write()
function to write an oscillator to a .wav
file.
Signatures:
write(oscillator: osc, outfile: string, duration: float); // write an oscillator with a duration
write(oscillator: osc, outfile: string); // write an oscillator without a duration, default 1s
Example:
osc s = Osc("sine", 440.0, 1.0);
write(s, "sine440.wav", 2.0);
Add, subtract, and multiply 2 oscillators. Return a new oscillator with a duration that equals to the longer one.
Example:
osc a = Osc("sine", 440.0, 1.0, 1.0);
osc b = Osc("sine", 220.0, 0.8, 1.5);
osc c = a + b; // additive synth
osc d = a - b; // subtractive synth
osc e = a * b; // multiplicative synth
Create an ADSR with type env
and the ADSR()
function.
Constructors:
env ADSR(); // default
env ADSR(decay_time: float, sustain_time: float, release_time: float, sustain_level=0.8: float); // attack always starts at 0s
Example:
env envDefault = ADSR();
/*
attack starts at 0s
decay starts at 1.0s
sustain starts at 2.5s at default amplitude
release starts at 5.0s
*/
env envParams = ADSR(1.0, 2.5, 5.0);
/*
attack starts at 0s
decay starts at 3.0s
sustain starts at 5.5s at 0.7 amplitude
release starts at 6.0s
*/
env envLevel = ADSR(3.0, 5.5, 6.0, 0.7);
Apply an ADSR to an oscillator with the apply()
function. Return a new oscillator.
Signature:
osc apply(envelop: env, oscillator: osc)
Example:
osc a = Osc("sine", 440.0, 1.0, 7.5);
env envDefault = ADSR();
osc oscDefault = apply(envDefault, a);
Write the block between the 2 keywords @header
and @end_header
verbatim at the beginning of the transpilled file. This allows the user to use some functions in their header files, define some data types or macros.
Example:
@header
#include <math.h>
#include "myadd.h"
using namespace std;
@end_header
int main() {
int a = myadd_func(2, 3); // from myadd.h
int b = max(a, 6); // from std library
println b;
}
Clone or download this project
Requirements:
- Java
- C++ compiler (Clang)
- CMake
libsndfile
to write a.wav
file (see github.com/libsndfile)- LLVM (see LLVM Download Page)
NOTE:: If you want to compile LLVM from source, then follow this documentation llvm.org/docs/checkout-llvm-from-git
./generate.sh
: generate lexer, parser, visitor classescmake -S ./ -B build
: create buildcd build; make
: compile and link./synthtax -h
: run help menu
Usage:
-i: input file path
-o: output file path
-m: (optional) mode "cpp" for transpiling to C++ or "ll" for generating LLVM IR. Default is "cpp"
Example:
./synthtax -i ../test/args.in -o ../out.cpp -m cpp
Compile outfile.cpp
with your favourite C++ compiler. If you have write_to_file()
function in output.cpp
(i.e., if you want to write to a .wav
file), you need to add -lsndfile
flag to link with the libsndfile
library. I will figure out how to link libsndfile
in CMake in the future (probably include this cmake folder)
Example:
g++ output.cpp -lsndfile
At first I attempted to write a lexer and a parser from scratch. Then I quit after spending several days debugging, and switched to ANTLR instead. ANTLR is a great tool to create a parse tree without too much effort, even though I had lots of ups and downs with it.
ANTLR is made up of two main parts: the tool and the runtime. The tool consists of Java and an ANTLR .jar
file, which is included in the project. There is a different runtime for every target language. Since the target language is C++, C++ runtime library is needed to generate the lexer and parser code. To use the newly generated clases, they need to be compiled and linked against the C++ runtime library. Building a C++ project on different platforms can be quite complicated. Thankfully, the official ANTLR repository provides an Antlr4Cpp external project, which aids the integration process.
To use ANTLR, I need to input my grammar for the Synthtax language (i.e., SynthtaxLexer.g4
and SynthtaxParser.g4
). Then ANTLR will produce a parser class and a standard base visitor class. To do code generation, I just need to implement the Visitor class (i.e., Visitor.h
).
For now, an audio is written to a .wav
file using libsndfile
library. Implementation for Synthtax built-in functions such as Oscillator and ADSR can be found in the include folder. These objects are transpilled as smart pointer objects because their memory can be deallocated automatically, and I don't have to keep track of which objects are created and add some delete statements.
- How to design a programming language
- How hard it is to write a lexer and parser from scratch
- How to write a proper ANTLR grammar
- How to intergrate the C++ runtime
- How to generate C++ code with the parse tree
- How to properly link libraries and header files in CMake
- How static typed and dynamic typed language can be transpiled
- A bit of RTAudio and libsndfile
- LLVM library
- Project structure, documentation, a better software engineer
Synthtax is a small language and it does not support many programming constructs. In the future, I will expand its capabilities such as adding arrays, and some digital processing concepts such as frequency modulation, filters, and getting MIDI input.
- github.com/antlr/antlr4/tree/master/runtime/Cpp/cmake: build files for many platforms