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-seconds 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     'beeper': 'beeper',
 282     'beeps': 'beeper',
 283     'bell': 'bells',
 284     'bells': 'bells',
 285     'busy': 'busy',
 286     'door-ajar': 'door-ajar',
 287     'doorajar': 'door-ajar',
 288     'heart': 'heart',
 289     'heart-beat': 'heart',
 290     'heart-beats': 'heart',
 291     'heartbeat': 'heart',
 292     'heartbeats': 'heart',
 293     'laser': 'laser',
 294     'noise': 'noise',
 295     'ready': 'ready',
 296     'ring-tone': 'ringtone',
 297     'ringtone': 'ringtone',
 298     'thud': 'thud',
 299     '440': 'tone',
 300     '440hz': 'tone',
 301     'tone': 'tone',
 302     'woo-woah-wow': 'woo-woah-wow',
 303     'woo': 'woo-woah-wow',
 304     'woah': 'woo-woah-wow',
 305     'wow': 'woo-woah-wow',
 306 
 307     'bust-a-move': 'bust-a-move',
 308     'bustamove': 'bust-a-move',
 309     'crazy': 'crazy',
 310     'piano': 'piano-loop',
 311     'piano-loop': 'piano-loop',
 312     'pianoloop': 'piano-loop',
 313     'walk-this-way': 'walk-this-way',
 314     'walkthisway': 'walk-this-way',
 315 }
 316 
 317 entries = {
 318     'beeper': (beeper, 'beep some TV-related devices used to make'),
 319     'bells': (bell, 'a synthetic bell'),
 320     'busy': (busy, 'a busy phone'),
 321     'door-ajar': (door_ajar, 'door-ajar warning sound'),
 322     'heart': (heartbeat, 'pairs of heart-pulses'),
 323     'laser': (laser, 'a stereotypical laser sound, once a second'),
 324     'noise': (noise, 'uniform random noise (annoying)'),
 325     'ready': (ready, 'a ready phone'),
 326     'ringtone': (ringtone, 'a slightly annoying ringtone'),
 327     'thud': (thud, 'a low beat, similar to those in nightclubs'),
 328     'tone': (tone, 'a 440hz tuning tone'),
 329     'woo-woah-wow': (woo_woah_wow, 'a crazy sound (loop: 1.25s)'),
 330 
 331     'bust-a-move': (bust_a_move, 'middle part of that song (loop: 4.09s)'),
 332     'crazy': (crazy, 'a repeating tune (NOT FULLY WORKING) (loop: 6.5s)'),
 333     'piano-loop': (piano_loop, 'a few repeating piano notes (loop: 1.025s)'),
 334     'walk-this-way': (walk_this_way, 'drums from that song (loop: 2.2s)'),
 335 }
 336 
 337 
 338 def show_help(w):
 339     print(info.strip(), file=w)
 340     print('', file=w)
 341     print('Sound-effects available', file=w)
 342     print('', file=w)
 343     for name, (_, note) in entries.items():
 344         print(f'    {name:16}    {note}', file=w)
 345     print('', file=w)
 346     print('Aliases available', file=w)
 347     print('', file=w)
 348     for k in aliases.keys():
 349         print(f'    {k}', file=w)
 350 
 351 
 352 # handle standard help cmd-line options, quitting right away in that case
 353 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
 354     show_help(stdout)
 355     exit(0)
 356 
 357 
 358 output_opts = (
 359     '-o', '--o', '-out', '--out', '-output', '--output',
 360 )
 361 
 362 args = argv[1:]
 363 name = ''
 364 output = False
 365 
 366 if len(args) > 0:
 367     name = args[0]
 368     args = args[1:]
 369 else:
 370     show_help(stderr)
 371     exit(1)
 372 
 373 if name in aliases:
 374     name = aliases[name]
 375 
 376 if len(args) > 0 and args[0] in output_opts:
 377     args = args[1:]
 378     output = True
 379 
 380 # default duration is 1 second, but a float argument can override it
 381 duration = 1.0
 382 if len(args) > 0:
 383     try:
 384         duration = float(args[0])
 385         args = args[1:]
 386         if duration < 0 or isnan(duration) or isinf(duration):
 387             duration = 0.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 volume = 1.0
 396 if len(args) > 0:
 397     try:
 398         volume = float(args[0])
 399         args = args[1:]
 400         if volume < 0 or volume > 1 or isnan(volume) or isinf(volume):
 401             volume = 1.0
 402     except Exception:
 403         pass
 404 
 405 if len(args) > 0 and args[0] in output_opts:
 406     args = args[1:]
 407     output = True
 408 
 409 # demand the output option, to behave more like ringtone.c
 410 if not output:
 411     print('live-sound mode not supported: use option -o instead', file=stderr)
 412     exit(1)
 413 
 414 if not name in entries:
 415     print(f'no sound-effect named "{name}" is available', file=stderr)
 416     exit(1)
 417 
 418 rate = 48000.0
 419 dt = 1.0 / rate
 420 samples = int(ceil(duration * rate))
 421 packer = Struct('<h')
 422 sound = entries[name][0]
 423 
 424 try:
 425     with open_wav(stdout.buffer, 'wb') as w:
 426         w.setparams((1, 2, rate, samples, 'NONE', 'none'))
 427         for i in range(samples):
 428             t = i * dt
 429             f = volume * sound(i, t)
 430             f = max(-1.0, f)
 431             f = min(f, +1.0)
 432             w.writeframesraw(packer.pack(int(32767.0 * f)))
 433 except BrokenPipeError:
 434     # quit quietly, instead of showing a confusing error message
 435     stderr.close()
 436     exit(0)
 437 except KeyboardInterrupt:
 438     exit(2)