Skip to content
SciStack
Minigame Python Beginner 25 min

Plucked string harmonics: why a pluck at a third has no third harmonic

Afterwards you can expand any string shape in a Fourier series, follow a pluck's kinks with d'Alembert, and predict its missing harmonics and its brightness.

Field
Mathematics, Physics
Prerequisites
none beyond Python basics
Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
Download notebook Save Mark as done

py-plucked-string.ipynb, executed with the versions above. The download needs a free account

Run it yourself. In a terminal, this installs exactly the versions above:

pip install numpy==2.4.3 scipy==1.18.1 matplotlib==3.11.2 jupyterlab

Play: pluck a guitar string at a third of its length

The A string of a classical guitar sounds at 110 Hz, with harmonics at 220, 330, 440 Hz and up that give it its tone, and where you pluck it decides how strong each harmonic is: a Fourier series of the plucked shape says by how much. The game shows the string from the front, 65 cm from the bridge at the left to the nut at the right, moving 1000 times slower than the sound and pulled far wider than a real pluck of a few millimeters. Touch it anywhere, pull, and let go, or press 1/2, 1/3, or 1/10 for a pluck at that fraction of the length from the bridge (on a keyboard 1, 2, 3; Space pauses, R resets, S switches the sound). The bars under the string show how loud each harmonic sounds against the fundamental, following your finger while you hold it and fading with the sound after you let go. Watch the third bar at a third, and the fundamental's share of the energy: 68 % at a third, 22 % at a tenth.

Download the game

Show code
// game.js: a guitar string between bridge (left) and nut (right). Pull it anywhere and let go, or press a
// preset; the string moves slowed down, the bars show how loud each harmonic sounds, and the sound plays.
// The notebook writes the string's constants, the decay times, and the loudness into #data.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const {length: L, f1: F1, modes: N, slow: SLOW, points: POINTS, bars: BARS} = DATA;
const [A_MIN, A_MAX] = DATA.a_range;
const TAU = [0, ...DATA.tau];                                  // decay time of harmonic n in s, TAU[n]

// ---- the state: everything that changes, in one object
const DEFAULTS = {a: 1 / 3, h: 0, held: false, plucked: false, touched: false, t: 0, heard: 0};
const state = {...DEFAULTS, running: true, sound: true};       // t: the string's time, heard: real time since release

// b[n] for a triangle of height 1 with its corner at x = aL, the formula of the second effect
function coefficients(a) {
  const b = new Float64Array(N + 1);
  for (let n = 1; n <= N; n++) b[n] = 2 * Math.sin(n * Math.PI * a) / (n * n * Math.PI ** 2 * a * (1 - a));
  return b;
}

// the fundamental's share of the energy, b1² / Σ n² bn², with the sum over all modes in closed form:
// Σ sin²(nπa)/n² = π²a(1 − a)/2 makes Σ n² bn² = 2 / (π² a(1 − a))
const fundamentalShare = (b, a) => b[1] ** 2 * Math.PI ** 2 * a * (1 - a) / 2;

// sin(nπx/L) at the drawn points, computed once
const SIN = Array.from({length: N + 1}, (_, n) => Float64Array.from({length: POINTS + 1}, (_, i) => Math.sin(n * Math.PI * i / POINTS)));

// the string's shape y(x, t) at POINTS + 1 points from bridge to nut: the sum of the modes, each at n·f1
function shape(b, t) {
  const y = new Float64Array(POINTS + 1);
  for (let n = 1; n <= N; n++) {
    const w = b[n] * Math.cos(2 * Math.PI * n * F1 * t);
    for (let i = 0; i <= POINTS; i++) y[i] += w * SIN[n][i];
  }
  return y;
}

// ---- sound: one buffer per pluck, the force on the bridge as a sum of fading cosines
function synthesize(b, fs) {
  const n = Math.round(DATA.seconds * fs), out = new Float32Array(n);
  let power = 0;
  for (let k = 1; k <= N; k++) power += (k * b[k]) ** 2 / 2;
  const g = DATA.rms / Math.sqrt(power);                       // every pluck equally loud: the ear compares tone
  for (let k = 1; k <= N; k++) {
    const amp = g * k * b[k];
    if (Math.abs(amp) < 1e-9) continue;                        // a missing harmonic costs nothing
    const w = 2 * Math.PI * k * F1 / fs, fade = Math.exp(-1 / (TAU[k] * fs));
    let env = amp;
    for (let i = 0; i < n; i++) { out[i] += env * Math.cos(w * i); env *= fade; }
  }
  const taper = Math.round(DATA.taper * fs);                   // the last 0.25 s fall to zero, so the end does not click
  for (let i = n - taper; i < n; i++) out[i] *= (n - i) / taper;
  return out;
}

let audio = null, master = null, source = null;
function wakeAudio() {                                         // call only where the browser counts a gesture
  if (!audio) {
    audio = new (window.AudioContext || window.webkitAudioContext)();
    master = audio.createGain();
    master.connect(audio.destination);
  }
  if (audio.state === "suspended" && state.running) audio.resume();   // iOS wants this inside the tap
  master.gain.value = state.sound ? 1 : 0;
}
function playPluck() {
  if (!audio) return;
  if (source) source.stop();
  const samples = synthesize(coefficients(state.a), audio.sampleRate);
  const buffer = audio.createBuffer(1, samples.length, audio.sampleRate);
  buffer.copyToChannel(samples, 0);
  source = audio.createBufferSource();
  source.buffer = buffer;
  source.connect(master);
  source.start();
}

// ---- drawing
const $ = (id) => document.getElementById(id);
const canvas = $("scene");
let dpr = 1, geom = null;

function colors() {
  const css = getComputedStyle(document.documentElement);
  return Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted"].map((k) => [k, css.getPropertyValue(`--${k}`).trim()]));
}

function layout() {
  dpr = window.devicePixelRatio || 1;
  const box = canvas.getBoundingClientRect();
  canvas.width = Math.round(box.width * dpr);
  canvas.height = Math.round(box.height * dpr);
  render();
}

