#!/usr/bin/env python3 """Moteur musical procédural KA — compose un lit musical UNIQUE par reel. 6 styles (house, lofi, synthwave, epic, funk, trap) × plusieurs progressions d'accords × tonalité, ligne de basse, batterie et MÉLODIE générées à partir d'une graine : deux reels n'ont jamais la même musique. API : compose(dur, seed, out_wav, style=None) -> style utilisé CLI : music_engine.py [style] Sortie : WAV stéréo 44,1 kHz 16 bits (fondu d'entrée/sortie inclus). """ import sys, math, random, wave import numpy as np SR = 44100 # ---------- filtres (magnitude ordre 1, via FFT — rapide et sans scipy) ---------- def _fftfilt(x, H_of_f): n = len(x) X = np.fft.rfft(x) f = np.fft.rfftfreq(n, 1.0/SR) return np.fft.irfft(X * H_of_f(f), n) def lowpass(x, fc): fc = max(30.0, fc) return _fftfilt(x, lambda f: 1.0/np.sqrt(1.0+(f/fc)**2)) def highpass(x, fc): fc = max(30.0, fc) return _fftfilt(x, lambda f: (f/fc)/np.sqrt(1.0+(f/fc)**2)) # ---------- oscillateurs / percussions ---------- def _t(n): return np.arange(n)/SR def sine(f, n, ph=0.0): return np.sin(2*np.pi*f*_t(n)+ph) def saw(f, n): return 2.0*((f*_t(n)) % 1.0)-1.0 def tri(f, n): return 2.0*np.abs(2.0*((f*_t(n)) % 1.0)-1.0)-1.0 def expdec(n, tau): return np.exp(-_t(n)/max(1e-4, tau)) def kick(dur=0.5, f0=150.0, f1=45.0, punch=1.0, rng=None): n = int(dur*SR); t = _t(n) f = f1+(f0-f1)*np.exp(-t*28.0) x = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*7.5)*punch nz = (rng or np.random).standard_normal(n)*np.exp(-t*400.0) return x + highpass(nz, 3500)*0.35 def sub808(f, dur, rng=None): n = int(dur*SR); t = _t(n) fr = f*(1.0+0.35*np.exp(-t*22.0)) # petit plongeon de hauteur x = np.sin(2*np.pi*np.cumsum(fr)/SR) x = np.tanh(x*2.2)*np.exp(-t*1.6) x[:int(0.004*SR)] *= np.linspace(0, 1, int(0.004*SR)) return x def snare(dur=0.22, rng=None): n = int(dur*SR) nz = (rng or np.random).standard_normal(n) body = sine(190, n)*expdec(n, 0.035)*0.5 return highpass(nz, 1600)*expdec(n, 0.055) + body def clap(rng=None): n = int(0.30*SR) out = np.zeros(n) r = rng or np.random for i, off in enumerate((0.0, 0.012, 0.026)): o = int(off*SR); m = n-o out[o:] += highpass(r.standard_normal(m), 1200)*expdec(m, 0.05)*(0.8-0.15*i) return out def hat(dur=0.05, opened=False, rng=None): d = 0.30 if opened else dur n = int(max(d, 0.03)*SR) nz = (rng or np.random).standard_normal(n) return highpass(nz, 8200)*expdec(n, 0.11 if opened else 0.018) def bass_note(f, dur, kind="saw"): n = int(dur*SR) if kind == "sub": x = sine(f, n)+0.35*sine(2*f, n) else: x = lowpass(saw(f, n), f*4.0)*0.9 + sine(f, n)*0.5 e = np.minimum(1.0, _t(n)/0.006)*np.exp(-_t(n)/max(0.05, dur*0.7)) return x*e def pluck(f, dur, bright=5.0): n = int(dur*SR) x = 0.6*saw(f, n)+0.4*tri(f, n) x = lowpass(x, f*bright) return x*expdec(n, max(0.04, dur*0.35))*np.minimum(1.0, _t(n)/0.003) def pad_chord(freqs, dur, detune=0.004, cutoff=1900.0): n = int(dur*SR) L = np.zeros(n); R = np.zeros(n) for f in freqs: L += saw(f*(1+detune), n)+0.5*sine(f, n) R += saw(f*(1-detune), n)+0.5*sine(f, n, ph=0.7) a = np.minimum(1.0, _t(n)/0.35) rel = np.minimum(1.0, (dur-_t(n))/0.25) e = a*np.clip(rel, 0, 1) return lowpass(L, cutoff)*e, lowpass(R, cutoff)*e # ---------- théorie : gammes, accords ---------- NOTE_HZ = {"F2": 87.31, "G2": 98.00, "A2": 110.00, "B2": 123.47, "C3": 130.81, "D3": 146.83, "E3": 164.81} SCALES = {"minor": [0, 2, 3, 5, 7, 8, 10], "major": [0, 2, 4, 5, 7, 9, 11], "dorian": [0, 2, 3, 5, 7, 9, 10]} def deg_semi(scale, d): return scale[d % 7] + 12*(d//7) def chord_freqs(root_hz, scale, degree, spread=(0, 2, 4)): return [root_hz*2**(deg_semi(scale, degree+s)/12.0) for s in spread] # ---------- définition des styles ---------- # patterns = 16 pas (doubles-croches) par mesure 4/4 ; valeurs = vélocité S = { "house": dict( bpm=(120, 126), scale="minor", roots=["A2", "G2", "F2", "C3"], progs=[[0, 5, 3, 4], [0, 5, 2, 6], [5, 3, 0, 4], [0, 2, 5, 6]], kick=[1, 0, 0, 0]*4, snare=None, clap=[0]*4+[1]+[0]*7+[1]+[0]*3, hats=[0, 0, .8, 0]*4, openhat=[0, 0, 1, 0]*4, bass="offbeat8", pad="sustain", mel_density=0.55, mel_oct=2, g=dict(kick=.95, clap=.5, hat=.35, bass=.62, pad=.30, mel=.42), swing=0.0), "lofi": dict( bpm=(78, 88), scale="dorian", roots=["F2", "G2", "A2"], progs=[[0, 3, 5, 4], [0, 2, 3, 4], [5, 4, 0, 3]], kick=[1]+[0]*6+[.7]+[0]*2+[.9]+[0]*5, snare=[0]*4+[.8]+[0]*7+[.8]+[0]*3, clap=None, hats=[.5, 0, .35, 0]*4, openhat=None, bass="half", pad="sustain", mel_density=0.35, mel_oct=1, g=dict(kick=.8, snare=.4, hat=.3, bass=.6, pad=.36, mel=.5), swing=0.12, vinyl=True), "synthwave": dict( bpm=(104, 112), scale="minor", roots=["A2", "C3", "G2"], progs=[[0, 5, 3, 6], [0, 6, 5, 4], [0, 3, 6, 4]], kick=[1, 0, 0, 0]*4, snare=[0]*4+[1]+[0]*7+[1]+[0]*3, clap=None, hats=[.35]*16, openhat=None, bass="arp16", pad="big", mel_density=0.4, mel_oct=2, g=dict(kick=.9, snare=.5, hat=.22, bass=.5, pad=.4, mel=.4), swing=0.0), "epic": dict( bpm=(96, 104), scale="minor", roots=["C3", "A2", "G2"], progs=[[0, 6, 3, 5], [0, 3, 6, 5], [0, 5, 6, 3]], kick=[1]+[0]*7+[0, 0, .9, 0]+[0]*4, snare=None, clap=[0]*12+[.6]+[0]*3, hats=None, openhat=None, bass="pulse8", pad="big", mel_density=0.45, mel_oct=2, g=dict(kick=.95, clap=.35, bass=.55, pad=.5, mel=.45), swing=0.0, swell=True), "funk": dict( bpm=(106, 114), scale="dorian", roots=["G2", "A2", "F2"], progs=[[0, 0, 3, 4], [0, 3, 0, 4], [0, 4, 3, 4]], kick=[1, 0, 0, 0, 0, 0, .8, 0, 0, .7, 0, 0, 1, 0, 0, 0], snare=[0]*4+[1]+[0]*7+[1]+[0, 0, .5], clap=None, hats=[.5, .2, .4, .2]*4, openhat=None, bass="synco", pad="stab", mel_density=0.5, mel_oct=1, g=dict(kick=.9, snare=.5, hat=.3, bass=.7, pad=.25, mel=.45), swing=0.08), "trap": dict( bpm=(136, 146), scale="minor", roots=["F2", "G2", "A2"], progs=[[0, 0, 5, 5], [0, 0, 6, 6], [0, 0, 3, 3]], kick=[1]+[0]*6+[.8]+[0]*8, snare=[0]*8+[1]+[0]*7, clap=None, hats=[.5, 0, .4, .4]*2+[.5, .3, .3, .3, .5, 0, .8, .4], openhat=None, bass="e808", pad="sustain", mel_density=0.3, mel_oct=2, g=dict(kick=.85, snare=.55, hat=.3, bass=.75, pad=.22, mel=.4), swing=0.0), } # ---------- séquenceur ---------- def _place(dst, sig, i0, gain): if gain <= 0: return i1 = min(len(dst), i0+len(sig)) if i1 <= i0: return dst[i0:i1] += sig[:i1-i0]*gain def _bass_events(kind, bar, rng): """-> liste (pas, longueur_en_pas, offset_degré, octave+)""" if kind == "offbeat8": return [(s, 2, 0, 0) for s in (2, 6, 10, 14)] if kind == "half": return [(0, 8, 0, 0), (8, 8, 0, 0)] if kind == "arp16": return [(s, 1, 0, (s % 4 == 2)) for s in range(16)] if kind == "pulse8": return [(s, 2, 0, 0) for s in range(0, 16, 2)] if kind == "e808": return [(0, 10, 0, 0), (10, 6, 0, 0)] if bar % 2 == 0 else [(0, 12, 0, 0), (13, 3, -2, 0)] if kind == "synco": pat = [(0, 2, 0, 0), (3, 1, 0, 1), (6, 2, 0, 0), (10, 1, 4, 0), (11, 1, 0, 1), (14, 2, 0, 0)] return pat if bar % 2 == 0 else pat[:-1]+[(14, 1, 0, 1), (15, 1, 0, 0)] return [(0, 4, 0, 0)] def _melody_bar(rng, density, prev_deg, chord_deg): """Rythme + hauteurs (degrés de gamme) pour une mesure. -> [(pas,long,deg)]""" events = [] step = 0 cur = prev_deg while step < 16: ln = rng.choice((2, 2, 2, 4, 4, 1, 3)) if rng.random() < density: if rng.random() < 0.5: # note d'accord cur = chord_deg + rng.choice((0, 2, 4, 7)) else: # marche par degrés cur = max(chord_deg-1, min(chord_deg+9, cur+rng.choice((-2, -1, -1, 1, 1, 2)))) events.append((step, ln, cur)) step += ln return events, cur def compose(dur, seed, out_wav, style=None): rng = random.Random(str(seed)) nrng = np.random.RandomState(abs(hash(str(seed))) % (2**31)) name = style if style in S else rng.choice(sorted(S.keys())) st = S[name] bpm = rng.uniform(*st["bpm"]) spb = 60.0/bpm stepd = spb/4.0 scale = SCALES[st["scale"]] root = NOTE_HZ[rng.choice(st["roots"])] prog = rng.choice(st["progs"]) swing = st.get("swing", 0.0) total = int((dur+0.2)*SR) L = np.zeros(total); R = np.zeros(total) g = st["g"] # percussions pré-rendues kick_s = kick(rng=nrng) snare_s = snare(rng=nrng) if st.get("snare") else None clap_s = clap(rng=nrng) if st.get("clap") else None hat_c = hat(rng=nrng); hat_o = hat(opened=True, rng=nrng) nbars = int(math.ceil(dur/(4*spb)))+1 prev_deg = 7 for bar in range(nbars): bar_t = bar*4*spb cdeg = prog[bar % len(prog)] cfreqs = chord_freqs(root*2, scale, cdeg) # pad / nappes if g.get("pad", 0) > 0: pl, pr = pad_chord(cfreqs, 4*spb*1.05, detune=0.006 if st["pad"] == "big" else 0.003, cutoff=2400 if st["pad"] == "big" else 1700) if st["pad"] == "stab": # stabs funk : accord plaqué sur 2 offbeats for s in (2, 10): n = int(spb*0.9*SR) stab = sum(pluck(f, spb*0.9, bright=4) for f in cfreqs) _place(L, stab, int((bar_t+s*stepd)*SR), g["pad"]*0.8) _place(R, stab, int((bar_t+s*stepd)*SR), g["pad"]*0.8) else: _place(L, pl, int(bar_t*SR), g["pad"]) _place(R, pr, int(bar_t*SR), g["pad"]) # batterie for s in range(16): tt = bar_t+s*stepd+(stepd*swing if s % 2 == 1 else 0.0) i0 = int(tt*SR) v = st["kick"][s] if st.get("kick") else 0 if v: _place(L, kick_s, i0, g["kick"]*v); _place(R, kick_s, i0, g["kick"]*v) if snare_s is not None and st["snare"][s]: _place(L, snare_s, i0, g["snare"]*st["snare"][s]); _place(R, snare_s, i0, g["snare"]*st["snare"][s]) if clap_s is not None and st.get("clap") and st["clap"][s]: _place(L, clap_s, i0, g["clap"]*st["clap"][s]); _place(R, clap_s, i0, g["clap"]*st["clap"][s]) if st.get("hats") and st["hats"][s]: hs = hat_o if (st.get("openhat") and st["openhat"][s]) else hat_c vv = g["hat"]*st["hats"][s]*(0.8+0.4*rng.random()) _place(L, hs, i0, vv*0.9); _place(R, hs, i0, vv*1.1) # basse bkind = st["bass"] for (s, ln, doff, octu) in _bass_events(bkind, bar, rng): f = root*2**(deg_semi(scale, cdeg+doff)/12.0)*(2 if octu else 1) d = ln*stepd sig = sub808(f/2 if bkind == "e808" else f, d*1.1) if bkind == "e808" else \ bass_note(f, d*0.95, kind="sub" if bkind in ("half", "pulse8") else "saw") i0 = int((bar_t+s*stepd)*SR) _place(L, sig, i0, g["bass"]); _place(R, sig, i0, g["bass"]) # mélodie (graine → jamais deux fois pareille) if g.get("mel", 0) > 0: events, prev_deg = _melody_bar(rng, st["mel_density"], prev_deg, cdeg) for (s, ln, deg) in events: f = root*2**(deg_semi(scale, deg)/12.0)*(2**st["mel_oct"]) d = ln*stepd sig = pluck(f, min(d*1.4, 1.2), bright=6.0) tt = bar_t+s*stepd+(stepd*swing if s % 2 == 1 else 0.0) i0 = int(tt*SR) pan = 0.15*math.sin(bar+s) # léger mouvement stéréo _place(L, sig, i0, g["mel"]*(1-pan)); _place(R, sig, i0, g["mel"]*(1+pan)) # montée bruitée vers chaque 4e mesure (styles amples) if st.get("swell") and bar % 4 == 3: n = int(4*spb*SR) sw = highpass(nrng.standard_normal(n), 1200)*np.linspace(0, 1, n)**2.5 _place(L, sw, int(bar_t*SR), 0.10); _place(R, sw, int(bar_t*SR), 0.10) # texture vinyle (lofi) if st.get("vinyl"): hiss = lowpass(nrng.standard_normal(total), 4000)*0.006 crackle = np.zeros(total) for _ in range(int(dur*7)): i = rng.randrange(total-200) crackle[i:i+120] += nrng.standard_normal(120)*expdec(120, 0.001)*0.05 L += hiss+crackle; R += hiss+crackle # écho discret sur le mix (profondeur) dly = int(0.375*spb/0.5*SR*0.5) or 1 eL = np.zeros(total); eR = np.zeros(total) eL[dly:] = R[:-dly]*0.18; eR[dly:] = L[:-dly]*0.18 # ping-pong léger L += eL; R += eR # master : drive doux, normalisation, fondus mix = np.stack([L, R]) mix = np.tanh(mix*1.25) peak = np.max(np.abs(mix)) or 1.0 mix = mix/peak*0.88 nfi = int(0.5*SR); nfo = int(1.4*SR) mix[:, :nfi] *= np.linspace(0, 1, nfi) if total > nfo: mix[:, -nfo:] *= np.linspace(1, 0, nfo) mix = mix[:, :int(dur*SR)] data = (np.clip(mix.T, -1, 1)*32767).astype(np.int16) with wave.open(out_wav, "wb") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes(data.tobytes()) return name if __name__ == "__main__": dur = float(sys.argv[1]); seed = sys.argv[2]; out = sys.argv[3] style = sys.argv[4] if len(sys.argv) > 4 else None print(compose(dur, seed, out, style))