File: sboard.py
   1 #!/usr/bin/python
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright (c) 2026 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the "Software"), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 info = '''
  27 sboard [options...] [sound...] [duration...] [volume...]
  28 
  29 
  30 Sound BOARD plays the sound given by name, lasting the number of seconds
  31 given, or 1 second by default. The audio output is 16-bit samples at 48khz.
  32 There's also an optional volume argument, which is 1 by default.
  33 
  34 Options, all of which can start with either 1 or 2 dashes:
  35 
  36     -h, -help            show this help message
  37     -o, -out, -output    emit WAV-format bytes to standard output
  38 '''
  39 
  40 
  41 from math import ceil, cos, e, exp, floor, fmod, isinf, isnan, log, sin, tau
  42 from random import random
  43 from struct import Struct
  44 from sys import argv, exit, stderr, stdout
  45 from wave import open as open_wav
  46 
  47 
  48 def beeper(i, t):
  49     return sin(2000 * tau * t) * (fmod(t, 0.5) < 0.0625)
  50 
  51 def bell(i, t):
  52     u = fmod(t, 1)
  53     return sin(880 * tau * u) * exp(-10 * u)
  54 
  55 # busy-phone tone
  56 def busy(i, t):
  57     u = fmod(t, 1)
  58     k = min(1, exp(-90 * (u - 0.5)))
  59     return k * (sin(480 * tau * t) + sin(620 * tau * t)) / 2
  60 
  61 def door_ajar(i, t):
  62     u = fmod(t, 1)
  63     return sin(660 * tau * u) * exp(-10 * u)
  64 
  65 def heartbeat(i, t):
  66     beat = 0.0
  67     u = fmod(t, 1)
  68     beat += sin(12 * tau * exp(-20 * u)) * exp(-2 * u)
  69     u = max(0, u - 0.25)
  70     beat += sin(8 * tau * exp(-20 * u)) * exp(-2 * u)
  71     return 0.5 * beat
  72 
  73 # a once-a-second stereotypical laser sound
  74 def laser(i, t):
  75     u = fmod(t, 1)
  76     return sin(100 * tau * exp(-40 * u))
  77 
  78 # flat/uniform random noise
  79 def noise(i, t):
  80     return 2 * random() - 1
  81 
  82 # ready-phone tone
  83 def ready(i, t):
  84     return 0.5 * sin(350 * tau * t) + 0.5 * sin(450 * tau * t)
  85 
  86 # annoying ringtone
  87 def ringtone(i, t):
  88     u = fmod(t, 0.1)
  89     return sin(2048 * tau * t) * exp(-50 * u)
  90 
  91 def thud(i, t):
  92     u = fmod(t, 1)
  93     return sin(12 * tau * exp(-20 * u)) * exp(-2 * u)
  94 
  95 # 440hz tuning tone
  96 def tone(i, t):
  97     return sin(440 * tau * t)
  98 
  99 def woo_woah_wow(i, t):
 100     # period = 1.3
 101     period = 1.25
 102     u = fmod(t, period)
 103     return sin(tau * (260 * sin(tau * u)) * u)
 104 
 105 
 106 # functions used by fancier tunes
 107 
 108 def kick(t, f, k, p):
 109     return sin(tau * f * pow(p, t)) * exp(-k * t)
 110 
 111 def default_kick(t, f, k):
 112     return kick(t, f, k, 0.085)
 113 
 114 # flat/uniform random noise
 115 def random_uniform():
 116     return 2 * random() - 1
 117 
 118 def hit_hi_hat(t, k):
 119     return random_uniform() * exp(-k * t)
 120 
 121 def schedule(t, delay, period):
 122     return fmod(t + (1 - delay) * period, period)
 123 
 124 def arp(x, y, z, k, t):
 125     u = fmod(t / 2, k)
 126     return sin(x * (exp(-y * u))) * exp(-z * u)
 127 
 128 def linterp(x, y, k):
 129     return k * x + (1 - k) * y
 130 
 131 def power_synth(t, freq):
 132     # function linspace(a, b, n) {
 133     #     const y = new Array(n);
 134     #     const incr = (b - a) / (n + 1);
 135     #     for (let i = 0; i < n; i++)
 136     #         y[i] = a + incr * i;
 137     #     return y;
 138     # }
 139     # bass_powers = linspace(1e-5, 1, 10).map(x => -0.05 * Math.log(x));
 140 
 141     powers = [
 142         0.5756462732485115, 0.11988976388990187,
 143         0.08523515466254475, 0.06496281589095718,
 144         0.05057917059158016, 0.03942226802181348,
 145         0.030306373513585217, 0.022598970473683477,
 146         0.015922499056278294, 0.010033423662119907,
 147     ]
 148 
 149     res = 0.0
 150     for i, p in enumerate(powers):
 151         res += p * cos(tau * (i + 1) * freq * t)
 152     return res
 153 
 154 def bass_envelope(t):
 155     u = fmod(t, 1)
 156     return 15 * u if (u < 0.05) else exp(-7 * u)
 157 
 158 # fancier tunes
 159 
 160 def bust_a_move(i, t):
 161     period = 4.09
 162     freqs = [
 163         50, 75, 50, 50, 75, 50, 50, 50, 75, 50, 50, 50, 50, 50, 75, 50
 164     ]
 165     # const delays = [
 166     #     0, 0.52, 0.77, 1.28, 1.54, 1.79,
 167     #     2.04, 2.3, 2.56, 2.67,
 168     #     3.05, 3.07, 3.2, 3.45, 3.57, 3.82
 169     # ].map(x => x / period);
 170     delays = [
 171         0.0000000000000000, 0.1271393643031785,
 172         0.1882640586797066, 0.31295843520782396,
 173         0.3765281173594132, 0.43765281173594134,
 174         0.49877750611246946, 0.5623471882640586,
 175         0.6259168704156479, 0.6528117359413202,
 176         0.745721271393643, 0.7506112469437652,
 177         0.78239608801956, 0.8435207823960881,
 178         0.8728606356968215, 0.9339853300733496,
 179     ]
 180 
 181     u = fmod(t, period)
 182     half = int(len(freqs) / 2)
 183     start = 0 if u < 2.5 else half
 184 
 185     kicks = 0.0
 186     for i in range(half):
 187         d = delays[start + i]
 188         f = freqs[start + i]
 189         kicks += default_kick(schedule(t, d, period), f, 50)
 190 
 191     hi_hats = 0.0
 192     for i in range(half):
 193         hi_hats += 0*hit_hi_hat(schedule(t, i / half, period / half), 25)
 194 
 195     return 0.9 * kicks + 1.0 / 32 * hi_hats
 196 
 197 def crazy(i, t):
 198     snares = [
 199         0, 0, 1, 0, 0, 0, 1, 0,
 200         0, 0, 1, 0, 0, 0, 1, 0,
 201         0, 0, 1, 0, 0, 0, 1, 0,
 202         0, 0, 1, 0, 0, 0, 1, 0,
 203         0, 0, 1, 0, 0, 0, 1, 0,
 204         0, 0, 1, 0, 0, 0, 1, 0,
 205         0, 0, 1, 0, 0, 0, 1, 0,
 206         0, 0, 0, 0, 0, 0, 0, 0,
 207     ]
 208 
 209     kicks = [
 210         1, 0, 0, 0, 1, 0, 0, 0,
 211         1, 0, 0, 0, 1, 0, 0, 0,
 212         1, 0, 0, 0, 1, 0, 0, 0,
 213         1, 1, 0, 1, 1, 0, 1, 1,
 214         1, 0, 0, 0, 1, 0, 0, 0,
 215         1, 0, 0, 0, 1, 0, 0, 0,
 216         1, 0, 0, 0, 1, 0, 0, 0,
 217         1, 1, 1, 1, 1, 1, 1, 1,
 218     ]
 219 
 220     bass_speed = [
 221         2, 2, 2, 2, 2, 2, 2, 2,
 222         2, 2, 2, 2, 2, 2, 2, 2,
 223         2, 2, 2, 2, 2, 2, 2, 2,
 224         2, 2, 2, 2, 2, 2, 2, 2,
 225         2, 2, 2, 2, 2, 2, 2, 2,
 226         2, 2, 2, 2, 2, 2, 2, 2,
 227         2, 2, 2, 2, 2, 2, 2, 2,
 228         4, 4, 4, 4, 4, 4, 4, 4,
 229     ]
 230 
 231     def seq(d, s, t):
 232         return d[floor(t / s / 2) % len(kicks)]
 233 
 234     t *= 148.0 / 120
 235     # period = 6.5
 236     # u = fmod(t, period)
 237     # v = fmod(t, 2*period)
 238     rand = linterp(random(), 1, 1.0 / 2)
 239     anticlip = max(-log(.75 * fmod(8 * t, 1) + 1.0 / e), 0)
 240     k = anticlip * arp(60, 40, 20, 1.0 / 16, t) * seq(kicks, 1.0 / 16, t)
 241     s = 0.3 * arp(60, 80, 3, 1.0 / 16, t) * rand * seq(snares, 1.0 / 16, t)
 242     su = seq(bass_speed, 1.0 / 16, t)
 243     b1 = bass_envelope(su * t) * power_synth(su * t, 50.0 / su)
 244     v = power_synth(su * (t + 0.5 * 6.5), 60.0 / su)
 245     b2 = bass_envelope(su * (t + 0.5 * 6.5)) * v
 246     b = b1 + b2
 247     return 0.9 * k + 0.7 * s + 0.7 * b
 248 
 249 def piano(t, n):
 250     p = (n - 49) / 12
 251     f = 440 * pow(2, p)
 252     return sin(tau * f * t)
 253 
 254 def piano_loop(i, t):
 255     period = 1.025
 256     cutoff = 12
 257     p = period
 258     y = 0
 259     y += piano(t, 49) * exp(-cutoff * fmod(t, period))
 260     y += piano(t + 0.25 * p, 50) * exp(-cutoff * fmod(t + 0.25 * p, p))
 261     y += piano(t + 0.50 * p, 54) * exp(-cutoff * fmod(t + 0.50 * p, p))
 262     y += piano(t + 0.75 * p, 51) * exp(-cutoff * fmod(t + 0.75 * p, p))
 263     return 0.75 * y
 264 
 265 def walk_this_way(i, t):
 266     period = 2.2
 267     freqs = [50, 70, 50, 50, 50, 70]
 268     delays = [0.000, 0.250, 0.450, 0.500, 0.625, 0.750]
 269     n = len(delays)
 270 
 271     kicks = 0
 272     for i in range(n):
 273         kicks += default_kick(schedule(t, delays[i], period), freqs[i], 50)
 274     hi_hats = 1.2 * hit_hi_hat(schedule(t, 0, period), 7)
 275     for i in range(n):
 276         hi_hats += hit_hi_hat(schedule(t, i, period / 8), 30)
 277     return 1 * kicks + 0.1 * hi_hats
 278 
 279 
 280 aliases = {
 281     'beeps': 'beeper',
 282     'bell': 'bells',
 283     'doorajar': 'door-ajar',
 284     'heartbeat': 'heart',
 285     'heartbeats': 'heart',
 286     '440': 'tone',
 287     '440hz': 'tone',
 288     'woo': 'woo-woah-wow',
 289     'woah': 'woo-woah-wow',
 290     'wow': 'woo-woah-wow',
 291     'woowoahwow': 'woo-woah-wow',
 292 
 293     'bustamove': 'bust-a-move',
 294     'crazy': 'crazy',
 295     'piano': 'piano-loop',
 296     'pianoloop': 'piano-loop',
 297     'walkthisway': 'walk-this-way',
 298 }
 299 
 300 entries = {
 301     'beeper': (beeper, 'beep some TV-related devices used to make'),
 302     'bells': (bell, 'a synthetic bell'),
 303     'busy': (busy, 'a busy phone'),
 304     'door-ajar': (door_ajar, 'door-ajar warning sound'),
 305     'heart': (heartbeat, 'pairs of heart-pulses'),
 306     'laser': (laser, 'a stereotypical laser sound, once a second'),
 307     'noise': (noise, 'uniform random noise (annoying)'),
 308     'ready': (ready, 'a ready phone'),
 309     'ringtone': (ringtone, 'a slightly annoying ringtone'),
 310     'thud': (thud, 'a low beat, similar to those in nightclubs'),
 311     'tone': (tone, 'a 440hz tuning tone'),
 312     'woo-woah-wow': (woo_woah_wow, 'a crazy sound (loop: 1.25s)'),
 313 
 314     'bust-a-move': (bust_a_move, 'middle part of that song (loop: 4.09s)'),
 315     'crazy': (crazy, 'a repeating tune (NOT FULLY WORKING) (loop: 6.5s)'),
 316     'piano-loop': (piano_loop, 'a few repeating piano notes (loop: 1.025s)'),
 317     'walk-this-way': (walk_this_way, 'drums from that song (loop: 2.2s)'),
 318 }
 319 
 320 
 321 def show_help(w):
 322     print(info.strip(), file=w)
 323     print('', file=w)
 324     print('Sound-effects available', file=w)
 325     print('', file=w)
 326     for name, (_, note) in entries.items():
 327         print(f'    {name:16}    {note}', file=w)
 328     print('', file=w)
 329     print('Aliases available', file=w)
 330     print('', file=w)
 331     for k in aliases.keys():
 332         print(f'    {k}', file=w)
 333 
 334 
 335 # handle standard help cmd-line options, quitting right away in that case
 336 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
 337     show_help(stdout)
 338     exit(0)
 339 
 340 
 341 output_opts = ('-o', '--o', '-out', '--out', '-output', '--output')
 342 
 343 args = argv[1:]
 344 name = ''
 345 output = False
 346 
 347 if len(args) > 0 and args[0] in output_opts:
 348     args = args[1:]
 349     output = True
 350 
 351 if len(args) > 0:
 352     name = args[0]
 353     args = args[1:]
 354 else:
 355     show_help(stderr)
 356     exit(1)
 357 
 358 s = name.replace('-', '')
 359 if s in aliases:
 360     name = aliases[s]
 361 
 362 if len(args) > 0 and args[0] in output_opts:
 363     args = args[1:]
 364     output = True
 365 
 366 # default duration is 1 second, but a float argument can override it
 367 duration = 1.0
 368 if len(args) > 0:
 369     try:
 370         duration = float(args[0])
 371         args = args[1:]
 372         if duration < 0 or isnan(duration) or isinf(duration):
 373             duration = 0.0
 374     except Exception:
 375         pass
 376 
 377 if len(args) > 0 and args[0] in output_opts:
 378     args = args[1:]
 379     output = True
 380 
 381 volume = 1.0
 382 if len(args) > 0:
 383     try:
 384         volume = float(args[0])
 385         args = args[1:]
 386         if volume < 0 or volume > 1 or isnan(volume) or isinf(volume):
 387             volume = 1.0
 388     except Exception:
 389         pass
 390 
 391 if len(args) > 0 and args[0] in output_opts:
 392     args = args[1:]
 393     output = True
 394 
 395 # demand the output option, to behave more like ringtone.c
 396 if not output:
 397     print('live-sound mode not supported: use option -o instead', file=stderr)
 398     exit(1)
 399 
 400 if not name in entries:
 401     print(f'no sound-effect named "{name}" is available', file=stderr)
 402     exit(1)
 403 
 404 rate = 48000.0
 405 dt = 1.0 / rate
 406 samples = int(ceil(duration * rate))
 407 packer = Struct('<h')
 408 sound = entries[name][0]
 409 
 410 try:
 411     with open_wav(stdout.buffer, 'wb') as w:
 412         w.setparams((1, 2, rate, samples, 'NONE', 'none'))
 413         for i in range(samples):
 414             t = i * dt
 415             f = volume * sound(i, t)
 416             f = max(-1.0, f)
 417             f = min(f, +1.0)
 418             w.writeframesraw(packer.pack(int(32767.0 * f)))
 419 except BrokenPipeError:
 420     # quit quietly, instead of showing a confusing error message
 421     stderr.close()
 422     exit(0)
 423 except KeyboardInterrupt:
 424     exit(2)