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)