// where things are, in CSS pixels: from the top, the names of the ends, the string's swing band (it swings as
// far down as up), then the bars with their header and numbers; the string gets 56 % of what the text leaves,
// less in a narrow, tall frame (an upright phone), where a long swing band would leave the bars too little
function geometry(W, H) {
  const pad = Math.max(18, 0.05 * W), rows = 22 + 8 + 22 + 20;    // ends' names, gap, bars' header, numbers
  const amp = Math.min(0.28 * Math.max(0, H - rows), 0.26 * W), mid = 22 + amp, top = mid + amp + 8;
  return {W, H, x0: pad, x1: W - pad, mid, amp, top, bars: {y0: top + 22, y1: H - 20}};
}
const xPixel = (g, frac) => g.x0 + frac * (g.x1 - g.x0);

function drawString(ctx, c, g, b) {
  ctx.font = "14px system-ui, sans-serif";
  ctx.fillStyle = c.muted; ctx.textBaseline = "bottom";
  ctx.textAlign = "left"; ctx.fillText("bridge", g.x0 - 6, g.mid - g.amp - 4);
  ctx.textAlign = "right"; ctx.fillText("nut", g.x1 + 6, g.mid - g.amp - 4);
  ctx.strokeStyle = c.muted; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(g.x0, g.mid); ctx.lineTo(g.x1, g.mid); ctx.stroke();            // the rest line
  ctx.textBaseline = "top"; ctx.textAlign = "center";
  for (const [frac, label] of [[1 / 10, "1/10"], [1 / 3, "1/3"], [1 / 2, "1/2"]]) {           // guides, from the bridge
    const x = xPixel(g, frac);
    ctx.beginPath(); ctx.moveTo(x, g.mid + 4); ctx.lineTo(x, g.mid + 10); ctx.stroke();      // under the rest line;
    ctx.fillText(label, x, g.mid + 12);                                                      // the string passes over
  }
  ctx.fillStyle = c.ink;
  for (const x of [g.x0, g.x1]) ctx.fillRect(x - 3, g.mid - 12, 6, 24);                      // bridge and nut
  let y;
  if (state.held || !state.plucked) {                                                        // a triangle, or at rest
    y = Array.from({length: POINTS + 1}, (_, i) => {
      const x = i / POINTS;
      return state.h * (x <= state.a ? x / state.a : (1 - x) / (1 - state.a));
    });
  } else {
    y = shape(b, state.t).map((v) => state.h * v);
  }
  ctx.strokeStyle = state.held || !state.plucked ? c.ink : c.accent; ctx.lineWidth = 2.5; ctx.lineJoin = "round";
  ctx.beginPath();
  y.forEach((v, i) => { const px = xPixel(g, i / POINTS), py = g.mid - v * g.amp; if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); });
  ctx.stroke();
  if (state.held) {
    ctx.fillStyle = c.ink;
    ctx.beginPath(); ctx.arc(xPixel(g, state.a), g.mid - state.h * g.amp, 6, 0, 2 * Math.PI); ctx.fill();
  }
  if (!state.touched) {
    ctx.fillStyle = c.fg; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.font = "16px system-ui, sans-serif";
    ctx.fillText("Pull the string", (g.x0 + g.x1) / 2, g.mid - 10);
  }
}

// the bars: |n·bn| / b1, how loud harmonic n sounds against the fundamental, fading after the release
function drawBars(ctx, c, g, b) {
  const {y0, y1} = g.bars, step = (g.x1 - g.x0) / BARS, width = 0.62 * step;
  ctx.font = "14px system-ui, sans-serif"; ctx.fillStyle = c.muted;
  ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillText("loudness", g.x0, y0 - 4);
  ctx.textAlign = "right"; ctx.fillText(`harmonic n, at n × ${F1} Hz`, g.x1, y0 - 4);
  ctx.strokeStyle = c.muted; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(g.x0, y1); ctx.lineTo(g.x1, y1); ctx.stroke();
  ctx.textAlign = "center"; ctx.textBaseline = "top";
  for (let n = 1; n <= BARS; n++) {
    const x = g.x0 + (n - 0.5) * step, full = Math.abs(n * b[n] / b[1]);
    const lit = !state.touched ? 0 : state.held ? full : full * Math.exp(-state.heard / TAU[n]);
    ctx.strokeStyle = c.muted; ctx.strokeRect(x - width / 2, y1 - full * (y1 - y0), width, full * (y1 - y0));
    ctx.fillStyle = c.accent; ctx.fillRect(x - width / 2, y1 - lit * (y1 - y0), width, lit * (y1 - y0));
    ctx.fillStyle = c.muted;
    if (full < 0.005) { ctx.textBaseline = "bottom"; ctx.fillText("0", x, y1 - 2); ctx.textBaseline = "top"; }
    if (step >= 20 || n % 2) ctx.fillText(String(n), x, y1 + 4);
  }
}

function render() {
  const c = colors(), ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  const W = canvas.width / dpr, H = canvas.height / dpr;
  ctx.fillStyle = c.bg; ctx.fillRect(0, 0, W, H);
  const b = coefficients(state.a);
  if (W > 0 && H > 0) {
    geom = geometry(W, H);
    drawString(ctx, c, geom, b);
    drawBars(ctx, c, geom, b);
  }
  $("share").textContent = `fundamental: ${Math.round(100 * fundamentalShare(b, state.a))} % of the energy`;
  $("where").textContent = `plucked at a = ${state.a.toFixed(2)} · ${(100 * state.a * L).toFixed(1)} cm from the bridge`;
  $("pitch").textContent = `${F1} Hz · picture slowed ${SLOW}×`;
  $("play").textContent = state.running ? "Pause" : "Play";
  $("sound").textContent = state.sound ? "Sound on" : "Sound off";
  $("sound").setAttribute("aria-pressed", state.sound);
}

// ---- controls: the string itself, the presets, and the keys
function set(partial) {                                       // game.set({a: 0.1, plucked: true, touched: true, h: 0.8})
  Object.assign(state, partial);
  if (audio) {                                                 // resuming is wakeAudio's job, inside a gesture
    master.gain.value = state.sound ? 1 : 0;
    if (!state.running && audio.state === "running") audio.suspend();
  }
  render();
}

function release() {
  set({held: false, plucked: true, t: 0, heard: 0});
  playPluck();
}

