input.x0 = 7; // min=1.01, max=20, step=0.001 // The equations of Lotka-Volterra are a simple model of // prey-predator population evolution. // x represents the amount of preys // y represents the amount of predators // Their evolution equation is // x' = x * (1 - y) // y' = y * (x - 1) // When there are few preys (x<1), the number of predators decreases. // Once there are few predators (y<1), the prey population is able to increase exponentially... // except this allows the number of predators to increase again, and eat them, leading to fewer preys, // and the cycle repeats... // // This specific model has stable cycles: from any starting position (x0,y0) with positive values, // the trajectory will return to (x0,y0) after one cycle, thus // the behavior will be periodic (but the period depends on the starting position). // In particular there is no "damping" toward the fixed point (x=1, y=1) // // If we start from the point (x0, 1), larger values of x0 mean that the cycle will have stronger variations, // but also a longer period. // In terms of sound, I think it's rather cool, // but the fact that it detunes depending on x0 is inconvenient for using it in synth code. // Retune the synth ! function calc_period_lk(x0, a0) { // How many samples does it take to run one period? var x = x0; var y = 1 + 0.5 * (x-1) * a0; var s = 0; while(y >= 1) { x += (x * (1-y)) * a0; y += (y * (x-1)) * a0; s++; } var dy = 0; while(y < 1) { x += (x * (1-y)) * a0; dy = (y * (x-1)) * a0; y += dy; s++; } // Sub-sample correction var overshoot = (y-1) / dy; return s - overshoot; } const lk = synth.def(class { constructor(options) { this.x0 = input.x0; this.calc_a0(options); this.x = this.x0; this.y = 1 + 0.5 * (this.x-1) * this.a0; } calc_a0(options) { // Change the coefficient a0 so that the frequency is correct despite the trajectory being different var freq = midi_to_hz(options.note); var a0 = 2*Math.PI * freq * ditty.dt; var spl = calc_period_lk(this.x0, a0); // current period duration (in samples) debug.log("spl", spl); var spl_target = 1 / (freq * ditty.dt); // desired period duration debug.log("spl_target", spl_target); var factor = (spl / spl_target); this.a0 = a0 * factor; // Just multiply the speed by this factor and hope for the best debug.log("factor", factor); var spl2 = calc_period_lk(this.x0, this.a0); // Now the period is a bit closer, but still not perfect debug.log("spl2", spl2); var factor2 = (spl2 / spl_target); this.a0 *= factor2; // So we correct the factor again var spl3 = calc_period_lk(this.x0, this.a0); // Almost perfect now debug.log("spl3", spl3); var factor3 = (spl3 / spl_target); this.a0 *= factor3; // One last time // It can be nice to use adaptive oversampling for large a0, // however this implementation is not compatible with the retuning. this.os = 1; /* while(this.a0 > 0.01) { this.os *= 2; this.a0 /= 2; } debug.log("os",this.os); */ } process(note,env,tick,options) { if(this.x0 != input.x0) { // Reset on input changd this.x0 = input.x0; this.x = this.x0; this.calc_a0(options); this.y = 1 + 0.5 * (this.x-1) * this.a0; } for(let i=0; i<this.os; i++) { // Störmer-Verlet integrator // (symplectic numerical integrator, so that it doesn't explode nor dampen) this.x += (this.x * (1-this.y)) * this.a0; this.y += (this.y * (this.x-1)) * this.a0; } return this.y - 1; } }, {env:one, amp:0.03}); lk.play(c3);