Compile Time Computation
Kronos produces runtime code that is both deterministic and simple. Therefore, most of the abstraction you can use to generate complex patches takes place at compile time.
Sinusoid Approximation
As an example, we are going to write a sinusoidal oscillator that uses polynomial approximation. This technique is not as efficient as the various recursive methods of sinusoid generation, but it has the benefit of adjustable precision and inexpensive modulation. That is why it is a good choice for frequency modulation synthesis.
MacLaurin Series
Our plan is to develop a polynomial approximation for a sinusoidal slope and utilize the symmetry properties to generate a full waveform period. The first step is the MacLaurin series.
Import Algorithm
Fact(n) {
When(n > #1
n * Fact(n - #1)
Otherwise
#1)
}
is = Algorithm:Expand(#10 (+ #1) #0)
Algorithm:Map(Fact is)#1x2 #2 #6 #24 #120 #720 #5040 #40320 #362880 nil
Sin-Coef(n) {
Math:Pow(#-1 n) / Fact(#2 * n + #1)
}
coefs = Algorithm:Map(Sin-Coef is)
coefs#1 #-0.16666666666666666666666666666666666667 #0.008333333333333333333333333333333333333 #-0.0001984126984126984126984126984126984127 #0.000002755731922398589065255731922398589065 #-0.000000025052108385441718775052108385441718775 #0.00000000016059043836821614599392377170154947933 #-0.0000000000007647163731819816475901131985788070444 #0.000000000000002811457254345520763198945583010320016 #-8.220635246624329716955981236872280749e-18 nil
Please note that as we are using invariant numbers (#10
), the entire computation happens at compile time during the specialization pass.
Horner-form polynomial
Armed with polynomial coefficients, we can use Horner's scheme to evaluate the polynomial efficiently. Since our series only has coefficients for odd powers, we use x^2
as the polynomial variable.
Horner(x coefs...) {
Algorithm:Fold((c p) => c + x * p coefs...)
}
SinA(w) {
w * Horner(w * w coefs)
}
Import Math
Algorithm:Map(SinA
Math:Pi / 4
Math:Pi / 3
Math:Pi / 2)0.70710677 0.86602545 0.99999988
Periodicity
Approximating sin
makes sense, because zero is in the middle of the slope. We can further shift the phase angle by -Pi/2
to translate sine into cosine, which is beneficial because cosine is symmetrical around zero.
CosA(w) {
SinA(Abs(w) - Math:Pi / #2)
}
Algorithm:Map(CosA
Math:Pi
Math:Pi / -2
0
Math:Pi / 2
Math:Pi )#0.9999999999999998484472428494967738426 0 -0.99999988 0 #0.9999999999999998484472428494967738426
We now have an approximation that's decent in the interval [-Pi, Pi]. We can plug in a phasor:
Import Gen
snd = CosA((Gen:Phasor(440) * 2 - 1) * Math:Pi) * 0.2
Cleaning Up
For completeness, we may want to warp the whole thing in a function.
SinOsc(freq) {
; Refer to Map, Expand and Fold from Algorithm
; with implicit package prefix
Use Algorithm[Map Expand Fold]
N = #10 ; approximation order
; compute polynomial coefficients
coefs = Map(Sin-Coef Expand(N (+ #1) #0))
; phasor
phase = Gen:Phasor(freq)
; map phasor to [-Pi, Pi]
x = ( Abs(phase * 2 - 1) - 0.5 ) * Math:Pi
x * Horner(x * x coefs)
}
snd = SinOsc(440 + 10 * SinOsc(5.5)) * 0.2
; Frequency Modulation Synthesis
f0 = 110
mod = SinOsc(0.1) * 32
op1 = SinOsc(f0 * 3)
op2 = SinOsc((1 + op1 * mod) * f0)
snd = op2 * 0.2
Compile Time Power
To recap, we have an oscillator definition that relies on a MacLaurin series that looks pretty close to that from a mathematical textbook. However, the generated code avoids all the tricky stuff. If we take a peek at the inner loop of our signal processor, this is what we see in LLVMish:
%15 = fmul float %10, 2.000000e+00
%16 = fadd float %15, -1.000000e+00
%17 = fmul float %16, 0x400921FB60000000
%abs.i.i = tail call float @llvm.fabs.f32(float %17) #1
%18 = fadd float %abs.i.i, 0xBFF921FB60000000
%19 = fmul float %18, %18
%20 = fmul float %19, 0x3C62F49B40000000
%21 = fsub float 0x3CE952C780000000, %20
%22 = fmul float %19, %21
%23 = fadd float %22, 0xBD6AE7F3E0000000
%24 = fmul float %19, %23
%25 = fadd float %24, 0x3DE6124620000000
%26 = fmul float %19, %25
%27 = fadd float %26, 0xBE5AE64560000000
%28 = fmul float %19, %27
%29 = fadd float %28, 0x3EC71DE3A0000000
%30 = fmul float %19, %29
%31 = fadd float %30, 0xBF2A01A020000000
%32 = fmul float %19, %31
%33 = fadd float %32, 0x3F81111120000000
%34 = fmul float %19, %33
%35 = fadd float %34, 0xBFC5555560000000
%36 = fmul float %19, %35
%37 = fadd float %36, 1.000000e+00
%38 = fmul float %18, %37
%39 = bitcast i8* %2 to float*
store float %38, float* %39, align 4, !alias.scope !9, !noalias !10
In other words, the compiler has generated an inlined Horner form polynomial with constant multipliers: pretty much what we would write by hand in C. Second-order code generation has some additional benefits - this oscillator could easily have parametric precision. SinOsc
could simply receive the approximation order, N
as a compile time constant argument.
To look at Kronos code output, please take a look at the command line workflow and kc
, the static compiler.