function pluckAt(a) {                                          // a preset: pull to 0.8 of the drawn range and let go
  set({a, h: 0.8, touched: true, running: true});
  wakeAudio();
  release();
}

// the pointer's place as a pluck: a clamped to the range and snapped to a nearby simple fraction
function grabAt(e) {
  const box = canvas.getBoundingClientRect(), g = geom;
  let a = ((e.clientX - box.left) - g.x0) / (g.x1 - g.x0);
  a = Math.min(A_MAX, Math.max(A_MIN, a));
  for (const s of DATA.snap) if (Math.abs(a - s) <= DATA.snap_within) a = s;
  const h = Math.min(1, Math.max(-1, (g.mid - (e.clientY - box.top)) / g.amp));
  set({a, h, held: true, touched: true});
}

canvas.addEventListener("pointerdown", (e) => {
  if (!geom || e.clientY - canvas.getBoundingClientRect().top > geom.top) return;   // the string's half only
  canvas.setPointerCapture(e.pointerId);
  if (!state.running) set({running: true});
  if (e.pointerType === "mouse") wakeAudio();               // a touch counts as a gesture only when it ends
  grabAt(e);
});
canvas.addEventListener("pointermove", (e) => { if (state.held) grabAt(e); });
const letGo = () => {
  if (!state.held) return;
  if (Math.abs(state.h) < 0.3) state.h = state.h < 0 ? -0.3 : 0.3;                      // a tap still plucks
  wakeAudio();
  release();
};
canvas.addEventListener("pointerup", letGo);
canvas.addEventListener("pointercancel", letGo);

const actions = {
  half: () => pluckAt(1 / 2), third: () => pluckAt(1 / 3), tenth: () => pluckAt(1 / 10),
  play: () => { set({running: !state.running}); wakeAudio(); },
  reset: () => { if (source) source.stop(); set({...DEFAULTS}); },
  sound: () => { set({sound: !state.sound}); wakeAudio(); },
};
for (const [id, act] of Object.entries(actions)) $(id).addEventListener("click", act);
const keys = {1: "half", 2: "third", 3: "tenth", " ": "play", r: "reset", s: "sound"};
document.addEventListener("keydown", (e) => {
  const key = keys[e.key.toLowerCase()];
  if (!key || (e.target instanceof HTMLButtonElement && e.key === " ")) return;
  e.preventDefault();
  actions[key]();
});

let last = null;
function frame(now) {                                          // called by the browser once per screen refresh
  const elapsed = last === null ? 0 : Math.min((now - last) / 1000, 0.05);   // capped: a background tab does not jump
  last = now;
  if (state.running && state.plucked && !state.held) {
    state.t += elapsed / SLOW;                                 // the picture runs SLOW times slower than the sound
    state.heard += elapsed;
  }
  render();
  requestAnimationFrame(frame);
}

window.addEventListener("resize", layout);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};

Two kinks run along the string

Press 1/3 and watch the string. It does not sway as one smooth arch. The corner at 21.7 cm splits into two kinks that run apart at the same speed, with the string flat between them; each kink flips upside down where it meets an end, and after half a period the string is the starting triangle mirrored and upside down, its corner at 43.3 cm below the line.

Show code
import json
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from scipy.fft import dst

