159 lines
8.1 KiB
Python
159 lines
8.1 KiB
Python
"""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 <ffmpeg path>
|
||
|
|
"""
|
||
|
|
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("<i2").tobytes())
|
||
|
|
output = target / f'{score["id"]}.mp3'
|
||
|
|
subprocess.run([args.ffmpeg,"-hide_banner","-loglevel","error","-y","-i",str(wav),
|
||
|
|
"-codec:a","libmp3lame","-b:a","128k","-ar",str(RATE),
|
||
|
|
"-metadata",f'title={score["title"]}',"-metadata","comment=Project-specific synthesized cue; no external audio samples",str(output)],check=True)
|
||
|
|
item={**score,"seconds":round(duration,3),"sample_rate":RATE,"channels":2,
|
||
|
|
"file":output.name,"bytes":output.stat().st_size,
|
||
|
|
"sha256":hashlib.sha256(output.read_bytes()).hexdigest(),
|
||
|
|
"peak_pcm":round(float(np.max(np.abs(pcm))),6),
|
||
|
|
"rms_pcm":round(float(np.sqrt(np.mean(pcm**2))),6)}
|
||
|
|
manifest.append(item)
|
||
|
|
print(f'{score["title"]}: {duration:.2f}s, {item["bytes"]} bytes')
|
||
|
|
evidence=ROOT / "design/memoir-modes/music"
|
||
|
|
evidence.mkdir(parents=True,exist_ok=True)
|
||
|
|
(evidence/"score-manifest.json").write_text(json.dumps({
|
||
|
|
"created":"2026-10-07", "method":"new project-specific score, additive tone synthesis",
|
||
|
|
"external_recordings":[], "external_samples":[], "reference_songs":[],
|
||
|
|
"generator":"scripts/create-memoir-music.py", "tracks":manifest,
|
||
|
|
},ensure_ascii=False,indent=2),encoding="utf-8")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|