Qu

Signal processing

Waveform generators

the `signals.` namespace sugar for the five periodic-waveform generators (square, impulse, pwm, sawtooth, triangle).

catalog/qu_signal_generators.qu

Backstory (see docs/qu-language-tour.md's write-up of the 2026-09-01 argument-validation audit): `square` the *waveform generator* and "square" as in "x squared" are both completely reasonable readings of the bare name `square(...)` to a new reader, and that ambiguity is a real footgun in a language where `x^2` and `square(freq, fs, n)` can both plausibly appear on the same page. The fix wasn't renaming anything -- the bare `square`/`impulse`/`pwm`/`sawtooth`/`triangle` builtins are unchanged and still callable directly -- it was adding a closed, parser-level `signals.*` namespace alias (the same mechanism `table.load`/`timer.start` already used) that rewrites `signals.square(...)` straight to the exact same `Call` AST as `square(...)`, byte-identical, zero runtime cost, pure disambiguation. This demo always spells the namespaced form for that reason, even though the bare names would produce identical output.

What it exercises

How it works

Shared timebase

Fs = 1000
N  = 200
t  = (0 to N - 1) / Fs

Generate all five, all through the disambiguating signals.* spelling

sq  = signals.square(10, Fs, N, duty=0.5)
imp = signals.impulse(N, index=20, amplitude=1.0)
saw = signals.sawtooth(10, Fs, N)
tri = signals.triangle(10, Fs, N)

# pwm needs a modulator signal to compare against its triangle carrier --
# a slow sine, well within [-1, 1], sampled at the same Fs/N.
modulator = 0.6 * sin(2 * pi * 3 * t)
pwm_sig = signals.pwm(modulator, 50, Fs)

print("square  : first 5 samples = {sq[0:4]}")          # +1 while duty phase < 0.5
print("impulse : nonzero at index 20 = {imp[20]}, elsewhere e.g. imp[0] = {imp[0]}")
print("sawtooth: first 5 samples = {saw[0:4]}")          # linear ramp from -1
print("triangle: first 5 samples = {tri[0:4]}")          # linear ramp from -1, different slope than sawtooth
print("pwm     : first 5 samples = {pwm_sig[0:4]}")     # +-1, natural-sampled against a triangle carrier

# Same instant (sample 55), five different generators -- the values below
# are read straight off each waveform, not hand-picked to differ.
i = 55
print("at sample {i}: square={sq[i]}, impulse={imp[i]}, sawtooth={saw[i]:.3f}, triangle={tri[i]:.3f}, pwm={pwm_sig[i]}")

# Prove square is genuinely a WAVEFORM, not "x squared": compare against the
# actual squaring operation on the same time vector to show they are nothing
# alike -- this is precisely the confusion `signals.square` exists to avoid.
squared_t = t ^ 2
print("signals.square(10,{Fs},{N})[0:2] = {sq[0:2]}  vs.  t^2 [0:2] = {squared_t[0:2]:.5f} -- unrelated waveforms")

Overlaid plot with a legend and distinct colors

plot(t, sq,      label="square (duty=0.5)", color="blue")
plot(t, imp,     label="impulse (idx=20)",  color="black")
plot(t, saw,     label="sawtooth",          color="orange")
plot(t, tri,     label="triangle",          color="green")
plot(t, pwm_sig, label="pwm (mod=3Hz sine)", color="red")
xlabel("Time (s)")
ylabel("Amplitude")
title("signals.* generators: square / impulse / sawtooth / triangle / pwm")
legend()
show plot

Results

printed
square  : first 5 samples = signal(Fs=1000) [1, 1, 1, 1, 1]
impulse : nonzero at index 20 = 1, elsewhere e.g. imp[0] = 0
sawtooth: first 5 samples = signal(Fs=1000) [-1, -0.98, -0.96, -0.94, -0.92]
triangle: first 5 samples = signal(Fs=1000) [-1, -0.96, -0.92, -0.88, -0.84]
pwm     : first 5 samples = signal(Fs=1000) [1, 1, 1, 1, 1]
at sample 55: square=-1, impulse=0, sawtooth=0.100, triangle=0.800, pwm=1
signals.square(10,1000,200)[0:2] = signal(Fs=1000) [1, 1, 1]  vs.  t^2 [0:2] = [0, 1e-6, 4e-6] -- unrelated waveforms
-1.0 -0.8 -0.6 -0.4 -0.2 0 0.2 0.4 0.6 0.8 1.0 0 0.04 0.08 0.12 0.16 0.20 signals.* generators: square / impulse / sawtooth / triangle / pwm Time (s) Amplitude square (duty=0.5) impulse (idx=20) sawtooth triangle pwm (mod=3Hz sine)
Drawn by running the program above.

The whole program

Runnable as it stands: qu run catalog/qu_signal_generators.qu

# qu_signal_generators.qu -- the `signals.` namespace sugar for the five

# periodic-waveform generators (square, impulse, pwm, sawtooth, triangle).

#

# Backstory (see docs/qu-language-tour.md's write-up of the 2026-09-01

# argument-validation audit): `square` the *waveform generator* and "square"

# as in "x squared" are both completely reasonable readings of the bare

# name `square(...)` to a new reader, and that ambiguity is a real footgun

# in a language where `x^2` and `square(freq, fs, n)` can both plausibly

# appear on the same page. The fix wasn't renaming anything -- the bare

# `square`/`impulse`/`pwm`/`sawtooth`/`triangle` builtins are unchanged and

# still callable directly -- it was adding a closed, parser-level

# `signals.*` namespace alias (the same mechanism `table.load`/`timer.start`

# already used) that rewrites `signals.square(...)` straight to the exact

# same `Call` AST as `square(...)`, byte-identical, zero runtime cost, pure

# disambiguation. This demo always spells the namespaced form for that

# reason, even though the bare names would produce identical output.

#

# Constructs exercised (all real, verified-working builtins):

#   signals.square(freq, fs, n, [duty=])   -- bipolar +-1 pulse train

#   signals.impulse(n, [index=], [amplitude=]) -- single spike in a zero vector

#   signals.pwm(modulator, carrier_freq, fs, [carrier=]) -- natural-sampling PWM

#   signals.sawtooth(freq, fs, n)          -- linear ramp -1 -> +1, instant drop

#   signals.triangle(freq, fs, n)          -- continuous -1 -> +1 -> -1 ramp

#   plot(t, x, label=, color=) / legend() / subplot-free overlay


backend auto

#%% Shared timebase

Fs = 1000
N  = 200
t  = (0 to N - 1) / Fs

#%% Generate all five, all through the disambiguating signals.* spelling

sq  = signals.square(10, Fs, N, duty=0.5)
imp = signals.impulse(N, index=20, amplitude=1.0)
saw = signals.sawtooth(10, Fs, N)
tri = signals.triangle(10, Fs, N)

# pwm needs a modulator signal to compare against its triangle carrier --

# a slow sine, well within [-1, 1], sampled at the same Fs/N.

modulator = 0.6 * sin(2 * pi * 3 * t)
pwm_sig = signals.pwm(modulator, 50, Fs)

print("square  : first 5 samples = {sq[0:4]}")          # +1 while duty phase < 0.5

print("impulse : nonzero at index 20 = {imp[20]}, elsewhere e.g. imp[0] = {imp[0]}")
print("sawtooth: first 5 samples = {saw[0:4]}")          # linear ramp from -1

print("triangle: first 5 samples = {tri[0:4]}")          # linear ramp from -1, different slope than sawtooth

print("pwm     : first 5 samples = {pwm_sig[0:4]}")     # +-1, natural-sampled against a triangle carrier


# Same instant (sample 55), five different generators -- the values below

# are read straight off each waveform, not hand-picked to differ.

i = 55
print("at sample {i}: square={sq[i]}, impulse={imp[i]}, sawtooth={saw[i]:.3f}, triangle={tri[i]:.3f}, pwm={pwm_sig[i]}")

# Prove square is genuinely a WAVEFORM, not "x squared": compare against the

# actual squaring operation on the same time vector to show they are nothing

# alike -- this is precisely the confusion `signals.square` exists to avoid.

squared_t = t ^ 2
print("signals.square(10,{Fs},{N})[0:2] = {sq[0:2]}  vs.  t^2 [0:2] = {squared_t[0:2]:.5f} -- unrelated waveforms")

#%% Overlaid plot with a legend and distinct colors

plot(t, sq,      label="square (duty=0.5)", color="blue")
plot(t, imp,     label="impulse (idx=20)",  color="black")
plot(t, saw,     label="sawtooth",          color="orange")
plot(t, tri,     label="triangle",          color="green")
plot(t, pwm_sig, label="pwm (mod=3Hz sine)", color="red")
xlabel("Time (s)")
ylabel("Amplitude")
title("signals.* generators: square / impulse / sawtooth / triangle / pwm")
legend()
show plot