plt.rcParams.update({
    "figure.figsize": (7, 3.6), "figure.dpi": 110,
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25,
    "font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"

L, F1 = 0.65, 110.0                 # the A string of a classical guitar: scale length / m, fundamental / Hz
C = 2 * L * F1                      # wave speed / m/s, from the round trip
T = 1 / F1                          # period / s
N_MODES = 50                        # the modes the game carries
SLOW = 1000                         # the picture runs 1000 times slower than the sound
A_RANGE = (0.05, 0.95)              # where the game lets you pluck, as a fraction of L from the bridge
PLUCKS = [1 / 2, 1 / 3, 1 / 10]
SNAP, SNAP_WITHIN = [1 / 2, 1 / 3, 2 / 3, 1 / 4, 3 / 4, 1 / 5, 1 / 10, 9 / 10], 0.01
FS, SECONDS, TAPER = 44100, 3.0, 0.25   # sample rate / Hz, length of a pluck's sound / s, its fade at the end / s
RMS = 0.15                          # every pluck's sound at the same rms, so the ear compares tone, not volume
n = np.arange(1, N_MODES + 1)
TAU_N = 3.0 / (1 + n / 8)           # decay time of harmonic n / s, a model: the high ones fade first
# No random numbers anywhere: every number on the page follows from the formulas.

def triangle(a, x):
    """The released string, height 1 at x = aL; x is measured from the bridge."""
    return np.where(x <= a * L, x / (a * L), (L - x) / ((1 - a) * L))

def coefficients(a, modes=N_MODES):
    """b_n / h for n = 1 .. modes: the triangle's Fourier coefficients."""
    k = np.arange(1, modes + 1)
    return 2 * np.sin(k * np.pi * a) / (k**2 * np.pi**2 * a * (1 - a))

def mode_sum(a, x, t):
    """y(x, t) / h as the game draws it: the sum of 50 modes, mode n swinging at n·f1."""
    b = coefficients(a)
    return (b * np.cos(2 * np.pi * n * F1 * t)) @ np.sin(np.outer(n, np.pi * x / L))

def extension(a, u):
    """d'Alembert's F: the triangle on [0, L], odd about 0, period 2L. Reduce u modulo 2L into [0, 2L);
    on the second half, oddness and the period give F(u) = F(u − 2L) = −F(2L − u)."""
    u = np.mod(u, 2 * L)
    return np.where(u <= L, triangle(a, np.minimum(u, L)), -triangle(a, np.clip(2 * L - u, 0, L)))

def dalembert(a, x, t):
    """y(x, t) / h exactly: half of F running right, half running left."""
    return 0.5 * (extension(a, x - C * t) + extension(a, x + C * t))

def kinks(a, t):
    """Where the kinks are at time t, in m: the corners of F, at ±aL modulo 2L, carried along by ±ct."""
    s = np.array([a * L - C * t, -a * L - C * t, a * L + C * t, -a * L + C * t])
    x = np.mod(s, 2 * L)
    return np.sort(x[(x > 1e-9) & (x < L - 1e-9)])

def bars(a, modes=16):
    """|n·b_n| / b_1: how loud harmonic n sounds against the fundamental, what the game's bars show."""
    k = np.arange(1, modes + 1)
    return np.abs(k * coefficients(a, modes) / coefficients(a, 1)[0])

def energy_shares(a, modes=N_MODES):
    """E_n / E, exact: 2 sin²(nπa) / (n²π² a(1 − a))."""
    k = np.arange(1, modes + 1)
    return 2 * np.sin(k * np.pi * a) ** 2 / (k**2 * np.pi**2 * a * (1 - a))

def pluck_sound(a, damped=True, seconds=SECONDS, fs=FS):
    """The game's synthesis in NumPy: the force on the bridge as 50 cosines at n·f1 with amplitudes n·b_n,
    each fading as exp(−t/τ_n), scaled to the rms RMS; the last TAPER seconds fall to zero."""
    t = np.arange(int(round(seconds * fs))) / fs
    amp = n * coefficients(a)
    amp = amp * RMS / np.sqrt(np.sum(amp**2) / 2)
    y = np.zeros_like(t)
    for k in range(N_MODES):
        wave = amp[k] * np.cos(2 * np.pi * n[k] * F1 * t)
        y += wave * np.exp(-t / TAU_N[k]) if damped else wave
    if damped:
        y *= np.clip((seconds - t) / TAPER, 0, 1)
    return y

def draw_scene(ax_string, ax_bars, a, readout=True):
    """The game's scene at the moment of release: the held string above, the bars |n·b_n| / b_1 below."""
    ax_string.set_axis_off()
    ax_string.set(xlim=(-4, 69), ylim=(-0.55, 1.45))
    ax_string.axhline(0, color=MUTED, lw=1, xmin=4 / 73, xmax=69 / 73)
    for frac, label in [(1 / 10, "1/10"), (1 / 3, "1/3"), (1 / 2, "1/2")]:
        ax_string.plot([100 * frac * L] * 2, [-0.18, -0.28], color=MUTED, lw=1)
        ax_string.text(100 * frac * L, -0.32, label, color=MUTED, ha="center", va="top")
    for x0, name, ha in [(0, "bridge", "left"), (100 * L, "nut", "right")]:
        ax_string.plot([x0, x0], [-0.12, 0.12], color=INK, lw=4, solid_capstyle="butt")
        ax_string.text(x0, 1.2, name, color=MUTED, ha=ha, va="bottom")
    x = np.linspace(0, L, 201)
    ax_string.plot(100 * x, triangle(a, x), color=INK, lw=2)
    ax_string.plot(100 * a * L, 1, "o", color=INK, ms=6)
    if readout:                                                     # the game's readout, top right, above the tallest bar
        ax_bars.text(0.99, 0.98, f"fundamental: {100 * energy_shares(a, 1)[0]:.0f} % of the energy",
                     transform=ax_bars.transAxes, color=ACCENT, ha="right", va="top", weight="bold")
    k = np.arange(1, 17)
    v = bars(a)
    ax_bars.bar(k, v, width=0.62, color=ACCENT)
    for kk in k[v < 0.005]:
        ax_bars.text(kk, 0.02, "0", color=MUTED, ha="center", va="bottom")
    ax_bars.set(xlim=(0.4, 16.6), ylim=(0, 1.3), xticks=[1, 4, 8, 12, 16], yticks=[0, 0.5, 1], xlabel="harmonic n")

print(f"c = {C:.0f} m/s, period {1e3 * T:.2f} ms, a kink crosses the string in {1e3 * L / C:.2f} ms")
c = 143 m/s, period 9.09 ms, a kink crosses the string in 4.55 ms
Show code
x = np.linspace(0, L, 400)
fig, axes = plt.subplots(5, 1, figsize=(7, 5), sharex=True, sharey=True, gridspec_kw=dict(hspace=0.25))
for ax, frac in zip(axes, [0, 1 / 12, 1 / 6, 1 / 3, 1 / 2]):
    t = frac * T
    ax.axhline(0, color=MUTED, lw=1)
    ax.plot(100 * x, triangle(1 / 3, x), color=MUTED, lw=1, alpha=0.5)
    ax.plot(100 * x, mode_sum(1 / 3, x, t), color=ACCENT)
    xk = kinks(1 / 3, t)
    ax.plot(100 * xk, dalembert(1 / 3, xk, t), "o", color=INK, ms=6)
    ax.text(0.99, 0.95, f"t = {1e3 * t:.2f} ms", transform=ax.transAxes, ha="right", va="top", color=INK)
    ax.set(ylim=(-1.25, 1.25), yticks=[-1, 0, 1])
axes[-1].set(xlim=(0, 100 * L), xlabel="x / cm from the bridge")
fig.supylabel("y / h", fontsize=11)
plt.show()
Five snapshots of the string plucked at a third, x in cm from the bridge, at t = 0, 0.76, 1.52, 3.03, and 4.55 ms. The corner at 21.7 cm splits into two kinks (dots) that run apart with a flat stretch between them, reflect upside down at the ends, and meet as an inverted triangle with its corner at 43.3 cm. Height exaggerated.

A disturbance on a string travels at \(c = \sqrt{\text{tension}/\text{mass per length}}\), whatever its shape. For a string released from rest, d'Alembert's solution of the wave equation splits the released shape into two halves that run apart:

\[y(x, t) = \tfrac12\bigl[F(x - ct) + F(x + ct)\bigr],\]

with \(F\) equal to the released triangle for \(0 \le x \le L\) and, outside the string, odd with a period of \(2L\):

\[F(-x) = -F(x), \qquad F(x + 2L) = F(x).\]

Oddness makes the halves cancel at the bridge, \(y(0, t) = \tfrac12[F(-ct) + F(ct)] = 0\), and the two rules together give \(F(L + s) = -F(L - s)\), so they cancel at the nut too. Beyond each end \(F\) is the string mirrored and upside down, so a kink leaving the string meets its upside-down image coming in: that is the reflection in the game. \(F\) repeats after \(2L\), so the motion repeats after \(2L/c\), a kink's round trip, and that period is \(1/f_1\): \(c = 2Lf_1\), 143 m/s for this string. Every motion of the string repeats after the same \(1/f_1\), which is why a plucked string gives a note and not a noise.

The game draws the string not from this formula but as a sum of 50 smooth sine arches, the string's modes of the next section. The hidden cell's mode_sum does the same, dalembert evaluates \(F\) exactly, and the cell below compares them.

print(f"c = 2 L f1 = {C:.0f} m/s;  L / c = {1e3 * L / C:.2f} ms;  1 / f1 = {1e3 * T:.2f} ms")
x = np.linspace(0, L, 400)
gap = max(np.abs(mode_sum(1 / 3, x, t) - dalembert(1 / 3, x, t)).max() for t in np.arange(120) * T / 120)
print(f"50 modes against d'Alembert, a = 1/3, 400 points x 120 times: {100 * gap:.1f} % of h at most")
back = np.abs(dalembert(1 / 3, x, T) - dalembert(1 / 3, x, 0)).max()
print(f"d'Alembert after one period, largest change: {back:.0e} h")
print(f"kinks at T/12: {', '.join(f'{100 * xk:.1f} cm' for xk in kinks(1 / 3, T / 12))}")
c = 2 L f1 = 143 m/s;  L / c = 4.55 ms;  1 / f1 = 9.09 ms
50 modes against d'Alembert, a = 1/3, 400 points x 120 times: 0.9 % of h at most
d'Alembert after one period, largest change: 6e-16 h
kinks at T/12: 10.8 cm, 32.5 cm

A kink crosses the 65 cm in 4.55 ms, and the shape returns after 9.09 ms, two crossings. The game's sum of 50 arches stays within 0.9 % of \(h\) of the exact solution, the difference sitting at the kinks, which smooth arches round off. After one period d'Alembert's string is back to 6e-16 of \(h\), zero to rounding.

A pluck at a third has no third harmonic

Hold the string at the middle and every even bar is empty. Slide your finger to a third and the 2nd comes back at half the fundamental, while every third bar goes empty. Press 1/2, then 1/3, to hear the difference.

Show code
fig, axes = plt.subplots(2, 2, figsize=(7.5, 4), sharey="row", gridspec_kw=dict(height_ratios=[1, 1.3], hspace=0.15, wspace=0.08))
for col, a in enumerate([1 / 2, 1 / 3]):
    draw_scene(axes[0, col], axes[1, col], a)
axes[1, 0].set_ylabel("loudness / fundamental")
plt.show()
The game at the moment of release, plucked at the middle (left) and at a third (right). Below each string, the loudness of harmonics 1 to 16 relative to the fundamental: at the middle every even bar is empty, at a third the 3rd, 6th, 9th, 12th, and 15th. Fundamental: 81 % and 68 % of the energy.

The string has shapes of its own, the sine arches \(\sin(n\pi x/L)\) with one hump, two, three, each swinging at \(n\) times 110 Hz, since arch \(n\) has the wavelength \(2L/n\) and \(c = 2Lf_1\): the harmonics. Any shape pinned at both ends is a sum of them, its Fourier series \(y(x, 0) = \sum_n b_n \sin(n\pi x/L)\), and the amount of each arch is twice the average, over the string, of the shape times that arch:

\[b_n = \frac{2}{L}\int_0^L y(x, 0)\,\sin\frac{n\pi x}{L}\,dx.\]

This works because the arches are orthogonal: two different ones multiplied together integrate to zero. For the triangle of height \(h\) with its corner at \(x = aL\), integrate by parts twice: the triangle's second derivative lives only at the corner, so only the corner contributes:

\[b_n = \frac{2h\,\sin(n\pi a)}{n^2\pi^2\,a(1 - a)}.\]

The factor \(\sin(n\pi a)\) is the \(n\)-th arch's height at the pluck point. The 3rd arch has a node at a third of the length, a point that never moves, and a pull there cannot set that mode going: \(b_3 = 0\), and with it \(b_6\) and \(b_9\). The bars, which show \(n\,b_n\) for a reason the next section gives, are empty there too. This is Thomas Young's law of 1800, which Helmholtz confirmed by ear with his resonators: a harmonic with a node at the pluck point is missing, and at \(p/q\) of the length in lowest terms every \(q\)-th harmonic is.

A shape you only have as numbers needs the integral as a sum. Sampled at \(N\) points \(x_j = jL/(N + 1)\) inside the string, the integral becomes

\[b_n \approx \frac{2}{N + 1}\sum_{j=1}^{N} y_j \sin\frac{n\pi j}{N + 1}.\]

This sum for all \(n\) at once is the type-1 discrete sine transform, scipy.fft.dst(y, type=1), fast and usable without knowing the FFT behind it. It includes the factor 2; divide by \(N + 1\) yourself.

for a in [1 / 2, 1 / 3]:
    print(f"a = 1/{1 / a:.0f}  b1 .. b9 / h: " + " ".join(f"{v:+.3f}" for v in np.round(coefficients(a, 9), 3) + 0.0))
print(f"b3 at a = 1/3: {coefficients(1 / 3)[2]:.1e} h")
N = 999
x_j = np.arange(1, N + 1) * L / (N + 1)
b_dst = dst(triangle(1 / 3, x_j), type=1)[:N_MODES] / (N + 1)
print(f"DST on {N} points against the formula, first {N_MODES} coefficients: {np.abs(b_dst - coefficients(1 / 3)).max():.0e} h at most")
a = 1/2  b1 .. b9 / h: +0.811 +0.000 -0.090 +0.000 +0.032 +0.000 -0.017 +0.000 +0.010
a = 1/3  b1 .. b9 / h: +0.790 +0.197 +0.000 -0.049 -0.032 +0.000 +0.016 +0.012 +0.000
b3 at a = 1/3: 1.2e-17 h
DST on 999 points against the formula, first 50 coefficients: 2e-07 h at most

At the middle the even coefficients print as zero; at a third the 3rd, 6th, and 9th do, \(b_3\) to 1e-17 of \(h\), which is rounding. The DST on 999 points matches the formula to 2e-7 of \(h\).

Near the bridge the tone turns bright

Pull the string close to the bridge, or press 1/10, and the bars fall slowly: the 2nd, 3rd, and 4th stand at 0.95, 0.87, and 0.77 of the fundamental, against 0, 0.33, and 0 at the middle, and the first empty bar is the 10th. The sound turns bright and twangy, and the readout falls from 81 % at the middle to 22 %.

Show code
fig, (ax_s, ax_b) = plt.subplots(2, 1, figsize=(7, 3.6), gridspec_kw=dict(height_ratios=[1, 1.3], hspace=0.15))
draw_scene(ax_s, ax_b, 1 / 10)
ax_b.set_ylabel("loudness / fundamental")
plt.show()
The game at the moment of release, plucked at a tenth of the length, 6.5 cm from the bridge. The loudness bars fall slowly from the fundamental, 0.95, 0.87, 0.77, 0.65 and on, down to the empty 10th harmonic. Fundamental: 22 % of the energy.

What reaches your ear is not the string's shape but the force on the bridge, which drives the guitar's top: the tension times the string's slope at the end. Harmonic \(n\)'s slope at the bridge is \(n\pi/L\) times its amplitude, so it sounds as \(n\,b_n \propto \sin(n\pi a)/n\), which the bars show relative to the fundamental. The square is the mode's energy, since kinetic energy goes with the squared speed and mode \(n\) moves \(n\) times as fast at the same amplitude. As shares of the total:

\[\frac{E_n}{E} = \frac{2\sin^2(n\pi a)}{n^2\pi^2\,a(1 - a)},\]

and they add up to 1 because \(\sum_n \sin^2(n\pi a)/n^2 = \pi^2 a(1 - a)/2\). The bar of harmonic \(n\) is \(\sin(n\pi a)/(n\sin\pi a)\). Near the bridge \(\sin(n\pi a) \approx n\pi a\) while \(n\pi a\) is small, so the bars start out nearly flat: at a tenth the 2nd stands at 0.95. By the 5th, \(n\pi a = \pi/2\), the sine has reached its top of 1, and the bar is down to \(1/(5\sin 18°) = 0.65\).

for a in PLUCKS:
    print(f"a = 1/{1 / a:<2.0f}  bars, n = 1 .. 10:     " + " ".join(f"{v:.2f}" for v in bars(a, 10)))
for a in PLUCKS:
    E = energy_shares(a)
    print(f"a = 1/{1 / a:<2.0f}  shares, n = 1 .. 6 / %: " + " ".join(f"{100 * v:4.1f}" for v in E[:6])
          + f"   above the 4th: {100 * (1 - E[:4].sum()):.1f} %")
print(f"fundamental at a = 1/2: {energy_shares(1 / 2)[0]:.4f};  8/π² = {8 / np.pi**2:.4f}")
a = 1/2   bars, n = 1 .. 10:     1.00 0.00 0.33 0.00 0.20 0.00 0.14 0.00 0.11 0.00
a = 1/3   bars, n = 1 .. 10:     1.00 0.50 0.00 0.25 0.20 0.00 0.14 0.13 0.00 0.10
a = 1/10  bars, n = 1 .. 10:     1.00 0.95 0.87 0.77 0.65 0.51 0.37 0.24 0.11 0.00
a = 1/2   shares, n = 1 .. 6 / %: 81.1  0.0  9.0  0.0  3.2  0.0   above the 4th: 9.9 %
a = 1/3   shares, n = 1 .. 6 / %: 68.4 17.1  0.0  4.3  2.7  0.0   above the 4th: 10.2 %
a = 1/10  shares, n = 1 .. 6 / %: 21.5 19.4 16.4 12.7  9.0  5.7   above the 4th: 29.9 %
fundamental at a = 1/2: 0.8106;  8/π² = 0.8106

The fundamental carries 81.1 % of the energy at the middle, exactly \(8/\pi^2\), 68.4 % at a third, and 21.5 % at a tenth. Above the 4th harmonic sit 9.9, 10.2, and 29.9 %, three times as much near the bridge. The chart draws both shares for every pluck point up to the middle.

Show code
a_grid = np.linspace(0.05, 0.5, 400)
fund = np.array([energy_shares(a, 1)[0] for a in a_grid])
high = np.array([1 - energy_shares(a, 4).sum() for a in a_grid])
fig, ax = plt.subplots(figsize=(7, 3.4))
ax.plot(a_grid, 100 * fund, color=ACCENT)
ax.plot(a_grid, 100 * high, color=SECOND)
for a in PLUCKS:
    f1, hi = 100 * energy_shares(a, 1)[0], 100 * (1 - energy_shares(a, 4).sum())
    ax.plot([a, a], [f1, hi], "o", color=INK, ms=6)
    if hi > f1:                                                      # at 1/10 the curves have crossed: one label below, one above right
        ax.annotate(f"{f1:.0f} %", (a, f1), xytext=(0, -10), textcoords="offset points", color=ACCENT, ha="center", va="top")
        ax.annotate(f"{hi:.0f} %", (a, hi), xytext=(8, 4), textcoords="offset points", color=SECOND, ha="left", va="bottom")
    else:                                                            # the rising red curve: its label above left, the flat teal one's below
        ax.annotate(f"{f1:.0f} %", (a, f1), xytext=(-6, 6), textcoords="offset points", color=ACCENT, ha="right", va="bottom")
        ax.annotate(f"{hi:.0f} %", (a, hi), xytext=(0, -8), textcoords="offset points", color=SECOND, ha="center", va="top")
ax.text(0.22, 52, "fundamental", color=ACCENT, ha="left")
ax.text(0.22, 20, "above the 4th harmonic", color=SECOND, ha="left")
ax.set(xlim=(0, 0.52), ylim=(0, 90), xticks=[0, 0.1, 1 / 3, 0.5], xticklabels=["0", "1/10", "1/3", "1/2"],
       xlabel="pluck point a (distance from the bridge / length)", ylabel="share of the energy / %")
plt.show()
Share of the energy in percent against the pluck point a, from 0.05 to 0.5. The fundamental (red) rises from 10 % near the bridge to 81 % at the middle; the share above the 4th harmonic (teal) falls from 60 % to about 10 % past a = 0.2. Dots at a = 1/10, 1/3, and 1/2.

The spectrum of the sound, its power at each frequency, shows the same in what the game plays: one second of each pluck without the fading, in dB relative to the fundamental. The circles are the formula's \(10\log_{10}(E_n/E_1)\), and the peaks meet them to 2e-12 dB. The even harmonics are gone at 1/2, every third at 1/3, and at 1/10 a slow slope runs down to the missing 10th.

Show code
fig, axes = plt.subplots(3, 1, figsize=(7, 5.4), sharex=True, sharey=True, gridspec_kw=dict(hspace=0.2))
worst = 0.0
for ax, a in zip(axes, PLUCKS):
    y = pluck_sound(a, damped=False, seconds=1.0)
    power = np.abs(np.fft.rfft(y)) ** 2
    f = np.fft.rfftfreq(len(y), 1 / FS)
    rel = 10 * np.log10(np.maximum(power / power[110], 1e-30))
    shares = energy_shares(a)
    present = shares > 1e-12 * shares[0]
    formula = 10 * np.log10(shares[present] / shares[0])
    worst = max(worst, np.abs(rel[110 * n[present]] - formula).max())
    ax.vlines(110 * n[~present & (n <= 20)], -40, 0, color=MUTED, ls="--", lw=1)   # the missing harmonics, under the label row
    shown = f <= 2300
    ax.plot(f[shown], np.maximum(rel[shown], -60), color=ACCENT, lw=1.4)
    keep = n[present] <= 20
    ax.plot(110 * n[present][keep], formula[keep], "o", mfc="none", color=INK, ms=6)
    ax.text(0.99, 0.98, f"a = 1/{1 / a:.0f}", transform=ax.transAxes, ha="right", va="top", color=INK)
    ax.set(ylim=(-40, 10), yticks=[-40, -20, 0])
axes[-1].set(xlim=(0, 2300), xlabel="f / Hz")
fig.supylabel("power / dB rel. fundamental", fontsize=11)
plt.show()
print(f"largest difference between the peaks and the formula: {worst:.0e} dB")
Power of the synthesized sound in dB relative to the fundamental, 0 to 2.3 kHz, for plucks at 1/2, 1/3, and 1/10 of the length, stacked. Peaks at multiples of 110 Hz sit on the circles of the formula; dashed lines mark the missing harmonics: every even one, every third one, the 10th and 20th.
largest difference between the peaks and the formula: 2e-12 dB

How the game computes it

The state. The game keeps in a single object the pluck point \(a\), the pull \(h\) as a fraction of the drawn range, whether the string is held, touched, and plucked, the string's time \(t\), the real time since the release, and whether picture and sound run.

const DEFAULTS = {a: 1 / 3, h: 0, held: false, plucked: false, touched: false, t: 0, heard: 0};
const state = {...DEFAULTS, running: true, sound: true};

The pluck. While you hold the string, the game draws the triangle through your finger, \(a\) kept within 0.05 to 0.95 and snapped to a simple fraction within 0.01, and computes the coefficients from the formula for \(b_n\) with \(h = 1\):

for (let n = 1; n <= N; n++) b[n] = 2 * Math.sin(n * Math.PI * a) / (n * n * Math.PI ** 2 * a * (1 - a));

The bars draw Math.abs(n * b[n] / b[1]). The readout needs \(\sum_n n^2 b_n^2\) over all modes, and the identity behind the energy shares gives it in closed form, \(2/(\pi^2 a(1 - a))\):

const fundamentalShare = (b, a) => b[1] ** 2 * Math.PI ** 2 * a * (1 - a) / 2;

One tick. On every repaint, about 60 times a second, the browser runs frame. It divides the real time since the last call, at most 50 ms, by SLOW = 1000, adds it to \(t\), and draws the string at 201 points as 50 modes summed, from a table SIN[n][i] of the arches there:

for (let n = 1; n <= N; n++) {
  const w = b[n] * Math.cos(2 * Math.PI * n * F1 * t);
  for (let i = 0; i <= POINTS; i++) y[i] += w * SIN[n][i];
}

The kinks come out of the sum. The picture does not fade; the bars do, with the sound.

The sound. On release the game fills an AudioBuffer, a block of samples the browser plays as it is, with 3 s of the force on the bridge: 50 cosines at \(n \cdot 110\) Hz with amplitudes \(n\,b_n\), each fading as \(e^{-t/\tau_n}\) with \(\tau_n = 3\text{ s}/(1 + n/8)\) (a model), all scaled to the same rms. An AudioBufferSourceNode plays it.

const w = 2 * Math.PI * k * F1 / fs, fade = Math.exp(-1 / (TAU[k] * fs));
let env = amp;
for (let i = 0; i < n; i++) { out[i] += env * Math.cos(w * i); env *= fade; }

The last 0.25 s fall to zero, so the end does not click. Browsers allow sound only after a click, a key, or a touch, which counts only when the finger lifts: on a phone the audio starts as your finger leaves the string.

From the notebook into the game. The constants, the decay times, and the rms sit in the page itself as JSON, numbers and lists in plain text, inside a <script type="application/json" id="data"> element the game reads. Nothing else is loaded, so the downloaded file plays offline.

The game against the notebook. The browser plays samples between −1 and 1, its full scale; a sample beyond is cut off, clipped, and crackles. The cell runs the game's synthesis for the presets, one point between, and 91 pluck points across the allowed range, and finds the loudest sample and the energy the 50 modes hold:

for a in PLUCKS + [0.27]:
    print(f"a = {a:.3f}: peak sample {np.abs(pluck_sound(a)).max():.3f}")
a_check = np.linspace(*A_RANGE, 91)
peaks = np.array([np.abs(pluck_sound(a)).max() for a in a_check])
held = np.array([energy_shares(a).sum() for a in a_check])
print(f"91 pluck points from {A_RANGE[0]} to {A_RANGE[1]}: largest peak {peaks.max():.3f} at a = {a_check[peaks.argmax()]:.2f}; "
      f"the 50 modes hold at least {100 * held.min():.1f} % of the energy, at a = {a_check[held.argmin()]:.2f}")
a = 0.500: peak sample 0.177
a = 0.333: peak sample 0.242
a = 0.100: peak sample 0.506
a = 0.270: peak sample 0.277
91 pluck points from 0.05 to 0.95: largest peak 0.713 at a = 0.05; the 50 modes hold at least 95.8 % of the energy, at a = 0.05

The loudest sample, for a pluck 3.3 cm from the bridge, is 0.71, so the sound never clips. There the 50 modes also hold their least, 95.8 % of the energy; closer to the bridge they would miss more, which is why the game stops you at 0.05.

The cell that writes the game. An HTML parser closes a script element at the first </script it meets, data or not, so the cell turns every </ in the JSON into <\/. The hidden cell above holds the page around the game.

Show code
# The page around the game: the site's light and dark colors, the scene, the readouts, and the controls.
# game.js fills in every number; the notebook fills in DATA and SCRIPT.
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pluck a guitar string</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg);
  font: 15px/1.3 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: grid; gap: 4px; padding: 4px 6px 6px; grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr) auto auto; grid-template-areas: "scene" "readouts" "controls"; }
