File: waveout.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2024 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 # waveout [options...] [duration...] [python expression]
  27 #
  28 # WAVE OUTput emits wave-format audio using the expression given to calculate
  29 # samples. The audio output is stereo (unless changed to mono) at 48khz, and
  30 # is a valid wav file as is, if saved to a file.
  31 #
  32 # The duration is an optional floating-point number of seconds: if not given,
  33 # the default duration is 1 second.
  34 #
  35 # The formula/expression can use floating-point value `t`, which has the
  36 # current time, integer `i`, which is the 0-based index of the current sample
  37 # being calculated, as well as all names (functions and values) from the
  38 # built-in python modules `math` and `random`.
  39 #
  40 # The formula is expected to result in floating-point values between -1.0
  41 # and +1.0; other types of results will end in an error.
  42 #
  43 # Leading options let you change the number of channels emitted, namely
  44 # mono (1 channel) or stereo (2 channels): you can use `mono`, `-mono`,
  45 # `--mono`, `stereo`, `-stereo`, or `--stereo` for the desired result.
  46 #
  47 # Running this script with no arguments will show a similar help message,
  48 # followed by several working examples.
  49 #
  50 # While many of those examples save to files, you can just pipe to a media
  51 # player such as mpv, to play results directly instead.
  52 
  53 
  54 import math
  55 from math import \
  56     acos, acosh, asin, asinh, atan, atan2, atanh, ceil, comb, \
  57     copysign, cos, cosh, degrees, dist, e, erf, erfc, exp, expm1, \
  58     fabs, factorial, floor, fmod, frexp, fsum, gamma, gcd, hypot, inf, \
  59     isclose, isfinite, isinf, isnan, isqrt, lcm, ldexp, lgamma, log, \
  60     log10, log1p, log2, modf, nan, nextafter, perm, pi, pow, prod, \
  61     radians, remainder, sin, sinh, sqrt, tan, tanh, tau, trunc, ulp
  62 try:
  63     from math import cbrt, exp2
  64 except:
  65     pass
  66 
  67 from random import \
  68     betavariate, choice, choices, expovariate, gammavariate, gauss, \
  69     getrandbits, getstate, lognormvariate, normalvariate, paretovariate, \
  70     randbytes, randint, random, randrange, sample, seed, setstate, \
  71     shuffle, triangular, uniform, vonmisesvariate, weibullvariate
  72 
  73 from struct import pack
  74 from sys import argv, exit, stderr, stdout
  75 from wave import open as open_wav, Wave_write
  76 
  77 
  78 # info is the help message shown when asked to
  79 info = '''
  80 waveout [seconds...] [python expression]
  81 
  82 WAVE OUTput emits wave-format audio using the expression given to calculate
  83 samples. The audio output is stereo (unless changed to mono) at 48khz, and
  84 is a valid wav file as is, if saved to a file.
  85 
  86 The duration is an optional floating-point number of seconds: if not given,
  87 the default duration is 1 second.
  88 
  89 The formula/expression can use floating-point value `t`, which has the
  90 current time, integer `i`, which is the 0-based index of the current sample
  91 being calculated, as well as all names (functions and values) from the
  92 built-in python modules `math` and `random`.
  93 
  94 The formula is expected to result in floating-point values between -1.0
  95 and +1.0; other types of results will end in an error.
  96 
  97 Leading options let you change the number of channels emitted, namely mono
  98 (1 channel) or stereo (2 channels): you can use `mono`, `-mono`, `--mono`,
  99 `stereo`, `-stereo`, or `--stereo` for the desired result.
 100 
 101 While many of the examples below save to files, you can just pipe to a media
 102 player such as mpv, to play results directly instead.
 103 
 104 
 105 Concrete Examples
 106 
 107 # low-tones commonly used in club music as beats
 108 waveout 2 'sin(10 * tau * exp(-20 * u)) * exp(-2 * u)' > club-beats.wav
 109 
 110 # 1 minute and 5 seconds of static-like random noise
 111 waveout 65 'random()' > random-noise.wav
 112 
 113 # many bell-like clicks in quick succession; can be a cellphone's ringtone
 114 waveout 'sin(2048 * tau * t) * exp(-50 * (t%0.1))' > ringtone.wav
 115 
 116 # similar to the door-opening sound from a dsc powerseries home alarm
 117 waveout 'sin(4096 * tau * t) * exp(-10 * (t%0.1))' > home-alarm.wav
 118 
 119 # watch your ears: quickly increases frequency up to 2khz
 120 waveout 'sin(2_000 * t * tau * t)' > frequency-sweep.wav
 121 
 122 # 1-second 400hz test tone
 123 waveout 'sin(400 * tau * t)' > test-tone-400.wav
 124 
 125 # 2s of a 440hz test tone, also called an A440 sound
 126 waveout 2 'sin(440 * tau * t)' > a440.wav
 127 
 128 # 1s 400hz test tone with sudden volume drop at the end, to avoid clip
 129 waveout 'sin(400 * tau * t) * min(1, exp(-100*(t-0.9)))' > nice-tone.wav
 130 
 131 # old ringtone used in north america
 132 waveout '0.5*sin(350 * tau * t) + 0.5*sin(450 * tau * t)' > na-ringtone.wav
 133 
 134 # 20 seconds of periodic pings
 135 waveout 20 'sin(800 * tau * u) * exp(-20 * u)' > pings.wav
 136 
 137 # 2 seconds of a european-style dial-tone
 138 waveout 2 '(sin(350 * tau * t) + sin(450 * tau * t)) / 2' > euro-dial-tone.wav
 139 
 140 # 4 seconds of a north-american-style busy-phone signal
 141 waveout 4 '(u < 0.5) * (sin(480*tau * t) + sin(620*tau * t)) / 2' > na-busy.wav
 142 
 143 # hit the 51st key on a synthetic piano-like instrument
 144 waveout 'sin(tau * 440 * 2**((51 - 49)/12) * t) * exp(-10*u)' > piano-key.wav
 145 
 146 # hit of a synthetic snare-like sound
 147 waveout 'random() * exp(-10 * t)' > synth-snare.wav
 148 
 149 # a stereotypical `laser` sound
 150 waveout 'sin(100 * tau * exp(-40 * t))' > laser.wav
 151 
 152 # a funny/wobbly sound, lasting 1.3 seconds
 153 waveout 1.3 'sin(tau * (260 * sin(tau * t)) * t)' > woo-woah-wow.wav
 154 '''.strip()
 155 
 156 # handle standard help cmd-line options, quitting right away in that case
 157 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
 158     print(info, file=stderr)
 159     exit(0)
 160 
 161 
 162 def clamp(x, lowest, highest):
 163     '''
 164     Keep the first number given between the other two. The second number
 165     is supposed to be less than or equal to the third number given.
 166     '''
 167     return max(lowest, min(x, highest))
 168 
 169 
 170 def deinf(x, default):
 171     '''Replace either kind of floating-point infinity with another value.'''
 172     return x if not isinf(x) else default
 173 
 174 
 175 def denan(x, default):
 176     '''Replace a floating-point NaN with an alternative value.'''
 177     return x if not isinf(x) else default
 178 
 179 
 180 def osc(x):
 181     '''
 182     This `oscillator` func is just a handy shortcut to calculate the sine,
 183     after multiplying the argument by 2 * pi.
 184     '''
 185     return sin(tau * x)
 186 
 187 
 188 def revalue(x, default):
 189     '''Replace floating-point NaNs and infinities with another value.'''
 190     return x if not (isinf(x) or isnan(x)) else default
 191 
 192 
 193 def until(t, until, k=100):
 194     '''
 195     Return a volume multiplier, which is 1 until a sudden drop-off starting
 196     at the time given.
 197     '''
 198     return min(1, exp(-abs(k) * (t - until)))
 199 
 200 
 201 def run(w: Wave_write, src: str, dur: float, rate: int, nchan: int) -> None:
 202     # prevent expression from using some global values
 203     argv = None
 204     exec = None
 205     exit = None
 206     info = None
 207     open = None
 208     open_wav = None
 209     stderr = None
 210     stdout = None
 211 
 212     t = 0.0
 213     u = 0.0
 214     dt = 1.0 / rate
 215     prev = 0.0
 216     duration = dur
 217     channels = nchan
 218     samples = int(duration * rate)
 219     # giving func eval a compiled string considerably speeds things up
 220     expr = compile(src, '<string>', 'eval')
 221 
 222     # the channel-count check is outside the loop to speed things up
 223     if nchan == 1:
 224         for i in range(samples):
 225             t = i * dt
 226             u = fmod(t, 1.0)
 227             prev = min(max(eval(expr), -1.0), +1.0)
 228             d = pack('<h', int(32_767 * prev))
 229             w.writeframesraw(d)
 230     elif nchan == 2:
 231         for i in range(samples):
 232             t = i * dt
 233             u = fmod(t, 1.0)
 234             prev = min(max(eval(expr), -1.0), +1.0)
 235             d = pack('<h', int(32_767 * prev))
 236             w.writeframesraw(d)
 237             w.writeframesraw(d)
 238     else:
 239         # internal error which should never happen
 240         raise ValueError('channel count can only be 1 or 2')
 241 
 242 
 243 src = ''
 244 duration = 0.0
 245 channels = 2
 246 
 247 args = argv[1:]
 248 # handle leading options
 249 if len(args) > 0 and args[0].lower() in ('mono', '-mono', '--mono'):
 250     channels = 1
 251     args = args[1:]
 252 elif len(args) > 0 and args[0].lower() in ('stereo', '-stereo', '--stereo'):
 253     channels = 2
 254     args = args[1:]
 255 
 256 # handle later options
 257 if len(args) == 0:
 258     print(info, file=stderr)
 259     exit(0)
 260 elif len(args) == 1:
 261     src = args[0]
 262     duration = 1.0
 263 elif len(args) == 2:
 264     src = args[1]
 265     try:
 266         duration = float(args[0])
 267         if duration < 0 or isnan(duration) or isinf(duration):
 268             duration = 0.0
 269     except Exception as e:
 270         print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 271         exit(1)
 272 else:
 273     print('\x1b[31mexpected either 1 or 2 args\x1b[0m', file=stderr)
 274     exit(1)
 275 
 276 try:
 277     rate = 48_000
 278     samples = int(duration * rate)
 279     params = (channels, 2, rate, samples, 'NONE', 'none')
 280     with open_wav(stdout.buffer, 'wb') as w:
 281         w.setparams(params)
 282         run(w, src, duration, rate, channels)
 283 except (BrokenPipeError, KeyboardInterrupt):
 284     # quit quietly, instead of showing a confusing error message
 285     stderr.flush()
 286     stderr.close()
 287 except Exception as e:
 288     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 289     exit(1)