Keyboard Foley

@rebindv0.1.3

Add Mechanical, Typewriter, or Soft Pop sounds to ordinary typing without Rebind Link. F8 mutes; F9 changes the theme.

Files

tools/synth.py

#!/usr/bin/env python3
"""Regenerate Keyboard Foley's original sound samples with the standard library."""

import array
import math
from pathlib import Path
import random
import sys
import wave

RATE = 48000
ROOT = Path(__file__).resolve().parents[1] / "samples"
THEMES = ("mechanical", "typewriter", "soft-pop")
KINDS = ("key-1", "key-2", "key-3", "space", "enter", "backspace")


def synth(theme, kind):
    rng = random.Random(f"keyboard-foley-v1/{theme}/{kind}")
    pitch = {"key-1": 1, "key-2": 1.06, "key-3": 0.94,
             "space": 0.68, "enter": 0.82, "backspace": 1.18}[kind]
    duration = {"mechanical": 0.095, "typewriter": 0.14, "soft-pop": 0.10}[theme]
    if kind in ("space", "enter"):
        duration += 0.025
    out = []
    low, previous, phase = 0.0, 0.0, 0.0
    for i in range(round(duration * RATE)):
        t = i / RATE
        noise = rng.uniform(-1, 1)
        low += 0.16 * (noise - low)
        high = noise - previous
        previous = noise
        if theme == "mechanical":
            # A noisy contact followed by a damped plastic case and return tick.
            value = 0.65 * high * math.exp(-t / 0.0018)
            value += 0.48 * low * math.exp(-t / 0.008)
            value += 0.38 * math.sin(2 * math.pi * 240 * pitch * t) * math.exp(-t / 0.013)
            value += 0.14 * math.sin(2 * math.pi * 1450 * pitch * t) * math.exp(-t / 0.004)
            if t > 0.021:
                value += 0.17 * high * math.exp(-(t - 0.021) / 0.0015)
        elif theme == "typewriter":
            # Inharmonic metal modes and a delayed mechanism impact.
            value = 0.75 * high * math.exp(-t / 0.003)
            for freq, amp, decay in ((740, 0.3, 0.018), (1730, 0.21, 0.012),
                                     (3270, 0.12, 0.007)):
                value += amp * math.sin(2 * math.pi * freq * pitch * t) * math.exp(-t / decay)
            value += 0.4 * low * math.exp(-t / 0.014)
            if t > 0.032:
                tail = t - 0.032
                value += (0.25 * high + 0.18 * math.sin(2 * math.pi * 380 * pitch * tail)) * math.exp(-tail / 0.007)
        else:
            # Integrate a falling frequency for a continuous, rounded pop.
            freq = (155 + 620 * math.exp(-t / 0.009)) * pitch
            phase += 2 * math.pi * freq / RATE
            value = math.sin(phase) * math.exp(-t / 0.014)
            value += 0.10 * low * math.exp(-t / 0.005)
        out.append(value)

    # Taper both boundaries and remove DC before quantizing.
    mean = sum(out) / len(out)
    attack = round(RATE * (0.0015 if theme == "soft-pop" else 0.0005))
    release = round(RATE * 0.008)
    for i, value in enumerate(out):
        fade_in = min(1, i / attack)
        fade_out = min(1, (len(out) - 1 - i) / release)
        out[i] = (value - mean) * fade_in * fade_out
    peak = max(abs(value) for value in out)
    # Leave headroom for overlapping keystrokes; user volume is separate.
    scale = 0.28 * 32767 / peak
    pcm = array.array("h", (round(value * scale) for value in out))
    if sys.byteorder != "little":
        pcm.byteswap()
    return pcm


def main():
    if sys.argv[1:] not in ([], ["--check"]):
        raise SystemExit("usage: synth.py [--check]")
    check = sys.argv[1:] == ["--check"]
    signatures = set()
    for theme in THEMES:
        folder = ROOT / theme
        if not check:
            folder.mkdir(parents=True, exist_ok=True)
        for kind in KINDS:
            pcm = synth(theme, kind)
            path = folder / f"{kind}.wav"
            if check:
                with wave.open(str(path), "rb") as source:
                    assert (source.getnchannels(), source.getsampwidth(), source.getframerate()) == (1, 2, RATE), path
                    raw = source.readframes(source.getnframes())
                    assert raw == pcm.tobytes(), f"sample differs from synthesis: {path}"
                    assert raw[:2] == raw[-2:] == b"\x00\x00", f"nonzero boundary: {path}"
                    assert raw not in signatures, f"duplicate sample: {path}"
                    signatures.add(raw)
                    assert 0.005 < math.sqrt(sum(x * x for x in pcm) / len(pcm)) / 32767 < 0.2, path
            else:
                with wave.open(str(path), "wb") as output:
                    output.setnchannels(1)
                    output.setsampwidth(2)
                    output.setframerate(RATE)
                    output.writeframes(pcm.tobytes())
            print(f"{theme}/{kind}.wav  {len(pcm) / RATE:.3f} s")
    if check:
        print("PASS: 18 distinct, reproducible PCM samples with tapered boundaries")


if __name__ == "__main__":
    main()

18 files without text previews