#view { grid-area: scene; position: relative; min-height: 0; }
#scene { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }
#readouts { grid-area: readouts; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 12px;
  font-variant-numeric: tabular-nums; }
#share { color: var(--accent); font-weight: 600; font-size: 18px; }
#where, #pitch { color: var(--muted); font-size: 14px; }
#controls { grid-area: controls; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.label { color: var(--muted); }
.seg { display: flex; } .seg button + button { margin-left: -1.5px; }
button { font: inherit; color: var(--second); background: none; border: 1.5px solid var(--second); border-radius: 4px;
  padding: 0.2em 0.7em; touch-action: manipulation; }
button[aria-pressed="true"], #play { color: var(--bg); background: var(--second); }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
@media (min-width: 600px) { body { gap: 6px 16px; padding: 10px; } #share { font-size: 22px; } }
@media (min-width: 600px) and (max-height: 399.98px) {   /* a phone held sideways: controls in a panel beside */
  body { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr);
    grid-template-areas: "scene readouts" "scene controls"; }
  #readouts { display: grid; align-content: start; max-width: 15em; }
  #controls { align-content: start; max-width: 15em; }
}
</style>
</head>
<body>
<div id="view"><canvas id="scene" aria-label="A guitar string between bridge and nut, with bars for the loudness of its harmonics"></canvas></div>
<div id="readouts"><span id="share"></span><span id="where"></span><span id="pitch"></span></div>
<div id="controls">
  <span class="label">pluck at, from the bridge:</span>
  <div class="seg" role="group" aria-label="Pluck at a fraction of the length from the bridge">
    <button id="half" title="1">1/2</button><button id="third" title="2">1/3</button><button id="tenth" title="3">1/10</button></div>
  <button id="play" title="Space"></button><button id="reset" title="R">Reset</button><button id="sound" title="S"></button>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
