102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Writes Halkyon Meditate's bells (ADR 0038, D5) into the app's assets.
|
||
|
|
|
||
|
|
Each bell is synthesised here from sine partials and decays, with nothing
|
||
|
|
recorded or downloaded, so the sounds are the project's own and under its
|
||
|
|
licence. Python's standard library alone; run from apps/meditate:
|
||
|
|
|
||
|
|
python3 -I tool/bells.py
|
||
|
|
|
||
|
|
It writes assets/bells/bowl.wav, small_bell.wav and wood_block.wav into
|
||
|
|
halkyon_meditate_flutter, mono 16-bit PCM at 22,050 Hz. The output is the
|
||
|
|
same on every run (the wooden block's noise has a fixed seed).
|
||
|
|
"""
|
||
|
|
|
||
|
|
import math
|
||
|
|
import random
|
||
|
|
import struct
|
||
|
|
import wave
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
RATE = 22050
|
||
|
|
OUT = Path(__file__).resolve().parent.parent / "halkyon_meditate_flutter" / "assets" / "bells"
|
||
|
|
|
||
|
|
|
||
|
|
def partials(seconds, parts, attack=0.008):
|
||
|
|
"""Sums sine partials (frequency in Hz, amplitude, decay time in s)."""
|
||
|
|
samples = []
|
||
|
|
for n in range(int(seconds * RATE)):
|
||
|
|
t = n / RATE
|
||
|
|
rise = min(1.0, t / attack)
|
||
|
|
value = sum(amp * math.exp(-t / decay) * math.sin(2 * math.pi * freq * t) for freq, amp, decay in parts)
|
||
|
|
samples.append(rise * value)
|
||
|
|
return samples
|
||
|
|
|
||
|
|
|
||
|
|
def bowl():
|
||
|
|
"""A singing bowl: a low hum with inharmonic partials, two of them close
|
||
|
|
together so that it beats slowly, dying away over several seconds."""
|
||
|
|
base = 196.0
|
||
|
|
return partials(
|
||
|
|
9.0,
|
||
|
|
[
|
||
|
|
(base, 1.0, 3.2),
|
||
|
|
(base * 1.007, 0.6, 3.0),
|
||
|
|
(base * 2.71, 0.45, 2.0),
|
||
|
|
(base * 2.73, 0.3, 1.8),
|
||
|
|
(base * 5.13, 0.18, 1.1),
|
||
|
|
(base * 8.27, 0.08, 0.6),
|
||
|
|
],
|
||
|
|
attack=0.02,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def small_bell():
|
||
|
|
"""A small bell: brighter, a bell's minor-third partial, shorter."""
|
||
|
|
base = 784.0
|
||
|
|
return partials(
|
||
|
|
4.0,
|
||
|
|
[
|
||
|
|
(base * 0.5, 0.35, 1.6),
|
||
|
|
(base, 1.0, 1.3),
|
||
|
|
(base * 1.19, 0.45, 1.0),
|
||
|
|
(base * 1.5, 0.35, 0.8),
|
||
|
|
(base * 2.0, 0.3, 0.6),
|
||
|
|
(base * 2.51, 0.12, 0.35),
|
||
|
|
],
|
||
|
|
attack=0.003,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def wood_block():
|
||
|
|
"""A wooden block: a hollow knock, a short tone and a click of noise."""
|
||
|
|
noise = random.Random(38)
|
||
|
|
samples = []
|
||
|
|
for n in range(int(0.45 * RATE)):
|
||
|
|
t = n / RATE
|
||
|
|
tone = math.exp(-t / 0.045) * (math.sin(2 * math.pi * 1180 * t) + 0.5 * math.sin(2 * math.pi * 2410 * t))
|
||
|
|
click = math.exp(-t / 0.004) * (noise.random() * 2 - 1)
|
||
|
|
samples.append(min(1.0, t / 0.001) * (tone + 0.6 * click))
|
||
|
|
return samples
|
||
|
|
|
||
|
|
|
||
|
|
def write(name, samples, peak=0.7):
|
||
|
|
"""Writes [samples] normalised to [peak], faded out over the last 50 ms."""
|
||
|
|
top = max(abs(sample) for sample in samples) or 1.0
|
||
|
|
fade = int(0.05 * RATE)
|
||
|
|
frames = bytearray()
|
||
|
|
for n, sample in enumerate(samples):
|
||
|
|
tail = min(1.0, (len(samples) - n) / fade)
|
||
|
|
frames += struct.pack("<h", round(sample / top * peak * tail * 32767))
|
||
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
||
|
|
with wave.open(str(OUT / name), "wb") as file:
|
||
|
|
file.setnchannels(1)
|
||
|
|
file.setsampwidth(2)
|
||
|
|
file.setframerate(RATE)
|
||
|
|
file.writeframes(bytes(frames))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
write("bowl.wav", bowl())
|
||
|
|
write("small_bell.wav", small_bell())
|
||
|
|
write("wood_block.wav", wood_block())
|