'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 * 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(', ')})`);