game_data = {
    "length": L, "f1": F1, "modes": N_MODES, "slow": SLOW, "points": 200, "bars": 16,
    "a_range": list(A_RANGE), "snap": [round(s, 6) for s in SNAP], "snap_within": SNAP_WITHIN,
    "tau": [round(float(v), 4) for v in TAU_N], "rms": RMS, "seconds": SECONDS, "taper": TAPER,
}
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(game_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/string/game.js").read_text(encoding="utf-8")
html = TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script)
Path("assets").mkdir(exist_ok=True)
Path("assets/string.html").write_text(html, encoding="utf-8", newline="\n")
print(f"assets/string.html: {len(data)} bytes of data, {Path('assets/string.html').stat().st_size / 1024:.0f} kB in all")
assets/string.html: 529 bytes of data, 16 kB in all

The data are 529 bytes, because the game computes everything else from the formulas; the file is 16 kB.

Variations

  • Your own shape. Pluck with a broad fingertip: sample its shape at 999 points, take the DST as above, and play the coefficients through the synthesis loop instead of the formula. Rounding the corner at a third over 2 cm keeps 0.85 of the 20th harmonic's amplitude and 0.48 of the 40th: a soft finger sounds darker than a pick.
  • An electric guitar's pickup. A magnetic pickup at the fraction \(p\) of the length from the bridge hears harmonic \(n\) in proportion to \(\sin(n\pi p)\) as well; multiply the amplitudes in the synthesis loop by it. With the pickup at a fifth and the pluck at a third, seven of the first 16 harmonics are silent: the 3rd, 5th, 6th, 9th, 10th, 12th, and 15th.
  • Strike instead of pluck. A hammer gives the string a velocity instead of a shape. For a narrow hammer at \(a\) the coefficients go as \(\sin(n\pi a)/n\), each mode starts as a sine, and the bars become \(|\sin(n\pi a)/\sin(\pi a)|\), which do not fall with \(n\): at an eighth, the 4th harmonic sounds 2.6 times as strong as the fundamental.
  • A stiff string. The game's string is ideal, with no stiffness and no guitar body taking energy away. Set the frequencies to \(n f_1 \sqrt{1 + Bn^2}\) with \(B = 10^{-4}\); the 10th harmonic moves up by 8.6 cents, a cent being a hundredth of a semitone, the shape no longer returns after a period, and the kinks smear.

Further reading

Was this tutorial helpful? Sign in to tell the author with one click.

Found a mistake, or something unclear? Report a problem (with a free account).

Cite this tutorial

SciStack (2026). Plucked string harmonics: why a pluck at a third has no third harmonic. https://scistack.dev/t/py-plucked-string/ (accessed 2026-10-19).

@online{scistack-py-plucked-string,
  author  = {{SciStack}},
  title   = {Plucked string harmonics: why a pluck at a third has no third harmonic},
  date    = {2026-10-19},
  url     = {https://scistack.dev/t/py-plucked-string/},
  urldate = {2026-10-19},
  note    = {numpy 2.4.3, scipy 1.18.1, matplotlib 3.11.2}
}

Tags

canvasdalembertdstfourier-seriesguitarharmonicsjavascriptmatplotlibnumpyovertonesplucked-stringscipy.ffttimbrewave-equationwebaudio

Comments

No comments yet.

Sign in to comment, with a free account.