"""Render newly written memoir cues without external recordings or samples. The score below is authored for this project. Numpy synthesizes every tone. FFmpeg only encodes the resulting PCM; it supplies no musical material. Run: python scripts/create-memoir-music.py --ffmpeg """ from __future__ import annotations import argparse import hashlib import json import subprocess import tempfile import wave from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[1] RATE = 44100 # Sixteen four-beat bars. Sparse phrases leave room for reading. SCORES = [ { "id": "paper-light", "title": "纸页微光", "tempo": 64, "voice": "felt", "description": "柔和琴音与低声和弦,适合精装书阅读。", "chords": [[48,55,62,64], [45,52,60,67], [53,60,64,69], [43,50,59,62], [48,55,59,64], [45,52,60,64], [50,57,60,65], [43,55,59,62], [53,60,64,67], [48,55,62,67], [45,52,60,64], [50,57,60,65], [53,57,60,64], [43,50,57,62], [48,55,59,64], [48,55,62,64]], "phrases": [[(0.5,76),(2.5,71)], [(1.5,72)], [(0.5,69),(3,76)], [(1,74)], [(0.5,67),(2.5,71)], [(1,72),(3,76)], [(0.5,77)], [(2,74)], [(0.5,76),(2.5,79)], [(1,74)], [(0.5,72),(3,69)], [(1.5,74)], [(0.5,72),(2.5,69)], [(1,71),(3,74)], [(0.5,76),(2.5,71)], [(1,67)]], }, { "id": "courtyard-breeze", "title": "桂香旧院", "tempo": 60, "voice": "silk", "description": "轻拨弦与悠长气息,适合画卷连续阅读。", "chords": [[50,57,62,69], [55,62,67,74], [47,54,62,69], [45,52,61,69], [50,57,66,69], [55,62,67,71], [47,54,62,66], [45,57,61,64], [55,62,69,74], [50,57,62,66], [52,59,66,71], [45,52,61,69], [55,62,67,71], [45,57,64,69], [50,57,62,66], [50,57,62,69]], "phrases": [[(0.75,74),(2.75,78)], [(1.25,81)], [(0.5,78),(3,74)], [(1.5,76)], [(0.75,69),(2.75,74)], [(1,78),(3,81)], [(0.5,78)], [(2,76)], [(0.5,83),(2.5,81)], [(1.5,78)], [(0.75,76),(3,71)], [(1.5,69)], [(0.5,74),(2.75,78)], [(1.25,76)], [(0.5,74),(2.5,69)], [(1,74)]], }, { "id": "slow-years", "title": "岁月慢行", "tempo": 68, "voice": "bell", "description": "清淡钟琴与暖音铺底,适合年代浏览。", "chords": [[53,60,65,69], [50,57,65,69], [58,65,69,72], [48,55,62,67], [53,60,64,69], [50,57,60,65], [58,65,69,74], [48,55,64,67], [57,64,67,72], [50,57,60,65], [58,65,69,72], [48,55,62,67], [58,65,67,72], [48,55,64,69], [53,60,65,69], [53,60,67,69]], "phrases": [[(0.5,81),(2.5,76)], [(1.5,77)], [(0.5,74),(3,77)], [(1,79)], [(0.75,76),(2.75,72)], [(1.5,74)], [(0.5,77),(3,81)], [(1,79)], [(0.5,84),(2.5,81)], [(1,77)], [(0.5,79),(3,77)], [(1.5,76)], [(0.75,74),(2.75,77)], [(1.5,76)], [(0.5,77),(2.5,72)], [(1,69)]], }, ] def tone(midi: int, length: float, voice: str, velocity: float) -> np.ndarray: t = np.arange(round(length * RATE), dtype=np.float64) / RATE f = 440 * 2 ** ((midi - 69) / 12) # Soft attack, decaying higher partials, tiny chorus beating, no sampled instruments. if voice == "pad": env = np.minimum(t / 1.2, 1) * np.minimum((length - t) / 1.8, 1) signal = (np.sin(2*np.pi*f*t) + .20*np.sin(2*np.pi*f*2*t) + .13*np.sin(2*np.pi*f*1.0015*t)) / 1.33 else: attack = .024 if voice == "felt" else .015 decay = 2.3 if voice == "felt" else 2.9 if voice == "silk" else 3.8 env = (1 - np.exp(-t/attack)) * np.exp(-t/decay) env *= np.minimum((length - t) / .4, 1) signal = np.zeros_like(t) partials = [(1,1,.95),(2,.27,.60),(3,.10,.35),(4,.045,.22)] if voice == "bell": partials = [(1,1,1),(2,.23,.75),(3,.11,.5),(4,.045,.3)] for harmonic, weight, falloff in partials: signal += weight*np.exp(-t/(decay*falloff))*np.sin(2*np.pi*f*harmonic*t) signal += .055*np.sin(2*np.pi*f*.9992*t)*np.exp(-t/decay) return (signal * env * velocity).astype(np.float32) def add_wrapped(buffer: np.ndarray, signal: np.ndarray, seconds: float, pan: float): start = round(seconds * RATE) left, right = np.cos((pan+1)*np.pi/4), np.sin((pan+1)*np.pi/4) stereo = signal[:,None] * np.array([left,right], dtype=np.float32) # Wrap tails into the beginning: both audio and reverberation are periodic. first = min(len(signal), len(buffer)-start) buffer[start:start+first] += stereo[:first] if first < len(signal): buffer[:len(signal)-first] += stereo[first:] def render(score: dict) -> tuple[np.ndarray, float]: beat = 60 / score["tempo"] duration = 64 * beat buffer = np.zeros((round(duration*RATE),2), dtype=np.float32) for bar, chord in enumerate(score["chords"]): beginning = bar*4*beat for position, note in enumerate(chord): add_wrapped(buffer, tone(note, 7, "pad", .011), beginning, (position-1.5)*.25) add_wrapped(buffer, tone(chord[0]-12, 5, "felt", .075), beginning+.04, -.12) for index, note in enumerate([chord[1],chord[2],chord[3]]): add_wrapped(buffer,tone(note,6,score["voice"],.07),beginning+(1+index)*beat+.025,[-.35,.25,-.15][index]) for index, (position, note) in enumerate(score["phrases"][bar]): add_wrapped(buffer,tone(note,7,score["voice"],.13 if score["voice"] != "bell" else .10), beginning+position*beat, .18 if index % 2 == 0 else -.18) dry = buffer.copy() # Quiet, distributed stereo reflections; periodic delays preserve the loop. for delay, gain in [(.071,.18),(.113,.16),(.191,.12),(.293,.10),(.431,.075),(.677,.045),(.977,.025)]: buffer += np.roll(dry[:,::-1],round(delay*RATE),axis=0)*gain peak = float(np.max(np.abs(buffer))) buffer *= .55 / peak # Cosmetic one-millisecond loop-edge taper prevents clicks on any player. edge = round(RATE*.001) buffer[:edge] *= np.linspace(0,1,edge)[:,None] buffer[-edge:] *= np.linspace(1,0,edge)[:,None] return buffer,duration def main(): parser = argparse.ArgumentParser() parser.add_argument("--ffmpeg", default="ffmpeg") args = parser.parse_args() target = ROOT / "public/assets/memoir/music" target.mkdir(parents=True,exist_ok=True) manifest=[] with tempfile.TemporaryDirectory(prefix="memoir-score-") as temporary: for score in SCORES: pcm,duration = render(score) wav = Path(temporary) / f'{score["id"]}.wav' with wave.open(str(wav),"wb") as file: file.setnchannels(2);file.setsampwidth(2);file.setframerate(RATE) file.writeframes((pcm*32767).astype("