#!/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("