Files
deskhub/DeskHub-Webseite/tools/demo-video/make_music.js
T
2026-10-24 20:14:46 +00:00

144 lines
10 KiB
JavaScript

'use strict';
/**
* tools/demo-video/make_music.js: erzeugt Musik und Geräusche für das Demo-Video selbst (reine Synthese, keine Aufnahmen, keine Samples,
* keine fremden Melodien). Das Stück ist damit ein eigenes Werk ohne Rechte Dritter und nicht bei GEMA oder einer anderen Verwertungsgesellschaft angemeldet.
* node tools/demo-video/make_music.js <ziel.wav> <raw.mp4.events.json>
* Die Musik folgt den Kapiteln der Aufnahme (Titel, Portal, Mitarbeiterbereich, Assistent, Windows, Android, Abspann): erst weiche Flächen,
* dann Zupf-Arpeggio und Bass, im Mitarbeiterbereich kommt ein leichter Beat dazu, im Assistenten-Kapitel wird es voll, beim Windows-Programm
* wieder ruhiger, vor dem Abspann baut ein Riser auf, und zum Schluss klingt ein großer Akkord aus. Dazu Tipp-, Klick-, Übergangs- und
* Hervorhebungsgeräusche genau zu den Zeitpunkten, die record.js mitgeschrieben hat.
*/
const fs = require('fs');
const OUT = process.argv[2] || 'music.wav';
const info = process.argv[3] && fs.existsSync(process.argv[3]) ? JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) : { duration: parseFloat(process.argv[3] || '100'), events: [] };
const SECONDS = Math.ceil(info.duration) + 1;
const SR = 44100;
const BPM = 100;
const BEAT = 60 / BPM;
const BAR = 4 * BEAT;
const N = Math.floor(SECONDS * SR);
const M_L = new Float32Array(N), M_R = new Float32Array(N); // Musik
const S_L = new Float32Array(N), S_R = new Float32Array(N); // Geräusche
const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12);
const CHORDS = [[57, 60, 64, 67], [53, 57, 60, 64], [48, 52, 55, 59], [55, 59, 62, 64]]; // Am7, Fmaj7, Cmaj7, G6
const BASS = [45, 41, 36, 43];
let seed = 987654321; const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 * 2 - 1; };
// ── Kapitel mit den Zeitpunkten der Aufnahme, Anfänge auf den nächsten Schlag gerastet ──
const chapters = info.events.filter((e) => e.type === 'chapter').map((e) => ({ name: e.name, t: Math.ceil(e.t / BEAT) * BEAT, raw: e.t }));
if (!chapters.length) chapters.push({ name: 'portal', t: 0, raw: 0 });
const PLAN = {
intro: { bell: 1 }, portal: { arp: 1, bass: 1 }, staff: { arp: 1, bass: 1, kick: 1, hat: 1 },
assistant: { arp: 1, arp2: 1, bass: 1, kick: 2, hat: 1, shaker: 1 }, windows: { arp: 1, bass: 1, soft: 1 },
android: { arp: 1, bass: 1, riser: 1 }, outro: { bell: 1, final: 1 },
};
const planAt = (t) => { let c = chapters[0]; for (const ch of chapters) if (ch.t <= t + 1e-6) c = ch; return { name: c.name, ...(PLAN[c.name] || {}) }; };
const chapterStart = (name) => (chapters.find((c) => c.name === name) || {}).raw;
// ── Klangerzeuger ──
function tone(buf, start, dur, f, gain, pan, kind, attack, release) {
const [bl, br] = buf;
const n0 = Math.floor(start * SR), n1 = Math.min(N, Math.floor((start + dur + release) * SR));
const gl = gain * (1 - pan), gr = gain * (1 + pan);
for (let i = Math.max(0, n0); i < n1; i++) {
const t = (i - n0) / SR;
let env, s;
if (kind === 'pluck') { env = Math.exp(-t * 5.2) * Math.min(1, t / 0.004); s = 0.7 * Math.sin(2 * Math.PI * f * t) + 0.2 * Math.sin(4 * Math.PI * f * t) + 0.1 * Math.sin(6 * Math.PI * f * t); }
else if (kind === 'bell') { env = Math.exp(-t * 1.7) * Math.min(1, t / 0.003); s = Math.sin(2 * Math.PI * f * t) + 0.5 * Math.sin(2 * Math.PI * f * 2.76 * t) * Math.exp(-t * 3) + 0.25 * Math.sin(2 * Math.PI * f * 5.4 * t) * Math.exp(-t * 6); }
else { env = t < attack ? t / attack : 1; if (t > dur) env *= Math.max(0, 1 - (t - dur) / release); s = Math.sin(2 * Math.PI * f * t) + 0.35 * Math.sin(2 * Math.PI * f * 1.004 * t) + 0.15 * Math.sin(4 * Math.PI * f * t); }
const v = s * env; bl[i] += v * gl; br[i] += v * gr;
}
}
const mus = [M_L, M_R], sfx = [S_L, S_R];
function kick(start, gain) {
const n0 = Math.floor(start * SR), n1 = Math.min(N, n0 + Math.floor(0.35 * SR)); let ph = 0;
for (let i = n0; i < n1; i++) { const t = (i - n0) / SR; ph += 2 * Math.PI * (48 + 90 * Math.exp(-t * 28)) / SR; const v = Math.sin(ph) * Math.exp(-t * 9) * gain; M_L[i] += v; M_R[i] += v; }
}
function hat(start, gain, len = 0.07) {
const n0 = Math.floor(start * SR), n1 = Math.min(N, n0 + Math.floor(len * SR)); let prev = 0;
for (let i = n0; i < n1; i++) { const t = (i - n0) / SR; const x = rnd(); const hp = x - prev * 0.97; prev = x; const v = hp * Math.exp(-t * (len < 0.05 ? 90 : 60)) * gain; M_L[i] += v * 0.9; M_R[i] += v * 1.1; }
}
/** Rauschen mit gleitender Filterfrequenz: Übergangs-Wusch (auf = true) oder Riser. */
function sweep(buf, start, dur, gain, from, to, shape) {
const [bl, br] = buf; const n0 = Math.floor(start * SR), n1 = Math.min(N, Math.floor((start + dur) * SR)); let lp = 0, lp2 = 0;
for (let i = Math.max(0, n0); i < n1; i++) {
const k = (i - n0) / (n1 - n0), fc = from * Math.pow(to / from, k), a = Math.min(0.99, 2 * Math.PI * fc / SR);
const x = rnd(); lp += a * (x - lp); lp2 += a * (lp - lp2);
const bp = lp - lp2; // einfacher Bandpass
const env = shape === 'swell' ? Math.pow(k, 1.6) : Math.sin(Math.PI * Math.min(1, k * 1.15)) ** 1.5;
const v = bp * env * gain * 7; bl[i] += v * (1 - 0.3 * (k - .5)); br[i] += v * (1 + 0.3 * (k - .5));
}
}
function key(t) { const g = 0.05 + 0.02 * rnd(); const f = 1800 + 500 * rnd(); const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(0.03 * SR)); let prev = 0;
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; const x = rnd(); const hp = x - prev * 0.9; prev = x; const v = (hp * 0.6 + Math.sin(2 * Math.PI * f * u) * 0.5) * Math.exp(-u * 230) * g; S_L[i] += v; S_R[i] += v; } }
function click(t) { const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(0.09 * SR)); let ph = 0, prev = 0;
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; ph += 2 * Math.PI * (880 - 520 * Math.min(1, u * 14)) / SR; const x = rnd(); const hp = x - prev * 0.8; prev = x; const v = (Math.sin(ph) * 0.8 + hp * 0.3) * Math.exp(-u * 60) * 0.13; S_L[i] += v; S_R[i] += v; } }
function pop(t) { const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(0.2 * SR)); let ph = 0;
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; ph += 2 * Math.PI * (520 + 520 * Math.min(1, u * 9)) / SR; const v = Math.sin(ph) * Math.exp(-u * 14) * 0.09; S_L[i] += v; S_R[i] += v; } }
function ding(t) { tone(sfx, t, 0.05, 1568, 0.07, -0.1, 'bell', 0, 0.05); tone(sfx, t + 0.0, 0.05, 2349, 0.035, 0.12, 'bell', 0, 0.05); }
function boom(t) { const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(1.8 * SR)); let ph = 0;
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; ph += 2 * Math.PI * (38 + 70 * Math.exp(-u * 9)) / SR; const v = Math.sin(ph) * Math.exp(-u * 2.2) * 0.55; M_L[i] += v; M_R[i] += v; } }
// ── Musik nach Takten ──
const bars = Math.ceil(SECONDS / BAR) + 1;
const finalStart = (chapters.find((c) => c.name === 'outro') || { t: SECONDS - 6 }).t;
for (let bar = 0; bar < bars; bar++) {
const t0 = bar * BAR, ci = Math.floor(bar / 2) % 4, chord = CHORDS[ci];
const p = planAt(t0), isOutro = t0 >= finalStart - 1e-6;
// Flächen: ständig, im Abspann und beim Windows-Teil etwas anders gewichtet
if (bar % 2 === 0) chord.forEach((m, k) => tone(mus, t0, 2 * BAR - 0.1, hz(m), isOutro ? 0.10 : 0.075, (k - 1.5) * 0.25, 'pad', 1.4, 2.0));
if (p.bass && !isOutro) { tone(mus, t0, BEAT * 1.5, hz(BASS[ci]), 0.2, 0, 'pad', 0.02, 0.4); tone(mus, t0 + BEAT * 2.5, BEAT * 1.2, hz(BASS[ci]), 0.16, 0, 'pad', 0.02, 0.4); }
for (let s = 0; s < 8; s++) {
const tt = t0 + s * BEAT / 2, q = planAt(tt);
if (q.arp && s % 1 === 0) {
const order = [0, 2, 3, 2, 1, 3, 2, 1];
tone(mus, tt, 0.5, hz(chord[order[s]] + 12), (q.soft ? 0.08 : 0.11) + (s % 2 ? 0 : 0.03), (s % 4 - 1.5) * 0.18, 'pluck', 0, 0);
if (q.arp2 && s % 2) tone(mus, tt, 0.4, hz(chord[order[(s + 3) % 8]] + 24), 0.05, (s % 4 - 1.5) * -0.2, 'pluck', 0, 0);
}
if (q.bell && s % 4 === 0 && !(isOutro && s % 2)) tone(mus, tt, 1.2, hz(chord[(s / 4 + bar) % 4 | 0] + 12), isOutro ? 0.09 : 0.06, (s - 3) * 0.1, 'bell', 0, 0);
if (q.kick && s % 4 === 0) kick(tt, 0.46);
else if (q.kick === 2 && s % 2 === 0) kick(tt, 0.3);
if (q.hat && s % 2) hat(tt, 0.06);
if (q.shaker) hat(tt + BEAT / 4, 0.025, 0.04);
}
}
// Übergänge in der Musik: Riser vor dem Abspann, großer Schlag und heller Schlussakkord
const outroT = chapterStart('outro');
if (outroT !== undefined) {
sweep(mus, Math.max(0, outroT - 2.6), 2.6, 0.2, 300, 5200, 'swell');
boom(outroT);
[60, 64, 67, 72, 76].forEach((m, k) => tone(mus, outroT, 6, hz(m), 0.06, (k - 2) * 0.2, 'pad', 0.4, 2.5));
}
// einfaches Echo für Raumgefühl (nur Musik)
const D1 = Math.floor(0.375 * SR), D2 = Math.floor(0.5 * SR);
for (let i = D1; i < N; i++) { M_L[i] += M_R[i - D1] * 0.28; M_R[i] += M_L[i - D1] * 0.28; }
for (let i = D2; i < N; i++) { M_L[i] += M_L[i - D2] * 0.14; M_R[i] += M_R[i - D2] * 0.14; }
// ── Geräusche nach den Ereignissen der Aufnahme ──
let lastKey = -1;
for (const e of info.events) {
if (e.type === 'key') { if (e.t - lastKey > 0.025) key(e.t); lastKey = e.t; }
else if (e.type === 'click') click(e.t);
else if (e.type === 'whoosh') sweep(sfx, Math.max(0, e.t - 0.1), 0.85, 0.075, 350, 3800, 'whoosh');
else if (e.type === 'pop') pop(e.t);
else if (e.type === 'ding') ding(e.t);
}
// ── mischen: Musik leiser als Geräusche, normalisieren, sanft ein- und ausblenden ──
const MUSIC_GAIN = 0.85, SFX_GAIN = 1.0;
let peak = 0; for (let i = 0; i < N; i++) { const a = M_L[i] * MUSIC_GAIN + S_L[i] * SFX_GAIN, b = M_R[i] * MUSIC_GAIN + S_R[i] * SFX_GAIN; peak = Math.max(peak, Math.abs(a), Math.abs(b)); }
const g = 0.72 / (peak || 1);
const buf = Buffer.alloc(44 + N * 4);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + N * 4, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(2, 22);
buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 4, 28); buf.writeUInt16LE(4, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(N * 4, 40);
for (let i = 0; i < N; i++) {
const t = i / SR, fade = Math.min(1, t / 1.2) * Math.min(1, (SECONDS - t) / 3.2);
const a = (M_L[i] * MUSIC_GAIN + S_L[i] * SFX_GAIN) * g * fade, b = (M_R[i] * MUSIC_GAIN + S_R[i] * SFX_GAIN) * g * fade;
buf.writeInt16LE(Math.max(-32767, Math.min(32767, Math.round(a * 32767))), 44 + i * 4);
buf.writeInt16LE(Math.max(-32767, Math.min(32767, Math.round(b * 32767))), 46 + i * 4);
}
fs.writeFileSync(OUT, buf);
console.log(`Musik und Geräusche: ${OUT} (${SECONDS} s, ${info.events.length} Ereignisse, Kapitel: ${chapters.map((c) => c.name + '@' + c.raw.toFixed(1)).join(', ')})`);