Lissajous and harmonograph curves#

biotuner.harmonic_geometry ships closed-form Lissajous figures and damped double-pendulum harmonographs. Both turn a ratio-set into a 2-D or 3-D trajectory whose visual complexity is a direct expression of the input harmonics — coprime ratios produce closed knots, near-rational ratios drift through dense rosettes, and a small amount of damping makes the trace decay inward like a real harmonograph drawing.

This notebook reproduces the Lissajous and harmonograph figures from the harmonic_geometry report.

import warnings
from fractions import Fraction

import numpy as np
import matplotlib.pyplot as plt

from biotuner.harmonic_geometry import HarmonicInput, plotting

warnings.filterwarnings("ignore")
plt.rcParams["figure.dpi"] = 110

3-D Lissajous knots#

When three coprime integer frequencies share a single trajectory the curve closes into a knot — the spatial counterpart of a chord.

from biotuner.harmonic_geometry import lissajous_3d

geoms = [
    lissajous_3d(ratios=[3, 4, 5], phases=[0.0, np.pi/4, np.pi/2], n_points=4000),
    lissajous_3d(ratios=[2, 3, 7], phases=[0.0, np.pi/3, np.pi/5], n_points=4000),
]
plotting.gallery(geoms, titles=["(3, 4, 5)", "(2, 3, 7)"], n_cols=2,
                 suptitle="lissajous_3d — knotted trajectories");
../../_images/09e0813317370123999b5303325ecc1401cf3bc08ec774e82b737244c2d14e8e.png

Pairwise grid and compound curves#

lissajous_pairwise_grid traces every component pair of a chord, so the diagonal contains 1:1 circles and off-diagonals encode interval structure. lissajous_compound sums every component on each axis, giving a single amplitude-weighted figure of the whole chord.

from biotuner.harmonic_geometry import lissajous_pairwise_grid, lissajous_compound

inp = HarmonicInput(ratios=[1, Fraction(3, 2), Fraction(5, 4)], base_freq=100.0)
grid = lissajous_pairwise_grid(inp, n_points=400)

labels = ["1/1", "3/2", "5/4"]
n = len(grid)
fig, axes = plt.subplots(n, n, figsize=(5.5, 5.5))
for i in range(n):
    for j in range(n):
        plotting.draw_curve_2d(grid[i][j], axes[i, j], lw=0.6)
        plotting.axis_clean(axes[i, j])
        axes[i, j].set_xticks([]); axes[i, j].set_yticks([])
        if i == 0: axes[i, j].set_title(labels[j], fontsize=9)
        if j == 0: axes[i, j].set_ylabel(labels[i], fontsize=9)
fig.suptitle("lissajous_pairwise_grid (3-component chord)")
fig.tight_layout();
../../_images/eb3a3c4ae7ed006bd673e9d4683c81d015497a784eb80e40d7ecf6522cbaf351.png
inp = HarmonicInput(
    ratios=[1, Fraction(3, 2), Fraction(5, 4), Fraction(7, 4)],
    amplitudes=[1.0, 0.7, 0.5, 0.3], base_freq=100.0,
)
g = lissajous_compound(inp, n_points=4000, n_periods=2)
fig, ax = plotting.plot_geometry(g, lw=0.6)
ax.set_title("lissajous_compound — just-intonation tetrad");
../../_images/934a0dda65abff19b5b7a829cf7959d761eb56f62754626ac440ead007e71c27.png

Phase drift#

A slowly-changing phase between two components un-closes a Lissajous figure and lets it precess through every member of its family.

from biotuner.harmonic_geometry import lissajous_phase_drift

geoms = [
    lissajous_phase_drift(ratio=Fraction(3, 2), drift_rate=d, duration=4.0, sr=600)
    for d in (0.5, 1.5, 4.0)
]
plotting.gallery(geoms, titles=[f"drift = {d} rad/s" for d in (0.5, 1.5, 4.0)],
                 n_cols=3, suptitle="lissajous_phase_drift, ratio 3:2 over 4 s",
                 draw_kwargs={"lw": 0.5});
../../_images/9be569a36ef356d6cee013fdef87a4a41f6db06c133a2f9ae0cfe16202371cb4.png

Harmonograph examples#

A real harmonograph couples two damped pendulums; harmonograph_lateral, harmonograph_rotary, and harmonograph_3d cover the three common rigs. Pass a HarmonicInput with peaks and per-component damping and the trace will decay exactly as a physical apparatus does.

from biotuner.harmonic_geometry import (
    harmonograph_3d, harmonograph_lateral, harmonograph_rotary,
)

inp = HarmonicInput(
    peaks=[2.01, 3.02, 5.0, 7.03],
    amplitudes=[1.0, 0.8, 0.6, 0.4],
    phases=[0.0, np.pi/5, np.pi/3, np.pi/7],
    damping=[0.05, 0.04, 0.06, 0.05],
)
g_lat = harmonograph_lateral(inp, duration=40.0, sr=400)
g_rot = harmonograph_rotary(inp, duration=40.0, sr=400, rotation_freq=0.05)
g_3d  = harmonograph_3d(inp, duration=40.0, sr=400)

plotting.gallery([g_lat, g_rot, g_3d],
                 titles=["harmonograph_lateral", "harmonograph_rotary", "harmonograph_3d"],
                 n_cols=3, draw_kwargs={"lw": 0.5},
                 suptitle="harmonograph family — same input, three rigs");
../../_images/34a0ad1fb38e426d3f9ef8b4a73c76a5b69059da29e51e02804f4f44e4f235f6.png

Effect of damping#

Zero damping gives a bounded but persistent trace; even mild damping makes the figure spiral inward to a point.

inp_zero  = HarmonicInput(peaks=[2.0, 3.0, 5.0, 7.0],
                          amplitudes=[1.0, 0.8, 0.6, 0.4],
                          damping=[0.0]*4)
inp_decay = HarmonicInput(peaks=[2.0, 3.0, 5.0, 7.0],
                          amplitudes=[1.0, 0.8, 0.6, 0.4],
                          damping=[0.15]*4)
geoms = [harmonograph_lateral(inp_zero,  duration=30.0, sr=300),
         harmonograph_lateral(inp_decay, duration=30.0, sr=300)]
plotting.gallery(geoms,
                 titles=["damping = 0", "damping = 0.15"],
                 n_cols=2, draw_kwargs={"lw": 0.5},
                 suptitle="harmonograph — damping comparison");
../../_images/6874edb318423f7c4b8ea8e52903ff0aedd5f8fd931cb9ddbb951661350b5d2f.png