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)