Files
story/web/scripts/create-memoir-music.py
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

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()