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 info = '''
  27 waveout [seconds...] [python expressions...]
  28 
  29 
  30 WAVE OUTput emits wave-format audio using the expression given to calculate
  31 samples. The audio output format is RIFF WAVE (wav) sampled at 48khz.
  32 
  33 The duration is an optional floating-point number of seconds: if not given,
  34 the default duration is 1 second.
  35 
  36 The formulas/expressions can use floating-point value `t`, which has the
  37 current time, integer `i`, which is the 0-based index of the current sample
  38 being calculated, as well as all names (functions and values) from the
  39 built-in python modules `math` and `random`. A few extra convenience funcs
  40 are also defined and are available to the expressions.
  41 
  42 The formulas are expected to result in floating-point values between -1.0
  43 and +1.0; other types of results will end in an error.
  44 
  45 The number of expressions determines the number of sound-channels, such as
  46 mono (1 formula/channel), or stereo (2 formulas/channels): you can use as
  47 many formulas/channels as you want.
  48 
  49 Using a single dot (`.`) as a formula reuses the previous formula, as a
  50 shortcut.
  51 
  52 While many of the examples below save to files, you can just pipe to a media
  53 player such as mpv, to play results directly instead.
  54 
  55 
  56 Examples
  57 
  58 # thuds commonly used in club music as beats
  59 waveout 2 'sin(10 * tau * exp(-20 * u)) * exp(-2 * u)' > club-beats.wav
  60 
  61 # a heartbeat-like sound lasting 2 seconds
  62 # waveout 2 'sum(sin(10*tau*exp(-20*v))*exp(-2*v) for v in (u, (u-0.25)%1))/2'
  63 
  64 # 1 minute and 5 seconds of static-like random noise
  65 waveout 65 'random()' > random-noise.wav
  66 
  67 # many bell-like clicks in quick succession; can be a cellphone's ringtone
  68 waveout 'sin(2048 * tau * t) * exp(-50 * (t%0.1))' > ringtone.wav
  69 
  70 # (vaguely) similar to the door-opening sound from a home alarm
  71 waveout 'sin(4096 * tau * t) * exp(-10 * (t%0.1))' > home-alarm.wav
  72 
  73 # watch your ears: quickly increases frequency up to 2khz
  74 waveout 'sin(2_000 * t * tau * t)' > frequency-sweep.wav
  75 
  76 # 1-second 400hz test tone
  77 waveout 'sin(400 * tau * t)' > test-tone-400.wav
  78 
  79 # 2s of a 440hz test tone, also called an A440 sound
  80 waveout 2 'sin(440 * tau * t)' > a440.wav
  81 
  82 # 1s 400hz test tone with sudden volume drop at the end, to avoid clip
  83 waveout 'sin(400 * tau * t) * min(1, exp(-100*(t-0.9)))' > nice-tone.wav
  84 
  85 # old ringtone used in north america
  86 waveout '0.5*sin(350 * tau * t) + 0.5*sin(450 * tau * t)' > na-ringtone.wav
  87 
  88 # 20 seconds of periodic pings
  89 waveout 20 'sin(800 * tau * u) * exp(-20 * u)' > pings.wav
  90 
  91 # 2 seconds of a european-style dial-tone
  92 waveout 2 '(sin(350 * tau * t) + sin(450 * tau * t)) / 2' > euro-dial-tone.wav
  93 
  94 # 4 seconds of a north-american-style busy-phone signal
  95 # 'min(1, exp(-90*(u-0.5))) * (sin(480*tau*t) + sin(620*tau*t)) / 2'
  96 waveout 4 '(u < 0.5) * (sin(480*tau * t) + sin(620*tau * t)) / 2' > na-busy.wav
  97 
  98 # hit the 51st key on a synthetic piano-like instrument
  99 waveout 'sin(tau * 440 * 2**((51 - 49)/12) * t) * exp(-10*u)' > piano-key.wav
 100 
 101 # hit of a synthetic snare-like sound
 102 waveout 'random() * exp(-10 * t)' > synth-snare.wav
 103 
 104 # a stereotypical `laser` sound
 105 waveout 'sin(100 * tau * exp(-40 * t))' > laser.wav
 106 
 107 # a heartbeat-like sound, lasting 4 seconds
 108 waveout 4 'sum(
 109     sin(a*tau*exp(-20*b)) * exp(-2*b) for (a, b) in ((12, u), (8, (u-0.25)%1))
 110 ) / 2' > heartbeat.wav
 111 
 112 # a funny/wobbly sound, lasting 1.3 seconds
 113 waveout 1.3 'sin(tau * (260 * sin(tau * t)) * t)' > woo-woah-wow.wav
 114 '''
 115 
 116 
 117 import math
 118 from math import \
 119     acos, acosh, asin, asinh, atan, atan2, atanh, cbrt, ceil, comb, \
 120     copysign, cos, cosh, degrees, dist, e, erf, erfc, exp, exp2, expm1, \
 121     fabs, factorial, floor, fmod, frexp, fsum, gamma, gcd, hypot, inf, \
 122     isclose, isfinite, isinf, isnan, isqrt, lcm, ldexp, lgamma, log, \
 123     log10, log1p, log2, modf, nan, nextafter, perm, pi, pow, prod, \
 124     radians, remainder, sin, sinh, sqrt, tan, tanh, tau, trunc, ulp
 125 
 126 Math = math
 127 power = pow
 128 
 129 from random import \
 130     betavariate, choice, choices, expovariate, gammavariate, gauss, \
 131     getrandbits, getstate, lognormvariate, normalvariate, paretovariate, \
 132     randbytes, randint, random, randrange, sample, seed, setstate, \
 133     shuffle, triangular, uniform, vonmisesvariate, weibullvariate
 134 
 135 from struct import Struct
 136 from sys import argv, exit, stderr, stdout
 137 from wave import open as open_wav
 138 
 139 
 140 def clamp(x, lowest, highest):
 141     return max(lowest, min(x, highest))
 142 
 143 def deinf(x, default):
 144     return default if isinf(x) else x
 145 
 146 def denan(x, default):
 147     return default if isnan(x) else x
 148 
 149 def ln(x):
 150     return log(x) if x >= 0 else nan
 151 
 152 def osc(x, freq = 1, phase = 0):
 153     return sin(freq * tau * (x + phase))
 154 
 155 def revalue(x, default):
 156     return default if isinf(x) or isnan(x) else x
 157 
 158 def safe(f, x):
 159     try:
 160         return f(x)
 161     except Exception:
 162         return nan
 163 
 164 recover = safe
 165 rescue = safe
 166 trycall = safe
 167 
 168 def scale(x, x0, x1, y0, y1):
 169     return (y1 - y0) * (x - x0) / (x1 - x0) + y0
 170 
 171 rescale = scale
 172 rescaled = scale
 173 scaled = scale
 174 
 175 def sinc(x):
 176     return sin(x) / x if x != 0.0 else 1.0
 177 
 178 def until(t, until, k = 100):
 179     'Keep a volume multiplier at 1, until an exponential drop-off.'
 180     return min(1, exp(-abs(k) * (t - until)))
 181 
 182 def unwrap(x, y0, y1):
 183     return (y1 - y0) * x + y0
 184 
 185 def wrap(x, y0, y1):
 186     return (x - y0) / (y1 - y0)
 187 
 188 
 189 # handle standard help cmd-line options, quitting right away in that case
 190 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
 191     print(info.strip(), file=stderr)
 192     exit(0)
 193 
 194 args = argv[1:]
 195 
 196 # default duration is 1 second, but a leading float argument can override it
 197 duration = 1.0
 198 if len(args) > 0:
 199     try:
 200         duration = float(args[0])
 201         if duration < 0 or isnan(duration) or isinf(duration):
 202             duration = 0.0
 203         args = args[1:]
 204     except Exception:
 205         pass
 206 
 207 # use all remaining args as formulas for the audio channel(s): single
 208 # dots just reuse the previous formula, as a shortcut
 209 sources = []
 210 prev_src = '0.0'
 211 for s in args:
 212     if s == '.':
 213         s = prev_src
 214     sources.append(s)
 215     prev_src = s
 216 
 217 if len(sources) == 0:
 218     print(info.strip(), file=stderr)
 219     exit(0)
 220 
 221 try:
 222     # giving func eval compiled strings considerably speeds things up
 223     comp = lambda i, s: compile(s, f'<channel {i+1}>', 'eval')
 224     expressions = [comp(i, s) for i, s in enumerate(sources)]
 225 
 226     with open_wav(stdout.buffer, 'wb') as w:
 227         rate = 48_000
 228         samples = int(duration * rate)
 229 
 230         open = open_wav = comp = compile = None
 231         w.setparams((len(sources), 2, rate, samples, 'NONE', 'none'))
 232 
 233         _ = f = 0.0
 234         dt = 1.0 / rate
 235         packer = Struct('<h')
 236 
 237         for i in range(samples):
 238             t = i * dt
 239             u = fmod(t, 1.0)
 240 
 241             for e in expressions:
 242                 _ = f = min(max(eval(e), -1.0), +1.0)
 243                 w.writeframesraw(packer.pack(int(32_767 * f)))
 244 except BrokenPipeError:
 245     # quit quietly, instead of showing a confusing error message
 246     stderr.close()
 247 except KeyboardInterrupt:
 248     exit(2)
 249 except Exception as e:
 250     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 251     exit(1)