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)