"""DIY sound-effects recipes. Python 3 standard library; no account or network.
Created for Renaissance Solutions with Codex; reviewed by Mike Besso.
Run: python3 make-effects.py --output my-sounds
Windows with Python installed: py make-effects.py --output my-sounds
Writes seven WAVs; use a new output folder to keep previous experiments.
Knock take 1 derives from the Story Studio procedural cue.
"""
from pathlib import Path
import argparse
import math
import random
import struct
import wave

RATE = 22050

def cue(kind):
    rng = random.Random(762)
    duration = {'door': 1.5, 'workshop': 2.0, 'bird': 1.8}[kind]
    samples = []
    for i in range(round(RATE * duration)):
        t = i / RATE
        if kind == 'door':
            # Three low, decaying wooden knocks, separated by silence.
            sample = sum((math.sin(2 * math.pi * 180 * (t - start)) + .25 * rng.uniform(-1, 1))
                         * math.exp(-35 * (t - start)) * .25
                         for start in (.1, .38, .9) if 0 <= t - start < .22)
        elif kind == 'workshop':
            # Soft brushing/sanding pulses; no sudden sustained loud tone.
            envelope = max(0, math.sin(2 * math.pi * 2 * t)) ** 2
            sample = rng.uniform(-1, 1) * .09 * envelope
        else:
            # A short ascending whistle, repeated with a rest.
            sample = 0
            for start in (.15, .85):
                x = t - start
                if 0 <= x < .38:
                    envelope = math.sin(math.pi * x / .38) ** 2
                    sample += .12 * envelope * math.sin(2 * math.pi * (950 * x + 650 * x * x))
        samples.append(sample)
    return samples

def smooth_noise(seconds, seed):
    rng = random.Random(seed)
    raw = [rng.uniform(-1, 1) for _ in range(round(seconds * RATE))]
    # Circular smoothing preserves the periodic construction at the loop seam.
    return [sum(raw[(i + k) % len(raw)] for k in range(-2, 3)) / 5
            for i in range(len(raw))]

def rain():
    noise = smooth_noise(8, 921)
    result = []
    for i, n in enumerate(noise):
        t = i / RATE
        # Every modulation frequency completes whole cycles within eight seconds.
        level = .13 * (1 + .12 * math.sin(2 * math.pi * t / 8)
                       + .06 * math.sin(2 * math.pi * 3 * t / 8))
        result.append(level * n)
    return result

def radio_layers():
    count = RATE * 5
    click, wake, tone = ([0.0] * count for _ in range(3))
    noise = smooth_noise(5, 922)
    for i in range(count):
        t = i / RATE
        x = t - .25
        if 0 <= x < .12:
            click[i] = .22 * math.exp(-55 * x) * math.sin(2 * math.pi * 650 * x)
        x = t - .42
        if 0 <= x < 1.4:
            env = math.sin(math.pi * x / 1.4) ** 2
            wake[i] = env * (.10 * noise[i] + .025 * math.sin(2 * math.pi * (95 * x + 35 * x * x)))
        x = t - 1.45
        if 0 <= x < 3.25:
            env = min(1, x / .35, (3.25 - x) / .55)
            tone[i] = .065 * env * (math.sin(2 * math.pi * 220 * x)
                                    + .25 * math.sin(2 * math.pi * 440 * x))
    return {'radio-click': click, 'radio-wake': wake, 'radio-tone': tone,
            'radio': [sum(s) for s in zip(click, wake, tone)]}

def wooden_knocks():
    rng = random.Random(923)
    result = [0.0] * round(1.5 * RATE)
    # Inharmonic, rapidly damped body modes; noise only at initial contact.
    modes = [(145, .78, 65), (335, .50, 95), (570, .27, 130), (1020, .09, 190)]
    for start, strength, pitch in [(.10, 1.0, 1.0), (.38, .91, 1.018), (.90, .97, .986)]:
        filtered = 0.0
        for j in range(round(.16 * RATE)):
            t = j / RATE
            filtered += .22 * (rng.uniform(-1, 1) - filtered)
            contact = .16 * filtered * math.exp(-t / .0025)
            body = sum(weight * math.sin(2 * math.pi * freq * pitch * t)
                       * math.exp(-decay * t) for freq, weight, decay in modes)
            attack = 1 - math.exp(-t / .0008)
            tail = min(1.0, (.16 - t) / .02)
            result[round(start * RATE) + j] += strength * attack * tail * (body + contact)
    # Match the original peak to avoid turning up the comparison as a shortcut.
    gain = .297 / max(abs(x) for x in result)
    return [x * gain for x in result]

def write_wave(path, samples):
    pcm = [round(s * 32767) for s in samples]
    if not pcm or max(abs(s) for s in pcm) >= 32767:
        raise ValueError('Empty or clipped output; reduce the level before exporting.')
    with wave.open(str(path), 'wb') as stream:
        stream.setparams((1, 2, RATE, 0, 'NONE', 'not compressed'))
        stream.writeframes(struct.pack('<' + 'h' * len(pcm), *pcm))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output', type=Path, default=Path('my-sounds'))
    args = parser.parse_args()
    args.output.mkdir(parents=True, exist_ok=True)
    sounds = {'knocks': cue('door'), 'knocks-v2': wooden_knocks(), 'rain': rain(), **radio_layers()}
    existing = [name for name in sounds if (args.output / (name + '.wav')).exists()]
    if existing:
        parser.error('Choose a new output folder to preserve existing takes: ' + ', '.join(existing))
    for name, samples in sounds.items():
        write_wave(args.output / (name + '.wav'), samples)
        print(f'{name}.wav: {len(samples) / RATE:g} seconds')


if __name__ == '__main__':
    main()
