diff --git a/pop/RELEASES.md b/pop/RELEASES.md index bd5cdbcafd..3adcbc1ea0 100644 --- a/pop/RELEASES.md +++ b/pop/RELEASES.md @@ -7,6 +7,96 @@ Status legend: **RELEASED** · **SUBMITTED** · **MASTERING** · **RENDER** · * --- +## nighttime study — RENDER (2026-09-30; Ringing Voice, @jeffrey ear-check next) + +- **Latest: Ringing Voice.** 9:48 · 104 BPM. Measured vowel spectra from all + three takes become pitched bells, including exposed chord strikes. Decays + reach 38.4 seconds; upper formants fade faster than the fundamental. Lead and + choir room tails extend to 10–20 seconds; final ring-out gains 16 seconds. + Tuned lead, chorale parts, narrative and dance pulse retained. + `afternoon-study/bin/render-night-ringing.py` → + `afternoon-study/out/nighttime-ringing/nighttime-study-ringing.{wav,mp3}`. + −18.0 LUFS · −2.5 dBTP · LRA 16.2; mono loss 0.38 dB, phone-proxy loss 1.06 dB. + Bell-decay envelopes and six lead/backing balance windows checked; QA saved. + +- **Earlier: Aesthetivox Chorale.** 9:32 · 104 BPM. Thirty-six recorded phrases, + voice from the opening, stronger WORLD note locking with original unvoiced + sounds, separately led bass/tenor/upper harmonies, and extended source-vowel + endings. Preserves the suite's narrative and dance pulse; instruments sit lower. + `afternoon-study/bin/render-night-chorale.py` → + `afternoon-study/out/nighttime-chorale/nighttime-study-chorale.{wav,mp3}`. + −18.6 LUFS · −2.5 dBTP · LRA 16.4; mono loss 0.11 dB, phone-proxy loss 0.81 dB. + Opening lead and three choir pitches independently checked within 1.4 cents + median absolute error; six sampled sections place combined vocals 6–26 dB + above instruments. Source ranges, voicings, tuning settings and QA saved. + +- **Earlier: Vocal Suite.** 9:32 · 104 BPM. Eighteen continuous phrases from the + original microphone recordings replace the note-bank foreground; original + pitch, timing and breaths retained. Overture → exposition → development → + slow movement → transformed return → coda. New instrumental themes, extended + sine sustains, 14–24-second bells, and a dance pulse. Voice enters at 0:18. + `afternoon-study/bin/render-night-suite.py` → + `afternoon-study/out/nighttime-suite/nighttime-study-suite.{wav,mp3}`. + −16.7 LUFS · −2.5 dBTP · LRA 14.7; mono loss 0.02 dB, phone-proxy loss 0.72 dB. + Six sampled vocal passages were checked against the backing; source ranges, + original recording hashes, separate stems, measurements and QA are saved. + +- **Earlier: Dance pass.** 9:05 · 108 BPM. Revised after the first mix felt morose: + major sixth/ninth harmony, steady kick, swung brush ticks, answering bass, + articulated vocal rolls and new lead melodies from the same three takes. + Shorter reverbs and lighter upper-register hums. Render: + `afternoon-study/bin/render-night-dance.py`; master: + `afternoon-study/out/nighttime-dance/nighttime-study-dance.{wav,mp3}`. + −20.1 LUFS · −2.5 dBTP · LRA 8.5; mono loss 0.22 dB, phone-proxy loss 0.99 dB. + 24-bit / 48 kHz WAV and 320 kbps MP3, private master backup complete. + +- **Lane:** `pop/afternoon-study/`, companion to Afternoon Study. 9:05 · 72 BPM · + 160 bars plus room tail. New melodies from @jeffrey's September 26 Lemon Kittens, + Indigo Shadows and Saffron Swallows note banks; rolling vocals, sustained hums, + sparse grand piano and low mallets. +- **Arrangement:** chamber platter phrase growth, unequal phrase lengths, + two-voice stillness at 3:07, late bloom at 6:00, opening recalled at half density; + rhythm platter complementary vocal/mallet attacks. Sources and applications in + `afternoon-study/README.md`; reproducible score in `bin/render-night.py`. +- **Master:** `afternoon-study/out/nighttime/nighttime-study.{wav,mp3}`; + separate stems, event score, loudness measurements and hashes in the same directory. + Ambient dynamics retained with measured static gain and an oversampled ceiling. +- **Artist:** Aesthetic Dot Computer. Private listening draft; not submitted. + +## afternoon study — RENDER (v5 2026-09-29: mallet kit, forward grand, sparse bass; @jeffrey ear-check next) + +- **Lane:** `pop/afternoon-study/` · one dance track from the three Sep 26 Menu Band + takes, played from the `pop/tape-sketches/` Aesthetivox note banks as three singers. + 118 BPM · 76 bars · 2:35 · C (Cmaj7 | Am7 | Fmaj7 | G7; Bbmaj7 in the breakdown). + Verse/chorus melodies written for the record are sung by **Lemon Kittens**' samples + through a nearest-sample picker (WORLD ≤5 st + octaves, stretched to the written + length, legato); harmony thirds, −12 under the chorus (never below G3), +12 in chorus + two. **Saffron Swallows** sings the whole-note counter line, the chord-tone sixteenth + arpeggio with octave trades, the un-chopped arch over the breakdown, and a reversed C4 + swell into each drop. **Indigo Shadows**: hums = a three-voice hymn pad, short notes = + the off-beat pulse, loud hits only = a quiet layer under the marimba clave. + **Grand piano** (the Salamander bank AC OS plays, voiced like pianotrax) sits forward at + −17 LUFS: solo intro with the tune up high and a falling run, verse comping, chorus + blocks doubling the hook with grace notes and +24 sparkle, pentatonic runs at phrase + ends, trills, two-octave-up broken chords in the breakdown, a marimba gliss into drops. + **Percussion is all synthesized mallets**: modal marimba (pop/marimba presets) on the + son clave, woodblock 3:4 hemiola, bubble pops, brush swishes on 2+4, a clickless soft + kick. Bass rests (root on one, an answer on the and-of-two, a pickup). Builds per + femrag++: block/bubble roll 4→32, sweeping riser, breath, A1→A2 sub stinger. + Separation: vocal buses centred, instruments notched at 3.6 k and ducked. Form: intro 4 + · verse 8 · build 4 · chorus 16 · hymn 8 · verse 8 · build 4 · chorus 16 · outro 8. +- **Master:** `out/afternoon-study.{wav,mp3}` (copy on the Desktop) · −12.3 LUFS · LRA 7.2 · + −1.8 dBTP. `out/chart.{txt,svg}`, `out/stems/{vocals,instrumental}.wav`, + `out/receipts.json` (melodies, samples + shifts, piano bank, breaths, SHA-256s). +- **Earlier cuts today:** v1 chopped-takes house · v2 pop (sampled claps, risers) · v3 + extended/subtle · v4 written melodies + CC0 kit + grand (the ticks read as static, the + lows too constant, the grand buried). All superseded by v5. +- **Artist:** Aesthetic Dot Computer (intended). +- **Next:** @jeffrey hears it → tune by ear (piano level and fills, mallet kit level, + bass spacing) · cover · packet. + +--- + ## eight gigabytes — RENDER (first cut 2026-09-26; @jeffrey ear-check next) - **Lane:** `pop/eightgigabytes/` · the pop cut of the MacNeoPolitan Trio's diff --git a/pop/afternoon-study/README.md b/pop/afternoon-study/README.md new file mode 100644 index 0000000000..ca8b661767 --- /dev/null +++ b/pop/afternoon-study/README.md @@ -0,0 +1,183 @@ +# afternoon-study + +## Nighttime Study — Ringing Voice + +The latest 9:48 revision turns measured vowels from all three takes into +pitched bell instruments. Vocal-bell chords ring for up to 38 seconds; +upper formants decay faster, leaving the fundamental hanging underneath. +The tuned lead and chorale remain, with longer 10–20-second room decays, +the original dance pulse, and additional space for the ending to ring out. + +```sh +pop/.venv/bin/python pop/afternoon-study/bin/render-night-ringing.py +``` + +Master: `out/nighttime-ringing/nighttime-study-ringing.{wav,mp3}`. Its +`bells.wav` stem is synthesized from the original voice's spectral envelopes. +This reuses the chorale's cached vocal analysis; render the chorale first on +a fresh machine. Prior versions remain separate. + +## Nighttime Study — Aesthetivox Chorale + +Latest revision: the vocal suite's 9:32 form and 104 BPM groove, with 36 +recorded phrases, voice from the opening, stronger Aesthetivox tuning, and +three separately written choir parts. The lead locks to the source score, +retaining 4% of within-note pitch movement and the original unvoiced sounds. +Bass, tenor and upper voices move through explicit chord voicings; selected +upper notes suspend across a chord change before resolving. Phrase endings +extend into held source vowels where the next entrance leaves room. + +The choir grows after the opening and opens further in the slow movement. +Piano, bells and sine harmony sit lower. This is a pop chorale arrangement, +not strict four-part counterpoint. Full takes supply the voice; no tiny +repeated note-bank lead is used in this revision. + +```sh +pop/.venv/bin/python pop/afternoon-study/bin/render-night-chorale.py +``` + +Master: `out/nighttime-chorale/nighttime-study-chorale.{wav,mp3}`. Separate +lead and choir stems, source hashes/ranges, tuning parameters and harmonic +voicings are preserved with the render. + +## Nighttime Study — Vocal Suite + +Latest September 30 revision: 9:32 at 104 BPM. The foreground now uses 18 +continuous excerpts from the three original `stems/voice.wav` microphone +recordings, with their pitch, timing and breaths preserved. No WORLD or +phase-vocoder processing touches this lead. Source ranges and hashes are +recorded per phrase. Piano, bells and sine harmony duck beneath the voice. + +The form is overture (0:00), exposition (1:14), development (2:46), slow +movement (4:55), transformed return (5:51), and coda (8:18). Two newly written +instrumental themes move between piano and bells, fragment and answer in +canon, stretch into longer notes, then return. This is a loose classical +narrative, not strict sonata form. Sustained sine harmony overlaps bar lines; +bells decay for 14–24 seconds. The dance groove enters during the overture. + +```sh +pop/.venv/bin/python pop/afternoon-study/bin/render-night-suite.py +``` + +Master: `out/nighttime-suite/nighttime-study-suite.{wav,mp3}`; the isolated +original vocal is `out/nighttime-suite/stems/voice.wav`. Earlier versions +remain separate. Platter sources are listed below. + +## Nighttime Study — Dance + +September 30 revision after the first nighttime mix felt morose: 9:05 at +108 BPM, warm major sixth/ninth harmony, four-to-the-floor kick, swung brush +ticks, an answering bass line, shorter rolling vocal notes, and two new lead +melodies. The three original takes remain the only vocal sources. Reverbs are +shorter; low sustained hums give way to occasional upper-register breaths. +The breaks retain a pulse. The first ambient version remains available. + +```sh +pop/.venv/bin/python pop/afternoon-study/bin/render-night-dance.py +``` + +Master: `out/nighttime-dance/nighttime-study-dance.{wav,mp3}`. Stems and +receipts are beside it. Uses the same platter sources listed below, retaining +gradual pattern growth, interlocking onsets and a reduced-density return. + +## Nighttime Study + +The nighttime companion is a new 9:05 composition at 72 BPM using the same +three September 26 voice banks. Lemon sings new, gradually growing melodies; +Saffron supplies overlapping rolling tones and one half-speed answer; Indigo +holds the harmony. Sparse Salamander piano and low marimba surround the voice. + +```sh +pop/.venv/bin/python pop/afternoon-study/bin/render-night.py +``` + +Master: `out/nighttime/nighttime-study.{wav,mp3}`. Separate stems, vocal note +events, harmonic map, source references, measurements and hashes are beside it. + +The arrangement applies the chamber platter's +[tonal-form rules](../../papers/chamber-platter/digest/01-tonal-forms.md) +(unequal phrases, growing/mirrored melodies, stillness, opening recalled), +[process rules](../../papers/chamber-platter/digest/03-process-and-envelopes.md) +(gradual note substitution and one half-speed answer), and the rhythm platter's +[interlocking patterns](../../papers/rhythm-platter/digest/06-complements-canons.md). +These are selective compositional applications, not a claim to implement every +rule or reproduce the cited works. The +[Loner arrangement critique](../../papers/arxiv-loner-arrangement-critique/loner-arrangement-critique.md) +informs the centered foreground voice and distinct role for each return. + +The two-voice clearing starts at 3:07; the late bloom at 6:00 gives way to a +quiet afterglow at 6:27. The opening returns at 7:33 with half the attacks. +Mastering uses static gain with an oversampled safety ceiling, preserving the +ambient dynamics. The original afternoon mix remains separate. + +## Afternoon Study + +One dance track from the three Menu Band takes @jeffrey recorded on +September 26, 2026 — Lemon Kittens, Indigo Shadows and Saffron Swallows. It +grows out of `pop/tape-sketches/`, which split those takes into Aesthetivox +note banks (one pitch-corrected WAV per sung note). This lane plays those +banks as three singers, writes new lines for them, and puts the AC OS grand +piano under them. Artist: Aesthetic Dot Computer. 118 BPM, 76 bars, 2:35. + +## Who plays what + +| Source | What it gives the track | +|---|---| +| Lemon Kittens (voice) | Sings the **verse** and **chorus** melodies written for the record (see `melodies` in the receipts). A `Singer` picks, for every written note, the bank sample whose pitch is nearest (WORLD-shifted by at most five semitones, plus octaves), stretches it to the written length and sings it legato. Harmony samples double at a third, an octave-down double sits under the chorus, an octave-up double joins the second chorus. | +| Saffron Swallows (voice) | Sings the **whole-note counter line** (E–E–F–D, then G–E–F–D), the **chord-tone arpeggio** on sixteenths in the choruses with the octave trading every beat, the un-chopped slow arch over the breakdown, and a reversed C4 that swells into each drop. | +| Indigo Shadows (voice + hits) | Its long hums are the **hymn** pad (three voices, WORLD-shifted ≤5 st into the chords); its short repeated notes are the off-beat **pulse** in the choruses. Its loud recorded hits (peak above .3) tuck quietly under the marimba on the clave — the quiet taps and ticks were recording floor and are gone. | +| Grand piano | The **Salamander Grand** (CC0, Alexander Holm) from `fedac/native/samples/piano`, the same 26 anchors AC OS plays, voiced like `pop/maytrax/c/pianotrax.c`: nearest anchor, resampled, damper release, keyboard pan, a hazy-lazy hand (16 ms late, jittered, velocity wobbled). It sits forward (−17 LUFS bus, barely ducked). The right hand lives an octave up: a solo intro (rolled chords, the verse tune sung up high, a falling run), verse comping, chorus block chords doubling the hook with grace notes and a +24 sparkle on the holds, sixteenth sparkle up top, a pentatonic run at every phrase end, a trill on the high tone, broken chords two octaves up in the breakdown, and a marimba glissando into each drop. | +| Mallet kit | All percussion is synthesized and soft: a modal **marimba** (the `rosewood`/`bass`/`staccato`/`roll` recipes from `pop/marimba/synths/marimba.mjs`, ported) playing chord tones on the son clave, a **woodblock** on the 3:4 hemiola, **bubble pops** (a sine falling an octave and a half in forty milliseconds) on the off-eighths, **brush swishes** on two and four, and a round **soft kick** with no click (on one and three in verses, four to the floor in choruses). Bass is authored and rests: root on one, one answer on the and-of-two, a pickup before the next bar. | + +## The build, from femrag++ + +Each chorus is set up the way `pop/maytrax/bin/render-femrag-plusplus.mjs` +sets up a drop: a four-bar woodblock-and-bubble roll (quarters → eighths → +sixteenths → thirty-seconds) under a noise riser that sweeps 500 → 7500 Hz +with two detuned sines climbing two octaves, one brush swish and marimba hit +on the "and" of four, then the last half-beat goes **silent** — the intake of +breath — and the drop lands on a short driven sub stinger (A1 → A2), a brush +wash, a marimba glissando, and the reversed vocal swell arriving where it +ends. + +## Percussion chart + +``` +slot 1e&a 2e&a 3e&a 4e&a 5e&a 6e&a 7e&a 8e&a +kick x... .... x... .... x... .... x... .... soft kick (four to the floor in choruses) +brush .... x... .... x... .... x... .... x... brush swish +marimba x... ..x. .... x... .... x... x... .... son clave, chord tones an octave down +block x..x ..x. .x.. x..x ..x. .x.. x..x ..x. 3:4 hemiola (3-bar cycle) +bubble ..x. ..x. ..x. ..x. ..x. ..x. ..x. ..x. off-eighths (a six-pop pattern in choruses) +``` + +`out/chart.svg` draws the same three bars. Nothing here is random. + +## Separation + +The vocal buses (`lead`, `lead-oct`, `long`) stay centred with presence at +4 kHz and a short slap. Instruments are notched at 3.6 kHz and duck under +the lead's 20 ms energy (arp and hymn most, the piano only lightly). The +master aims at −12 LUFS with a light glue compressor, so the mix keeps its +air. Every sung note +has 15 ms attack and up to 250 ms release fades. `out/stems/vocals.wav` and +`out/stems/instrumental.wav` are the two halves. + +## Form + +intro 4 · verse 8 · build 4 · chorus 16 · hymn 8 · verse 8 · build 4 · +chorus 16 · outro 8. + +## Render + +```sh +pop/.venv/bin/python pop/afternoon-study/bin/render.py # → out/afternoon-study.{wav,mp3} +pop/.venv/bin/python pop/afternoon-study/bin/render.py --lufs -12 # quieter master +``` + +Sources are read from the private study on the Shelf +(`~/Documents/Shelf/Menu Band pop sketches 2026-09-26`, override with +`--study`). `out/` holds the master, per-bus stems, the WORLD cache, the +chart and `receipts.json` (melodies, every vocal sample used with its shift +and stretch, kit credits, piano bank, breaths, bus trims, master loudness, +SHA-256). Media stays out of git per `pop/ASSETS.md`. diff --git a/pop/afternoon-study/bin/render-night-chorale.py b/pop/afternoon-study/bin/render-night-chorale.py new file mode 100644 index 0000000000..442383dcd1 --- /dev/null +++ b/pop/afternoon-study/bin/render-night-chorale.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Aesthetivox chorale revision of the vocal dance suite. + +Full recorded phrases -> WORLD note lock -> original unvoiced composite. +Three individually voiced choir parts retain the recording's spectral shape. +The house vowel-hold technique extends phrase endings, with room to breathe. +""" +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +import numpy as np +import soundfile as sf +from scipy.signal import resample_poly, butter, sosfilt + +spec = importlib.util.spec_from_file_location('suite', Path(__file__).with_name('render-night-suite.py')) +s = importlib.util.module_from_spec(spec) +spec.loader.exec_module(s) +n, ac = s.n, s.ac +SR, FS = ac.SR, 24000 +OUT = n.ROOT / 'out/nighttime-chorale' +EXTRA = [ + ('indigo-shadows', 0, 6, 0), ('saffron-swallows', 34.3, 10, 16), + ('indigo-shadows', 13.4, 8, 28), ('lemon-kittens', 16.5, 8, 40), + ('indigo-shadows', 8.5, 6, 52), ('saffron-swallows', 27.3, 8, 64), + ('lemon-kittens', 41, 10, 90), ('indigo-shadows', 34.4, 8, 104), + ('saffron-swallows', 0, 8, 115), ('lemon-kittens', 55, 5, 126), + ('saffron-swallows', 9.1, 12, 142), ('indigo-shadows', 13.4, 8, 162), + ('lemon-kittens', 5.6, 10, 174), ('saffron-swallows', 0, 7, 186), + ('indigo-shadows', 22, 8, 196), ('lemon-kittens', 16.5, 6.6, 210), + ('indigo-shadows', 17.7, 8, 216), ('saffron-swallows', 43.0, 1.7, 235), +] +# Bass / tenor / upper voice, independently led rather than parallel shifts. +VOICINGS = {'C6add9': [48,55,64], 'Fmaj9': [48,57,65], + 'G6sus': [50,55,62], 'Dm9': [50,57,65], + 'Gmajor': [47,55,62], 'D7sus': [50,55,60], 'Cmaj9': [48,55,64]} +ROUTES = [ + ['C6add9','C6add9','Fmaj9','G6sus'], + ['C6add9','Fmaj9','Dm9','G6sus','C6add9'], + ['Gmajor','D7sus','Gmajor','Cmaj9','Dm9','G6sus','Gmajor'], + ['Fmaj9','C6add9','Fmaj9'], + ['C6add9','Fmaj9','G6sus','C6add9','Fmaj9','Dm9','G6sus','C6add9'], + ['Fmaj9','G6sus','C6add9'], +] + + +def chord_at(seconds): + bar = min(s.BARS-1, max(0, int(seconds/s.BAR))) + for (start,bars,_),route in zip(s.SECTIONS,ROUTES): + if start <= bar < start+bars: + return route[(bar-start)//8] + + +def level(y, rms=.15): + active = np.sqrt(np.percentile([np.mean(a*a) for a in + np.array_split(y,max(1,len(y)//1200))],70)) + return .42*np.tanh(y*min(18,rms/max(active,1e-6))/.42) + + +class AesthetivoxVoice: + def __init__(self,name,offset): + self.bank = SimpleNamespace(name=name) + self.path=ac.STUDY/'sources'/name/'stems/voice.wav' + self.sha=hashlib.sha256(self.path.read_bytes()).hexdigest() + self.offset,self.events=offset,[] + self.raw=ac.load_mono(self.path,FS) + cached=OUT/'cache'/f'{name}-{self.sha[:10]}-analysis.npz' + if cached.exists(): + with np.load(cached) as z: + self.f0,self.t,self.sp,self.ap=[z[k] for k in ('f0','t','sp','ap')] + else: + print('WORLD analysis',name,flush=True) + x=np.ascontiguousarray(self.raw) + self.f0,self.t=ac.pw.harvest(x,FS,f0_floor=65,f0_ceil=1000,frame_period=5) + self.f0=ac.pw.stonemask(x,self.f0,self.t,FS) + fft=ac.pw.get_cheaptrick_fft_size(FS,f0_floor=65) + self.sp=ac.pw.cheaptrick(x,self.f0,self.t,FS,fft_size=fft,f0_floor=65) + self.ap=ac.pw.d4c(x,self.f0,self.t,FS,fft_size=fft) + np.savez_compressed(cached,f0=self.f0,t=self.t,sp=self.sp,ap=self.ap) + score=json.loads((self.path.parents[1]/'score.mbscore').read_text())['notes'] + self.lock=self.f0.copy() + observed=np.zeros_like(self.f0) + voiced=self.f0>0 + observed[voiced]=69+12*np.log2(self.f0[voiced]/440) + # Every note owns the space to the next note; preserve 4% of its + # internal pitch motion. The fixed target retains the sung octave. + for i,note in enumerate(score): + a=note['start']+offset + b=score[i+1]['start']+offset if i+1=a)&(self.t0 + sp=np.ascontiguousarray(self.sp[a:b]); ap=np.ascontiguousarray(self.ap[a:b]) + x=self.raw[round(source*FS):round(source*FS)+round(duration*FS)] + tag=f'{self.bank.name}-{start}-{duration}-{bar}' + prefix=OUT/'cache'/tag + at=bar*s.BAR + next_at=min([row[3]*s.BAR for row in s.TAKES if row[3]>bar] or [s.BARS*s.BAR]) + hold=min(4.5,max(0,next_at-at-duration-.4)) + if bar==235: hold=8.5 + print('chorale phrase',self.bank.name,bar,flush=True) + tail_frames=np.where(voiced & (t>max(0,t[-1]-.7)))[0] + if len(tail_frames)<6: hold=0 + for part,bus,pan,gain in [(-1,'voice',0,1.45),(0,'choir-bass',-.12,.50), + (1,'choir-tenor',-.42,.64),(2,'choir-upper',.42,.57)]: + path=Path(str(prefix)+f'-{bus}.wav') + if path.exists(): + y=ac.load_mono(path) + else: + pitch=f0.copy() + if part>=0: + midis=np.array([VOICINGS[chord_at(at+v)][part] for v in t],dtype=float) + # One upper suspension resolves by step a beat after the + # chord changes; lower voices arrive with the harmony. + if part==2: + lag=round(s.BEAT/.005) + for change in np.where(np.diff(midis)!=0)[0]+1: + if abs(midis[change]-midis[change-1])<=2: + midis[change:min(len(midis),change+lag)]=midis[change-1] + pitch[voiced]=440*2**((midis[voiced]-69)/12) + y=ac.pw.synthesize(np.ascontiguousarray(pitch),sp,ap,FS,5)[:len(x)] + y=np.pad(y,(0,max(0,len(x)-len(y)))) + mask=np.interp(np.arange(len(x))/FS,t,voiced.astype(float)) + mask=np.convolve(mask,np.ones(121)/121,mode='same') + if part<0: + raw_rms=np.sqrt(np.mean((x*mask)**2)) + y*=np.clip(raw_rms/max(1e-7,np.sqrt(np.mean((y*mask)**2))),.3,3) + y=y*mask*.96+x*((1-mask)+.04*mask) + else: + y*=mask + y=level(y,.18 if part<0 else .13) + if hold>0: + ix=tail_frames[-min(50,len(tail_frames)):] + count=round(hold/.005) + spectrum=np.exp(np.mean(np.log(sp[ix]+1e-12),axis=0)) + noise=np.median(ap[ix],axis=0) + freq=np.linspace(0,FS/2,len(noise)) + noise[freq>4000]=np.maximum(noise[freq>4000],.35) + hz=float(np.median(pitch[ix])) + tt=np.arange(count)*.005 + hold_pitch=hz*2**(.035*np.sin(2*np.pi*4.7*tt)*np.minimum(1,tt/.5)/12) + held=ac.pw.synthesize(hold_pitch,np.tile(spectrum,(count,1)),np.tile(noise,(count,1)),FS,5) + ref=y[max(0,len(y)-round(.6*FS)):] + held*=min(5,np.sqrt(np.mean(ref*ref))/max(1e-6,np.sqrt(np.mean(held*held)))) + held*=np.sin(np.minimum(1,np.arange(len(held))/FS/.06)*np.pi/2) + held*=np.clip(1-np.arange(len(held))/len(held),0,1)**.65 + y[-min(len(y),1200):]*=np.linspace(1,0,min(len(y),1200)) + y=np.concatenate([y,held]) + y=resample_poly(y,2,1) + y*=ac.fades(len(y),.012 if part<0 else .10,.09) + sf.write(path,y,SR,subtype='FLOAT') + # More exposed lead at the start, then gradually grow the choir. + choir_level=.50 if bar<8 else .8 if bar<32 else 1 + if 128<=bar<152: choir_level=1.15 + mix.place(bus,y,at,gain*(1 if part<0 else choir_level),pan) + self.events.append(dict(source=str(self.path.relative_to(ac.STUDY)),sourceSha256=self.sha, + sourceStartSeconds=source,seconds=duration+hold,atSeconds=at, + holdSeconds=hold,processing='WORLD note lock, 4% pitch motion, original unvoiced composite; bass/tenor/upper independently voiced')) + + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument('--study',type=Path,default=ac.STUDY) + args=parser.parse_args();ac.STUDY=args.study + for sub in ('cache','stems'): (OUT/sub).mkdir(parents=True,exist_ok=True) + s.TAKES=sorted(s.TAKES+EXTRA,key=lambda row:row[3]) + s.RecordedVoice=AesthetivoxVoice + n.OUT,n.BPM,n.BEAT,n.BAR,n.BARS=OUT,s.BPM,s.BEAT,s.BAR,s.BARS + n.DURATION=s.BARS*s.BAR+18;n.PHRASES=s.SECTIONS;n.MOTIFS=[s.A,s.B] + ac.BEAT,ac.BAR=s.BEAT,s.BAR + mix,voices,harmony=s.arrange() + settings={'voice':(.07,1.7,11500),'choir-bass':(.17,3.8,6400), + 'choir-tenor':(.22,4.5,7800),'choir-upper':(.25,5.2,9000), + 'piano':(.20,4,8500),'bells':(.25,7,10500),'sines':(.24,6,7000), + 'kick':(0,0,8000),'bass':(0,0,2100),'shuffle':(.05,.8,12000),'clap':(.08,1,10000)} + n.finish(mix,voices,harmony,title='Nighttime Study — Aesthetivox Chorale',slug='nighttime-study-chorale', + settings=settings,bus_gains={'piano':1.15,'bells':.75,'sines':.65}, + target_lufs=-15.5,measure=s.d.meter, + vocal_source='Jeffrey original Sep 26 mic recordings; WORLD hard note lock with original unvoiced composite, independently led three-part vocal choir, extended source vowels') + receipt=json.loads((OUT/'receipts.json').read_text()) + receipt.update(choraleVoicings=VOICINGS,vocalPhrases=len(s.TAKES), + tuning=dict(intraNoteMotion=.04,maxDeviationSemitones=.07,voicedDryBlend=.04), + sources=n.SOURCES+['pop/bin/autotune.py','pop/bin/vowel-hold.py']) + (OUT/'receipts.json').write_text(json.dumps(receipt,indent=2)) + + +if __name__=='__main__':main() diff --git a/pop/afternoon-study/bin/render-night-dance.py b/pop/afternoon-study/bin/render-night-dance.py new file mode 100644 index 0000000000..b4c258f08e --- /dev/null +++ b/pop/afternoon-study/bin/render-night-dance.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Nighttime Study, dance pass: rolling Jeffrey vocals, warm sixths, 108 BPM. + +Keeps the nine-minute span but rebuilds the rhythm and harmony. Chamber +platter gradual substitution/return and rhythm platter interlocking are +compositional constraints; all sung sounds use the three original takes. +""" +import argparse +import importlib.util +import json +import re +from pathlib import Path +import numpy as np + +spec = importlib.util.spec_from_file_location('night', Path(__file__).with_name('render-night.py')) +n = importlib.util.module_from_spec(spec) +spec.loader.exec_module(n) +ac = n.ac +BPM, BARS = 108, 240 +BEAT = 60 / BPM +BAR = BEAT * 4 +OUT = n.ROOT / 'out/nighttime-dance' +CHORDS = { + 'C6add9': (36, [60, 64, 67, 69, 74]), + 'F6add9': (41, [60, 65, 67, 69, 74]), + 'G6add9': (43, [59, 62, 67, 69, 76]), + 'C6overE': (40, [60, 64, 67, 69, 74]), +} +SECTIONS = [ + (0, 24, 'lights on', .66), + (24, 36, 'rolling floor', .84), + (60, 24, 'first lift', 1.0), + (84, 12, 'open air', .64), + (96, 48, 'night dancing', .94), + (144, 36, 'high windows', 1.06), + (180, 12, 'floating pulse', .66), + (192, 24, 'last dance', 1.08), + (216, 24, 'walk home', .76), +] +# Eight-bar lines. Shorter notes articulate; held tones earn their space. +MELODIES = [ + [(0, 1, 67), (1.5, .75, 69), (3, 1.5, 72), (5.5, .75, 69), + (7, 2, 67), (11, 1, 64), (13, 2.5, 67), (17, 1, 69), + (18.5, 1, 67), (21, 2, 65), (24, 1, 67), (26, 1, 69), (28, 3, 72)], + [(0, 1, 72), (1.5, 1, 74), (4, 2, 76), (7, 1, 74), + (9, 2, 72), (13, 2, 69), (17, 1, 72), (18.5, 1, 69), + (21, 2, 67), (24, 1, 69), (26, 1.5, 67), (29, 2, 72)], +] + + +def meter(path): + log = ac.ffmpeg('-i', path, '-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-').stderr + summary = log[log.rfind('Integrated loudness'):] + def number(pattern): + return float(re.search(pattern, summary)[1]) + return dict(input_i=number(r'I:\s*(-?[\d.]+) LUFS'), + input_tp=number(r'Peak:\s*(-?[\d.]+) dBFS'), + input_lra=number(r'LRA:\s*(-?[\d.]+) LU')) + + +def arrange(): + rng = np.random.default_rng(n.SEED) + piano = ac.Piano(rng) + mix = n.Mixer(n.DURATION) + voices = [n.Voice(ac.Bank(name, OUT / 'cache')) for name in ac.NAMES] + lemon, indigo, saffron = voices + harmony = [] + kick = ac.soft_kick() + # Synthesized brush and wood taps, all project-owned. + closed = ac.brush(.075, .004, 3600, 10500, 31) + opened = ac.brush(.17, .012, 2600, 9200, 54) + clap = ac.brush(.14, .012, 900, 5900, 92) + for start, bars, name, level in SECTIONS: + print(f'arrange {start:3d}: {name}', flush=True) + for b in range(bars): + bar = start + b + at = bar * BAR + airy = start in (84, 180) + ending = start == 216 + gain = level * (1 - .55 * b / bars if ending else 1) + route = ['C6add9', 'F6add9', 'C6overE', 'G6add9'] + chordname = route[(bar // 8) % 4] + root, tones = CHORDS[chordname] + harmony.append(dict(bar=bar, chord=chordname)) + # Four to the floor from the opening. Breaks keep a light pulse; + # the last eight bars let it drift out instead of a hard stop. + kick_beats = [0, 2] if airy or bar < 4 else range(4) + if bar >= 232: + kick_beats = [] + for beat in kick_beats: + mix.place('kick', kick, at + beat * BEAT, .22 * gain) + for beat in range(4): + if bar >= 236: + continue + # Eighth-note lift plus a late, quiet sixteenth: light swing. + mix.place('shuffle', opened if beat == 3 else closed, + at + (beat + .54) * BEAT, .039 * gain, + -.18 if beat % 2 else .18) + if bar >= 12 and not airy and beat in (0, 2): + mix.place('shuffle', closed, at + (beat + .80) * BEAT, + .012 * gain, .25) + if bar >= 8 and not airy and bar < 228: + for beat in (1, 3): + mix.place('clap', clap, at + (beat + .025) * BEAT, .043 * gain) + # Bass answers the kick; upper harmonics make the line audible. + if bar >= 8 and not airy and bar < 228: + for beat, midi, length in [(0.55, root, .68), (2.55, root, .65), + (3.55, root + (7 if bar % 2 else 12), .32)]: + bass = ac.bass_tone(midi, length * BEAT, .7) + mix.place('bass', bass, at + beat * BEAT, .075 * gain) + # Short vocal rolls, staggered with mallets on the empty slots. + if not airy and bar < 232: + pattern = [tones[0], tones[2], tones[3], tones[2], tones[4], tones[2]] + count = 2 if bar < 8 else 3 if bar < 24 else 4 + for k in range(8): + beat = bar * 4 + k * .5 + if k % 2 == 0 and k // 2 < count: + saffron.sing(mix, 'rolling', pattern[(bar * 4 + k // 2) % 6], + beat, .85, .46 * gain, + .3 * np.sin(beat / 25), False) + elif k in (3, 7) and bar >= 12: + mix.place('mallets', ac.marimba(tones[(bar + k) % 4], 'rosewood', 1.4), + beat * BEAT, .032 * gain, -.3 if k == 3 else .3) + # Lift comes from sixths/ninths above a stable major root. A single + # breathing upper voice replaces the continuous low two-note bed. + if b % 8 == 0 and bar >= 16: + indigo.sing(mix, 'hums', tones[2], bar * 4 + 2, 6, + .25 * gain, -.1, True) + if b % 2 == 0: + for k, midi in enumerate([tones[1] + 12, tones[3] + 12, tones[2] + 12]): + piano.key(mix, midi, at + (1.5 + k * .55) * BEAT, + 1.5 * BEAT, .34 * gain, vel=.48, pan=.1) + if airy: + if b % 4 == 0: + saffron.sing(mix, 'answer', tones[2], bar * 4 + 1, 3, + .4 * gain, .12, True) + continue + # Returns develop by register and rhythmic answer, with breathing + # gaps between eight-bar statements. The voice arrives at 0:36. + if bar >= 16 and bar < 224 and bar % 16 == 0: + line = MELODIES[1 if 144 <= bar < 180 or bar >= 192 else 0] + for beat, length, midi in line: + lemon.sing(mix, 'lead', midi, bar * 4 + beat + .25, + length, .93 * gain) + # Pitched Indigo replies, only in the gap after Lemon's phrase. + if bar >= 40 and bar < 216 and bar % 16 == 10: + for beat, midi in [(0, tones[2]), (1.5, tones[3]), (3, tones[2])]: + indigo.sing(mix, 'answer', midi, bar * 4 + beat, + .9, .40 * gain, -.15) + if ending: + piano.roll(mix, [48, 64, 67, 74], 236 * BAR, BAR * 4, .2, vel=.35) + return mix, voices, harmony + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--study', type=Path, default=ac.STUDY) + args = parser.parse_args() + ac.STUDY = args.study + for sub in ('cache', 'stems'): + (OUT / sub).mkdir(parents=True, exist_ok=True) + n.OUT, n.BPM, n.BEAT, n.BAR, n.BARS = OUT, BPM, BEAT, BAR, BARS + n.DURATION = BARS * BAR + 12 + n.PHRASES, n.MOTIFS = SECTIONS, MELODIES + ac.BEAT, ac.BAR = BEAT, BAR + mix, voices, harmony = arrange() + settings = {'piano': (.17, 2.8, 8000), 'lead': (.12, 2.3, 10000), + 'hums': (.28, 4.2, 6000), 'rolling': (.19, 3.0, 7800), + 'answer': (.23, 3.2, 8500), 'mallets': (.19, 2.8, 7200), + 'bass': (0, 0, 2100), 'kick': (0, 0, 8000), + 'shuffle': (.06, .8, 12000), 'clap': (.09, 1.0, 10000)} + n.finish(mix, voices, harmony, title='Nighttime Study — Dance', + slug='nighttime-study-dance', settings=settings, + bus_gains={'piano': 2.8, 'hums': .8}, target_lufs=-15, measure=meter) + + +if __name__ == '__main__': + main() diff --git a/pop/afternoon-study/bin/render-night-ringing.py b/pop/afternoon-study/bin/render-night-ringing.py new file mode 100644 index 0000000000..96a67e343e --- /dev/null +++ b/pop/afternoon-study/bin/render-night-ringing.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Your voice as ringing, chorded bells around the Aesthetivox chorale. + +Vocal bell = measured vowel spectrum + pitched WORLD excitation, individual +frequency-band decays. The original vowel formants supply the instrument. +""" +import argparse +import importlib.util +import json +from pathlib import Path +import numpy as np +import soundfile as sf +from scipy.signal import resample_poly + +spec=importlib.util.spec_from_file_location('chorale',Path(__file__).with_name('render-night-chorale.py')) +c=importlib.util.module_from_spec(spec) +spec.loader.exec_module(c) +s,n,ac=c.s,c.n,c.ac +SR,FS=ac.SR,24000 +OUT=n.ROOT/'out/nighttime-ringing' + + +class VoiceBells: + def __init__(self): + self.memory={};self.events=[];self.vowels=[] + # Three different mouths of the same singer, from the same takes. + for name,seconds in [('saffron-swallows',3.0),('indigo-shadows',6.0),('lemon-kittens',8.0)]: + files=list((c.OUT/'cache').glob(f'{name}-*-analysis.npz')) + assert files,'render the chorale once to prepare its source analysis' + with np.load(files[0]) as z: + t=z['t'];f0=z['f0'];sp=z['sp'];ap=z['ap'] + ix=np.where((t>=seconds)&(t0))[0] + assert len(ix)>8,(name,'no stable vowel') + spectrum=np.exp(np.mean(np.log(sp[ix]+1e-12),axis=0)) + noise=np.median(ap[ix],axis=0) + self.vowels.append((name,spectrum,noise)) + + def __call__(self,midi,seconds=15): + # Long fundamental, quicker loss of the upper formants: a vocal + # strike gradually turns into a pure ringing pitch. + seconds=round(seconds*1.6,2) + key=(midi,seconds) + self.events.append(dict(midi=midi,decaySeconds=seconds)) + if key in self.memory:return self.memory[key] + path=OUT/'cache'/f'vocal-bell-{midi}-{seconds}.wav' + if path.exists(): + y=ac.load_mono(path) + else: + bank,spectrum,noise=self.vowels[midi%3] + count=round(seconds/.005) + t=np.arange(count)*.005 + hz=ac.midi_hz(midi) + freq=np.linspace(0,FS/2,len(spectrum)) + decay=seconds/np.clip(1+np.maximum(0,freq-450)/1800,1,5) + env=np.exp(-6.91*t[:,None]/decay[None,:]) + attack=np.minimum(1,t/.009) + sp=np.ascontiguousarray(spectrum[None,:]*(env*attack[:,None])**2) + noise=noise.copy() + noise[freq<3500]*=.6 + noise[freq>4500]=np.maximum(noise[freq>4500],.45) + ap=np.ascontiguousarray(np.tile(noise,(count,1))) + # Almost still pitch, with a very slow beat instead of vibrato. + f0=hz*2**(.008*np.sin(2*np.pi*.33*t)/12) + y=ac.pw.synthesize(f0,sp,ap,FS,5) + y=resample_poly(y,2,1) + y*=.9/max(1e-8,np.max(abs(y))) + y*=ac.fades(len(y),.005,1.0) + sf.write(path,y,SR,subtype='FLOAT') + self.memory[key]=y + return y + + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument('--study',type=Path,default=ac.STUDY) + args=parser.parse_args();ac.STUDY=args.study + for sub in ('stems','cache'):(OUT/sub).mkdir(parents=True,exist_ok=True) + s.TAKES=sorted(s.TAKES+c.EXTRA,key=lambda row:row[3]) + s.RecordedVoice=c.AesthetivoxVoice + # Reuse the already-rendered vocal phrases without altering them. + bells=VoiceBells();s.bell=bells + n.OUT,n.BPM,n.BEAT,n.BAR,n.BARS=OUT,s.BPM,s.BEAT,s.BAR,s.BARS + n.DURATION=s.BARS*s.BAR+34;n.PHRASES=s.SECTIONS;n.MOTIFS=[s.A,s.B] + ac.BEAT,ac.BAR=s.BEAT,s.BAR + mix,voices,harmony=s.arrange() + # Exposed vocal-bell chords give the listener a clear demonstration of + # the new instrument; later chords broaden the slow movement and coda. + for bar,chord,gain in [(4,'C6add9',.14),(20,'Fmaj9',.13), + (128,'Fmaj9',.14),(144,'C6add9',.14), + (216,'Fmaj9',.11),(236,'C6add9',.10)]: + pitches=[m+12 for m in c.VOICINGS[chord]] + for k,midi in enumerate(pitches): + mix.place('bells',bells(midi,24),bar*s.BAR+k*.075,gain,[-.3,0,.3][k]) + settings={'voice':(.27,10,11500),'choir-bass':(.38,13,6400), + 'choir-tenor':(.48,17,7800),'choir-upper':(.53,20,9000), + 'piano':(.25,7,8500),'bells':(.42,16,10500),'sines':(.32,11,7000), + 'kick':(0,0,8000),'bass':(0,0,2100),'shuffle':(.05,.8,12000),'clap':(.08,1,10000)} + n.finish(mix,voices,harmony,title='Nighttime Study — Ringing Voice',slug='nighttime-study-ringing', + settings=settings,bus_gains={'piano':1.05,'bells':.90,'sines':.55}, + target_lufs=-16,measure=s.d.meter,fade_seconds=10, + vocal_source='Jeffrey Sep 26 voice, hard-tuned lead and chorale; bells synthesized from the measured vowel spectra of the same three takes') + receipt=json.loads((OUT/'receipts.json').read_text()) + receipt.update(choraleVoicings=c.VOICINGS,vocalPhrases=len(s.TAKES), + voiceBellDesign='WORLD vowel formants; 9ms strike, frequency-dependent exponential decay, tiny slow pitch drift', + voiceBells=bells.events,roomDecays={k:v[1] for k,v in settings.items()}, + tuning=dict(intraNoteMotion=.04,maxDeviationSemitones=.07,voicedDryBlend=.04), + sources=n.SOURCES+['pop/bin/autotune.py','pop/bin/vowel-hold.py']) + (OUT/'receipts.json').write_text(json.dumps(receipt,indent=2)) + + +if __name__=='__main__':main() diff --git a/pop/afternoon-study/bin/render-night-suite.py b/pop/afternoon-study/bin/render-night-suite.py new file mode 100644 index 0000000000..3284724f7f --- /dev/null +++ b/pop/afternoon-study/bin/render-night-suite.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Nighttime Study: vocal dance suite, overture / exposition / development / +slow movement / transformed return / coda. Original dry mic phrases lead. + +Applies chamber platter 01 (phrase proportions, contrast, transformed return) +and 03 (motivic growth, canon, individually decaying bell partials). The suite +is a loose narrative design, not a claim of strict historical sonata form. +""" +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +import numpy as np +from scipy.signal import butter, sosfilt + +spec = importlib.util.spec_from_file_location('dance', Path(__file__).with_name('render-night-dance.py')) +d = importlib.util.module_from_spec(spec) +spec.loader.exec_module(d) +n, ac = d.n, d.ac +SR = ac.SR +BPM, BARS = 104, 240 +BEAT = 60 / BPM +BAR = BEAT * 4 +OUT = n.ROOT / 'out/nighttime-suite' +SECTIONS = [(0, 32, 'overture'), (32, 40, 'exposition'), + (72, 56, 'development'), (128, 24, 'slow movement'), + (152, 64, 'transformed return'), (216, 24, 'coda')] +# Wide upper voicings; the major home is established by the overture. +CHORDS = { + 'C6add9': (36, [60, 64, 67, 69, 74]), + 'Fmaj9': (41, [60, 64, 65, 69, 74]), + 'G6sus': (43, [60, 62, 67, 69, 76]), + 'Dm9': (38, [60, 64, 65, 69, 74]), + 'Gmajor': (43, [59, 62, 67, 71, 74]), + 'D7sus': (38, [60, 62, 67, 69, 74]), + 'Cmaj9': (36, [59, 62, 64, 67, 72]), +} +# Newly written themes, introduced separately before development. +A = [(0, 1.5, 67), (2, 1, 69), (3.5, 3, 72), (8, 2, 71), (11, 3, 67)] +B = [(0, 3, 74), (4, 2, 72), (7, 1.5, 69), (10, 1, 67), (12, 3, 65)] +# Preserve real phrasing: take, source MIDI seconds, length, destination bar. +# No voice pitch shifting or time stretching; source breaths stay in place. +TAKES = [ + ('saffron-swallows', 0, 7.8, 8), + ('lemon-kittens', 0, 5.7, 24), + ('lemon-kittens', 5.6, 10.2, 34), + ('indigo-shadows', 0, 8.5, 46), + ('saffron-swallows', 34.3, 10.2, 58), + ('lemon-kittens', 16.5, 6.6, 74), + ('indigo-shadows', 17.7, 11.5, 84), + ('saffron-swallows', 9.1, 10.5, 98), + ('lemon-kittens', 24.0, 8.0, 110), + ('indigo-shadows', 29.1, 11.1, 120), + ('saffron-swallows', 19.6, 14.8, 132), + ('lemon-kittens', 0, 15.6, 154), + ('indigo-shadows', 41.0, 10.2, 168), + ('saffron-swallows', 34.3, 10.2, 180), + ('lemon-kittens', 32.0, 9.0, 190), + ('indigo-shadows', 0, 13.2, 202), + ('saffron-swallows', 27.3, 7.4, 220), + ('lemon-kittens', 16.5, 6.6, 230), +] + + +class RecordedVoice: + def __init__(self, name, offset): + self.bank = SimpleNamespace(name=name) + self.path = ac.STUDY / 'sources' / name / 'stems/voice.wav' + self.raw = ac.load_mono(self.path) + self.offset, self.events = offset, [] + self.sha = hashlib.sha256(self.path.read_bytes()).hexdigest() + + def place(self, mix, start, duration, bar): + start = max(0, start + self.offset - .06) + x = self.raw[round(start * SR):round((start + duration) * SR)].copy() + assert len(x) > .98 * duration * SR, self.path + x = sosfilt(butter(2, [85, 11500], btype='band', fs=SR, output='sos'), x) + # Gentle level control on real microphone audio. No resynthesis. + block = SR // 20 + powers = [np.mean(a*a) for a in np.array_split(x, max(1, len(x)//block))] + active = np.sqrt(np.percentile(powers, 70)) + x *= min(12, .14 / max(active, 1e-6)) + x = .40 * np.tanh(x / .40) + x *= ac.fades(len(x), .012, .07) + mix.place('voice', x, bar * BAR, 1.25, 0) + self.events.append(dict(source=str(self.path.relative_to(ac.STUDY)), + sourceSha256=self.sha, sourceStartSeconds=start, + seconds=len(x)/SR, atSeconds=bar*BAR, + processing='HP/LP, gain, gentle saturation, 12/70ms seams; original pitch and timing')) + + +def sine(midi, seconds): + t = np.arange(round(seconds * SR)) / SR + f = ac.midi_hz(midi) + y = (np.sin(2*np.pi*f*t) + .15*np.sin(2*np.pi*2*f*t)) + return y * ac.fades(len(t), 1.6, min(5, seconds/2)) * (.94 + .06*np.cos(t*.8)) + + +def bell(midi, seconds=15): + t = np.arange(round(seconds * SR)) / SR + f = ac.midi_hz(midi) + y = np.zeros(len(t)) + for ratio, amp, decay in [(1, 1, 1), (2, .40, .65), (3, .18, .3), (4.07, .12, .12)]: + y += amp*np.sin(2*np.pi*f*ratio*t)*np.exp(-6.9*t/(seconds*decay)) + return y/1.7*ac.fades(len(t), .009, 1.0) + + +def theme(mix, piano, line, bar, gain, instrument='piano', speed=1, transpose=0): + for beat, length, midi in line: + at = bar*BAR + beat*BEAT*speed + if instrument == 'piano': + piano.key(mix, midi+transpose, at, length*BEAT*speed+1.5, gain, vel=.48) + else: + mix.place('bells', bell(midi+transpose, 18 if speed > 1 else 14), at, gain, .15) + + +def arrange(): + mix = n.Mixer(n.DURATION) + rng = np.random.default_rng(n.SEED) + piano = ac.Piano(rng) + offsets = {r['name']: r['alignment']['offsetSeconds'] for r in + json.loads((ac.STUDY/'vocal-receipts.json').read_text())} + voices = {name: RecordedVoice(name, offsets[name]) for name in ac.NAMES} + for name, src, length, bar in TAKES: + voices[name].place(mix, src, length, bar) + harmony = [] + kick = ac.soft_kick() + hat = ac.brush(.10, .006, 3200, 10000, 14) + clap = ac.brush(.16, .012, 1000, 6000, 45) + routes = { + 'overture': ['C6add9','C6add9','Fmaj9','G6sus'], + 'exposition': ['C6add9','Fmaj9','Dm9','G6sus','C6add9'], + 'development': ['Gmajor','D7sus','Gmajor','Cmaj9','Dm9','G6sus','Gmajor'], + 'slow movement': ['Fmaj9','C6add9','Fmaj9'], + 'transformed return': ['C6add9','Fmaj9','G6sus','C6add9','Fmaj9','Dm9','G6sus','C6add9'], + 'coda': ['Fmaj9','G6sus','C6add9'], + } + for start, bars, name in SECTIONS: + print('arrange', name, flush=True) + for b in range(bars): + bar = start+b + root, notes = CHORDS[routes[name][b//8]] + harmony.append(dict(bar=bar,chord=routes[name][b//8])) + slow = name == 'slow movement' + overture = name == 'overture' + ending = name == 'coda' + growth = .75 + .25*b/bars if name == 'development' else 1 + gain = growth*(1-.6*b/bars if ending else 1) + # Sustained sine harmony crosses bar lines; bell partials ring + # independently. Breathing room replaces repeated short toplines. + if b%8 == 0: + for k,m in enumerate([notes[0],notes[2]]): + mix.place('sines', sine(m, BAR*8+3), bar*BAR, + (.016 if slow else .012)*gain, -.3 if k==0 else .3) + piano.roll(mix,[root+12,notes[1]+12,notes[3]+12],bar*BAR,BAR*4,.24*gain,.4) + mix.place('bells',bell(notes[2]+12,22 if slow else 15),bar*BAR,.08*gain,.2) + # Overture begins freely; the dance pulse enters in stages. + pulse = not slow and (not overture or b>=16) and bar<232 + if pulse: + for beat in ([0,2] if overture or ending else range(4)): + mix.place('kick',kick,(bar*4+beat)*BEAT,.25*gain) + for beat in range(4): + mix.place('shuffle',hat,(bar*4+beat+.54)*BEAT,.035*gain,(-1)**beat*.16) + if not overture and not ending: + for beat in [1,3]: + mix.place('clap',clap,(bar*4+beat+.02)*BEAT,.042*gain) + for beat,m,length in [(0.6,root,.6),(2.6,root,.6),(3.6,root+7,.25)]: + mix.place('bass',ac.bass_tone(m,length*BEAT,.7),(bar*4+beat)*BEAT,.065*gain) + # Different accompanimental grammar per movement; rests keep the + # sung phrases audible. The return develops earlier piano cells. + if name in ('exposition','development','transformed return') and b%4<3: + order = [0,2,1,3,2,4] if name!='development' else [4,2,3,1,2,0] + subdivision = BEAT/2 if name!='transformed return' else BEAT/3 + for k in range(6 if name!='transformed return' else 9): + m=notes[order[(bar+k)%6]]+12 + piano.key(mix,m,bar*BAR+k*subdivision,2.5*BEAT,.20*gain,vel=.38) + if overture and b in (0,12,24): + theme(mix,piano,A if b!=12 else B,bar,.34, speed=1.4) + if name=='exposition' and b in (8,24): + theme(mix,piano,A if b==8 else B,bar,.25,'bells') + if name=='development' and b in (0,12,28,44): + # Motif fragments invert their contour and answer in canon. + line=[(at,length,134-midi) for at,length,midi in A[:3]] + theme(mix,piano,line,bar,.24,speed=.75,transpose=2) + theme(mix,piano,line,bar+2,.08,'bells',speed=1.5,transpose=14) + if slow and b in (0,12): + theme(mix,piano,B,bar,.16,'bells',speed=2) + if name=='transformed return' and b in (0,16,36,52): + theme(mix,piano,A if b%32==0 else B,bar,.30,speed=1.5) + if ending and b in (0,12): + theme(mix,piano,A[:3],bar,.22 if b==0 else .14,'bells',speed=2) + if name=='coda': + mix.place('bells',bell(72,24),236*BAR,.07) + # The actual recorded voice owns the foreground. Instrumental mids duck + # beneath each continuous phrase; the kick and bass keep the dance moving. + duck=np.ones(mix.total,dtype='float32') + for voice in voices.values(): + for event in voice.events: + a=round(event['atSeconds']*SR); z=min(mix.total,a+round(event['seconds']*SR)) + duck[a:z]=np.minimum(duck[a:z],.42) + attack=min(round(.25*SR),a) + duck[a-attack:a]=np.minimum(duck[a-attack:a],np.linspace(1,.42,attack)) + end=min(mix.total,z+round(.8*SR)) + duck[z:end]=np.minimum(duck[z:end],np.linspace(.42,1,end-z)) + for bus in ('piano','bells','sines'): + mix.buses[bus][:] *= duck[:,None] + return mix,list(voices.values()),harmony + + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument('--study',type=Path,default=ac.STUDY) + args=parser.parse_args(); ac.STUDY=args.study + for sub in ('stems','cache'): (OUT/sub).mkdir(parents=True,exist_ok=True) + n.OUT,n.BPM,n.BEAT,n.BAR,n.BARS=OUT,BPM,BEAT,BAR,BARS + n.DURATION=BARS*BAR+18; n.PHRASES=SECTIONS; n.MOTIFS=[A,B] + ac.BEAT,ac.BAR=BEAT,BAR + mix,voices,harmony=arrange() + settings={'voice':(.045,1.2,11500),'piano':(.2,4,8500), + 'bells':(.25,7,10500),'sines':(.24,6,7000), + 'kick':(0,0,8000),'bass':(0,0,2100), + 'shuffle':(.05,.8,12000),'clap':(.08,1,10000)} + n.finish(mix,voices,harmony,title='Nighttime Study — Vocal Suite',slug='nighttime-study-suite', + settings=settings,bus_gains={'piano':2.2},target_lufs=-16,measure=d.meter, + vocal_source='Original Sep 26 stems/voice.wav microphone recordings; continuous phrases, original pitch and timing, no WORLD or phase-vocoder processing') + + +if __name__=='__main__': main() diff --git a/pop/afternoon-study/bin/render-night.py b/pop/afternoon-study/bin/render-night.py new file mode 100644 index 0000000000..d85b1ec7b1 --- /dev/null +++ b/pop/afternoon-study/bin/render-night.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Nighttime Study — new composition for Jeffrey's three Sep 26 vocal banks. + +Platter applications (creative choices, not claims about the source works): +chamber/digest/01 R1,R2,R6,R12: unequal phrases, growing/mirrored melody, +stillness, opening recalled; 03: gradual substitution and half-speed answer; +rhythm/digest/06: rolling vocal and mallet attacks occupy complementary slots. +Original Afternoon Study is left intact. Run with pop/.venv/bin/python. +""" +import argparse +import gc +import hashlib +import json +from pathlib import Path + +import numpy as np +import soundfile as sf +from scipy.signal import butter, sosfilt, oaconvolve +import render as ac + +SR = ac.SR +BPM = 72 +BEAT = 60 / BPM +BAR = BEAT * 4 +BARS = 160 +DURATION = BARS * BAR + 12 +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'out/nighttime' +SEED = 9302026 +# The same four upper voices move in small steps; bass changes their meaning. +CHORDS = { + 'Am9': (45, [57, 60, 64, 71]), + 'Fmaj9': (41, [57, 60, 64, 67]), + 'Cmaj9': (48, [55, 59, 62, 64]), + 'G6add9': (43, [57, 59, 62, 67]), + 'Dm9': (38, [57, 60, 64, 65]), + 'Em7': (40, [55, 59, 62, 67]), +} +# start, length, identity, harmonic route +PHRASES = [ + (0, 8, 'lamp', ['Am9']), + (8, 12, 'first hum', ['Am9', 'Fmaj9', 'Am9']), + (20, 16, 'rolling in', ['Am9', 'Fmaj9', 'Cmaj9', 'G6add9']), + (36, 8, 'first melody', ['Dm9', 'Am9']), + (44, 12, 'answer', ['Fmaj9', 'Cmaj9', 'Em7']), + (56, 8, 'still room', ['Am9']), + (64, 16, 'returning motion', ['Am9', 'Fmaj9', 'Cmaj9', 'G6add9']), + (80, 12, 'long ascent', ['Dm9', 'Fmaj9', 'Am9']), + (92, 16, 'open window', ['Fmaj9', 'Cmaj9', 'G6add9', 'Am9']), + (108, 8, 'night bloom', ['Dm9', 'Am9']), + (116, 12, 'afterglow', ['Fmaj9', 'Cmaj9', 'Am9']), + (128, 8, 'distant answer', ['Dm9', 'Am9']), + (136, 16, 'lamp returns', ['Am9', 'Fmaj9', 'Am9', 'Am9']), + (152, 8, 'last vowel', ['Am9']), +] +# New melodies, not the source tunes or Afternoon Study's verse/chorus. +# Beat offset, duration, MIDI; each phrase grows then mirrors toward E4. +MOTIFS = [ + [(0, 3, 64), (5, 4, 67), (11, 4, 64)], + [(0, 3, 64), (4, 2, 67), (7, 4, 69), (13, 3, 67), (18, 5, 64)], + [(0, 3, 64), (4, 3, 67), (8, 3, 69), (12, 4, 71), + (18, 3, 69), (22, 3, 67), (27, 5, 64)], + [(0, 4, 69), (6, 3, 72), (10, 4, 74), (16, 3, 72), + (21, 3, 69), (26, 6, 64)], +] +SOURCES = [ + 'papers/chamber-platter/digest/01-tonal-forms.md', + 'papers/chamber-platter/digest/03-process-and-envelopes.md', + 'papers/rhythm-platter/digest/06-complements-canons.md', + 'papers/arxiv-loner-arrangement-critique/loner-arrangement-critique.md', +] + + +class Mixer(ac.Mixer): + def bus(self, name): + if name not in self.buses: + self.buses[name] = np.memmap(OUT / f'{name}.buffer', dtype='float32', + mode='w+', shape=(self.total, 2)) + return self.buses[name] + + +class Voice: + def __init__(self, bank): + self.bank = bank + self.cache = {} + self.events = [] + + def tone(self, midi, beats, soft=False): + key = (midi, beats, soft) + if key in self.cache: + return self.cache[key] + dur = beats * BEAT + pool = [n for n in self.bank.manifest['notes'] if n['pitchable'] + and n['end'] - n['start'] > .18] + note = min(pool, key=lambda n: abs(n['samples']['lead']['targetMidi'] - midi) + + .3 / (n['end'] - n['start'])) + shift = midi - note['samples']['lead']['targetMidi'] + filename = OUT / 'cache' / f'{self.bank.name}-{midi}-{beats}-{soft}.wav' + if filename.exists(): + y = ac.load_mono(filename).astype('float32') + else: + raw = self.bank.sample(note['index'], shift=shift).astype('float32') + # Phase-vocoder duration change keeps the note's measured pitch. + y = ac.librosa.effects.time_stretch(raw, rate=len(raw) / (dur * SR)) + y = y[:round(dur * SR)] + y *= ac.fades(len(y), .6 if soft else .045, min(1.2 if soft else .25, dur * .3)) + rms = np.sqrt(np.mean(y * y)) + y *= min(.10 / max(rms, 1e-6), .75 / max(float(np.max(np.abs(y))), 1e-6)) + sf.write(filename, y, SR, subtype='FLOAT') + self.cache[key] = y + return y + + def sing(self, mix, bus, midi, beat, length, gain, pan=0, soft=False): + mix.place(bus, self.tone(midi, length, soft), beat * BEAT, gain, pan) + self.events.append(dict(bus=bus, midi=midi, beat=beat, length=length, gain=round(gain, 4))) + + +def arrange(): + ac.BEAT, ac.BAR = BEAT, BAR + rng = np.random.default_rng(SEED) + piano = ac.Piano(rng) + mix = Mixer(DURATION) + voices = [Voice(ac.Bank(n, OUT / 'cache')) for n in ac.NAMES] + lemon, indigo, saffron = voices + harmony = [] + for pi, (start, bars, name, route) in enumerate(PHRASES): + print(f'arrange {start:3d}: {name}', flush=True) + # 120 seconds of gradual growth leads into the late bloom. + strength = .6 if start < 64 else min(1.1, .6 + max(0, start - 80) / 64) + if start >= 116: + strength = .34 if start < 128 else .5 * (160 - start) / 32 + if start == 56: + # Two held vocal notes; no fresh attacks inside this clearing. + indigo.sing(mix, 'hums', 57, start * 4, 32, .22, -.2, True) + saffron.sing(mix, 'hums', 64, start * 4, 32, .16, .2, True) + continue + if start == 152: + indigo.sing(mix, 'hums', 57, start * 4, 32, .20, 0, True) + piano.roll(mix, [45, 64, 71], start * BAR, BAR * 6, .16, vel=.3) + continue + for b in range(bars): + bar = start + b + chordname = route[min(len(route) - 1, b // 4)] + root, tones = CHORDS[chordname] + harmony.append(dict(bar=bar, chord=chordname)) + if b % 4 == 0: + # Slow common-tone harmony; each voice enters at breath length. + if start >= 8 and not (116 <= start < 128): + for k, midi in enumerate((tones[0], tones[2])): + indigo.sing(mix, 'hums', midi, bar * 4 + k * .35, + 16, .42 * strength, (-.25, .25)[k], True) + piano.roll(mix, [root + 12, tones[1] + 12, tones[2] + 12], + bar * BAR, BAR * 2.2, .18 * strength, vel=.32) + if start < 20 or start >= 136: + # Opening recalled at half its original attack density. + if b % (2 if start < 20 else 4) == 0: + for k, m in enumerate((76, 79, 76)): + piano.key(mix, m, bar * BAR + k * 1.3 * BEAT, + BEAT * 3, .21 * strength, vel=.35) + continue + if 116 <= start < 128: + continue + # Six-beat pitch cycle slips across four-beat bars. At first only + # some slots are filled. Mallets inhabit the unused odd slots. + count = 2 if start < 36 else 3 if start < 80 else 4 + pattern = [tones[1], tones[2], tones[3], tones[2], tones[0] + 12, tones[2]] + for k in range(8): + beat = bar * 4 + k * .5 + if k % 2 == 0 and k // 2 < count: + midi = pattern[(bar * 4 + k // 2) % 6] + saffron.sing(mix, 'rolling', midi, beat, 1.5, + .34 * strength * (1 + .12 * np.sin(beat / 9)), + .38 * np.sin(beat / 19), True) + elif k in (3, 7) and start >= 44 and (bar + k) % 3 != 0: + midi = tones[(bar + k) % 4] - 12 + mix.place('mallets', ac.marimba(midi, 'bass', 2.4), beat * BEAT, + .026 * strength, -.3 if k == 3 else .3) + # Piano answers in the space after the figure, with whole bars off. + if b % 4 == 2: + for k, midi in enumerate([tones[3] + 12, tones[2] + 12]): + piano.key(mix, midi, (bar * 4 + 2 + k) * BEAT, + BEAT * 3, .19 * strength, vel=.4) + # Rounded, mono bass only in the long ascent, with rests. + if 80 <= start < 116 and b % 2 == 0: + bass = ac.bass_tone(root, BEAT * 4, .8) + bass *= ac.fades(len(bass), .5, 1.2) + mix.place('bass', bass, bar * BAR, .024 * strength) + if 36 <= start < 116 or start == 128: + mi = {36: 0, 44: 1, 64: 1, 80: 2, 92: 2, 108: 3, 128: 0}[start] + line = MOTIFS[mi] + for beat, length, midi in line: + lemon.sing(mix, 'lead', midi, start * 4 + beat + 1, length, + .95 * strength, 0) + # A half-speed answer is reserved for one long phrase, not stacked + # under every foreground phrase. + if start == 92: + for beat, length, midi in MOTIFS[0]: + saffron.sing(mix, 'answer', midi - 12, start * 4 + 30 + beat * 2, + length * 2, .55 * strength, .18) + return mix, voices, harmony + + +def room_ir(seconds, seed): + rng = np.random.default_rng(seed) + n = round(seconds * SR) + t = np.arange(n) / SR + ir = sosfilt(butter(2, [260, 4400], btype='band', fs=SR, output='sos'), + rng.normal(size=n)).astype('float32') + ir *= np.exp(-6.91 * t / seconds) * np.minimum(1, t / .08) + ir /= max(1e-9, np.sqrt(np.sum(ir * ir))) + for delay, gain in [(.061, .24), (.109, .16), (.173, .1)]: + ir[round(delay * SR)] += gain + return ir + + +def finish(mix, voices, harmony, *, title='Nighttime Study', slug='nighttime-study', + settings=None, bus_gains=None, target_lufs=-17, measure=ac.loudness, + vocal_source='Jeffrey, Sep 26 2026 Menu Band Aesthetivox note banks', + fade_seconds=26): + n = mix.total + summed = np.memmap(OUT / 'sum.buffer', dtype='float32', mode='w+', shape=(n, 2)) + # Wet gain, room decay, lowpass. Dry lead stays centered and identifiable. + settings = settings or {'piano': (.20, 3.8, 6800), 'lead': (.20, 4.8, 8500), + 'hums': (.44, 7.5, 4200), 'rolling': (.31, 5.8, 5800), + 'answer': (.36, 6.8, 6500), 'mallets': (.30, 5.0, 5000), + 'bass': (0, 0, 1800)} + stem_metrics = {} + for bus, data in list(mix.buses.items()): + print('space and mix:', bus, flush=True) + wet, seconds, cutoff = settings[bus] + shaped = np.empty((n, 2), dtype='float32') + for ch in range(2): + y = sosfilt(butter(2, [35 if bus in ('piano', 'bass', 'kick') else 135, cutoff], + btype='band', fs=SR, output='sos'), data[:, ch]).astype('float32') + if wet: + reverb = oaconvolve(y, room_ir(seconds, SEED + ch * 77), mode='full')[:n] + shaped[:, ch] = y + wet * reverb + del reverb + else: + shaped[:, ch] = y + # Gentle disappearance over the final held vowel and room tail. + shaped *= (bus_gains if bus_gains is not None else + {'piano': 2.8, 'mallets': 1.6, 'bass': 2.5}).get(bus, 1) + fade = round(fade_seconds * SR) + shaped[-fade:] *= np.cos(np.linspace(0, np.pi / 2, fade))[:, None] ** 2 + shaped[:round(2 * SR)] *= np.linspace(0, 1, round(2 * SR))[:, None] + assert np.isfinite(shaped).all(), bus + sf.write(OUT / 'stems' / f'{bus}.wav', shaped, SR, subtype='PCM_24') + summed[:] += shaped + stem_metrics[bus] = dict(peak=float(np.abs(shaped).max()), rms=float(np.sqrt(np.mean(shaped ** 2)))) + del shaped, y + del mix.buses[bus] + gc.collect() + sf.write(OUT / f'{slug}-premaster.wav', summed, SR, subtype='FLOAT') + del summed + gc.collect() + premaster = OUT / f'{slug}-premaster.wav' + measured = measure(premaster) + # Static gain preserves the entire long-form dynamic arc. Ceiling limits + # gain; the oversampled safety limiter should barely work, if at all. + gain = min(target_lufs - float(measured['input_i']), -2.5 - float(measured['input_tp'])) + wav = OUT / f'{slug}.wav' + ac.ffmpeg('-i', premaster, '-af', + f'volume={gain:.4f}dB,aresample=192000,alimiter=limit=0.75:attack=5:release=150:level=false:latency=true,aresample=48000', + '-c:a', 'pcm_s24le', wav) + mp3 = OUT / f'{slug}.mp3' + ac.ffmpeg('-i', wav, '-c:a', 'libmp3lame', '-b:a', '320k', + '-metadata', f'title={title}', '-metadata', 'artist=Aesthetic Dot Computer', mp3) + checked, encoded = measure(wav), measure(mp3) + assert float(checked['input_tp']) <= -2.0 + assert float(encoded['input_tp']) < 0 + receipt = dict(title=title, artist='Aesthetic Dot Computer', bpm=BPM, + seconds=DURATION, bars=BARS, seed=SEED, phrases=PHRASES, + newMelodies=MOTIFS, sources=SOURCES, barChords=harmony, + voices={v.bank.name: v.events for v in voices}, + vocalSource=vocal_source, + piano='Salamander Grand Piano, Alexander Holm, CC0; AC OS anchors', + stemMetrics=stem_metrics, gainDB=gain, wav=checked, mp3=encoded, + sha256={p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (wav, mp3)}) + (OUT / 'receipts.json').write_text(json.dumps(receipt, indent=2)) + print(json.dumps(dict(seconds=DURATION, wav=checked, mp3=encoded)), flush=True) + premaster.unlink() + for path in OUT.glob('*.buffer'): + path.unlink() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--study', type=Path, default=ac.STUDY) + args = parser.parse_args() + ac.STUDY = args.study + for sub in ('cache', 'stems'): + (OUT / sub).mkdir(parents=True, exist_ok=True) + mix, voices, harmony = arrange() + finish(mix, voices, harmony) + + +if __name__ == '__main__': + main() diff --git a/pop/afternoon-study/bin/render.py b/pop/afternoon-study/bin/render.py new file mode 100644 index 0000000000..09ceb17de4 --- /dev/null +++ b/pop/afternoon-study/bin/render.py @@ -0,0 +1,932 @@ +#!/usr/bin/env python3 +"""Afternoon Study: one dance track from the three Sep 26 Menu Band takes. + +Saffron Swallows supplies the arpeggio (its C-D-E-F-E-D-C arch, chopped to +sixteenths) and the slow topline of the breakdown. Indigo Shadows supplies +the rhythm (its recorded percussion, sliced into hits and re-sequenced on +its own folded groove) and the hymn (its long hums, WORLD-shifted into +chord tones). Lemon Kittens supplies the hook (its C-D-E-D figure on +eighths) and the climax (its rising answer). Kick, bass and keys are +authored. Every vocal sound is @jeffrey's own pitch-corrected note sample. +""" +import argparse +import hashlib +import json +import math +import re +import subprocess +from pathlib import Path + +import numpy as np +import soundfile as sf +import librosa +import pyworld as pw +from scipy.signal import resample_poly, butter, sosfiltfilt + +SR = 48000 +BPM = 118 +BEAT = 60 / BPM +BAR = 4 * BEAT +STEP = BEAT / 4 # a sixteenth +STUDY = Path.home() / 'Documents/Shelf/Menu Band pop sketches 2026-09-26' +NAMES = ['lemon-kittens', 'indigo-shadows', 'saffron-swallows'] + + +# ---------------------------------------------------------------- utilities +def ffmpeg(*args): + result = subprocess.run(['ffmpeg', '-hide_banner', '-nostats', '-y', *map(str, args)], + text=True, capture_output=True) + if result.returncode: + raise RuntimeError(result.stderr[-4000:]) + return result + + +def loudness(path): + r = ffmpeg('-i', path, '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-') + return json.loads(re.search(r'\{\s*"input_i"[\s\S]*?\}', r.stderr)[0]) + + +def normalize(source, target, level, peak=-3.0): + measured = loudness(source) + adjustment = min(level - float(measured['input_i']), peak - float(measured['input_tp'])) + assert math.isfinite(adjustment), f'silent source: {source}' + ffmpeg('-i', source, '-af', f'volume={adjustment:.3f}dB', '-ar', SR, '-ac', 2, '-c:a', 'pcm_f32le', target) + return adjustment + + +def load_mono(path, sr=SR): + x, rate = sf.read(path, always_2d=True) + x = x.mean(axis=1) + if rate != sr: + g = math.gcd(sr, rate) + x = resample_poly(x, sr // g, rate // g) + return x.astype(np.float64) + + +def fades(n, attack, release): + env = np.ones(n) + a, r = min(n, round(attack * SR)), min(n, round(release * SR)) + if a: + env[:a] = .5 - .5 * np.cos(np.linspace(0, np.pi, a)) + if r: + env[n - r:] *= .5 + .5 * np.cos(np.linspace(0, np.pi, r)) + return env + + +def midi_hz(m): + return 440 * 2 ** ((m - 69) / 12) + + +NOTE_RE = re.compile(r'([A-G])(s?)(\d)') +def name_midi(name): + letter, sharp, octave = NOTE_RE.match(name).groups() + return 12 * (int(octave) + 1) + 'C D EF G A B'.index(letter) + (1 if sharp else 0) + + +# ------------------------------------------------------------- vocal banks +class Bank: + """One take's Aesthetivox note bank (24 kHz mono, one file per note).""" + def __init__(self, name, cache): + self.name = name + self.root = STUDY / 'enhanced-v2/vocal-banks' / name + self.manifest = json.loads((self.root / 'manifest.json').read_text()) + self.cache = cache + self.used = set() + self._audio = {} + + def note(self, index, part='lead'): + return self.manifest['notes'][index]['samples'][part] + + def midi(self, index, part='lead'): + return self.note(index, part)['targetMidi'] + + def raw(self, index, part='lead'): + key = (index, part) + if key not in self._audio: + x = load_mono(self.root / self.note(index, part)['file'], 24000) + rms = float(np.sqrt(np.mean(x ** 2))) + if rms > 1e-5: + x = x * min(.12 / rms, .9 / max(1e-6, np.abs(x).max())) + self._audio[key] = x + return self._audio[key] + + def sample(self, index, part='lead', shift=0, stretch=1.0): + """The note at 48 kHz, optionally WORLD-shifted (semitones) / stretched.""" + self.used.add((index, part, shift, round(stretch, 3))) + if shift == 0 and stretch == 1.0: + return resample_poly(self.raw(index, part), 2, 1) + tag = f'{self.name}-{index:03d}-{part}-{shift:+d}-x{stretch:.3f}.wav' + path = self.cache / tag + if path.exists(): + return load_mono(path) + x = np.ascontiguousarray(self.raw(index, part)) + fs = 24000 + f0, t = pw.harvest(x, fs, f0_floor=65, f0_ceil=1400, frame_period=5) + f0 = pw.stonemask(x, f0, t, fs) + fft = pw.get_cheaptrick_fft_size(fs, f0_floor=65) + sp = pw.cheaptrick(x, f0, t, fs, fft_size=fft, f0_floor=65) + ap = pw.d4c(x, f0, t, fs, fft_size=fft) + if stretch != 1.0: + src = np.arange(len(f0)) + dst = np.linspace(0, len(f0) - 1, round(len(f0) * stretch)) + f0 = np.interp(dst, src, f0) + sp = np.stack([np.interp(dst, src, sp[:, k]) for k in range(sp.shape[1])], axis=1) + ap = np.stack([np.interp(dst, src, ap[:, k]) for k in range(ap.shape[1])], axis=1) + f0 = f0 * 2 ** (shift / 12) + y = pw.synthesize(np.ascontiguousarray(f0), np.ascontiguousarray(sp), + np.ascontiguousarray(ap), fs, 5) + y = y / max(1e-6, np.abs(y).max()) * max(1e-6, np.abs(x).max()) + y = resample_poly(y, 2, 1) + sf.write(path, y, SR, subtype='FLOAT') + return y + + def chop(self, index, length, part='lead', shift=0, skip=0.0, attack=.004, release=.03, stretch=1.0): + x = self.sample(index, part, shift, stretch)[round(skip * SR):] + n = min(len(x), round(length * SR)) + return x[:n] * fades(n, attack, release) + + + + +# ------------------------------------------------ Indigo's recorded thumps +def indigo_thumps(cache): + """Slice the loud, real hits out of Indigo Shadows' percussion stem. + + Only hits with a peak above .3 are kept: the quiet taps and ticks in this + stem are recording floor, and normalizing them up is what sounded like + static in the earlier cuts. + """ + stem = STUDY / 'sources/indigo-shadows/stems/percussion.wav' + y = load_mono(stem) + onsets = librosa.onset.onset_detect(y=y, sr=SR, units='time', backtrack=False, delta=.05) + shots, report = [], [] + for i, t in enumerate(onsets): + a = round(t * SR) + nxt = round(onsets[i + 1] * SR) if i + 1 < len(onsets) else len(y) + seg = y[a:min(nxt, a + round(.35 * SR))] + peak = float(np.abs(seg[:round(.06 * SR)]).max()) + if peak < .3 or len(seg) < round(.08 * SR): + continue + seg = seg * fades(len(seg), .002, .03) + shots.append(seg / peak) + report.append(dict(time=round(float(t), 3), peak=round(peak, 3))) + shots.sort(key=lambda s: -np.abs(s).max()) + return shots[:8], dict(stem=str(stem.relative_to(STUDY)), onsets=len(onsets), thumpsKept=len(shots[:8]), hits=report[:8]) + + +# ------------------------------------------------------------- the kit +KIT_DIR = Path(__file__).resolve().parents[2] / 'imab/samples/real' + + +def load_kit(): + meta = json.loads((KIT_DIR / 'kit-real.json').read_text()) + kit, credits = {}, {} + for name in ['clap', 'snare', 'hat-closed', 'hat-open', 'shaker']: + assert 'zero/1.0' in meta[name]['license'], name + x = load_mono(KIT_DIR / f'{name}.wav') + kit[name] = x / max(1e-6, np.abs(x).max()) + credits[name] = dict(name=meta[name]['name'], by=meta[name]['by'], url=meta[name]['url'], license='CC0') + return kit, credits + + +# -------------------------------------------- the AC OS grand (Salamander) +PIANO_DIR = Path(__file__).resolve().parents[3] / 'fedac/native/samples/piano' +PIANO_RELEASE = .42 + + +class Piano: + """The same Salamander anchors AC OS plays, voiced the way pianotrax does.""" + def __init__(self, rng): + self.anchors = {} + for path in sorted(PIANO_DIR.glob('*.raw')): + self.anchors[int(path.stem)] = np.fromfile(path, dtype=np.float32).astype(np.float64) + assert 60 in self.anchors, 'no piano bank' + self.rng = rng + + def note(self, midi, dur, vel=.7, pan=0.0): + anchor = min(self.anchors, key=lambda a: abs(a - midi)) + src = self.anchors[anchor] + step = 2 ** ((midi - anchor) / 12) + n = min(int((len(src) - 1) / step), round((dur + PIANO_RELEASE) * SR)) + pos = np.arange(n) * step + y = np.interp(pos, np.arange(len(src)), src) + env = np.ones(n) + att = max(1, round(.0015 * SR)) + env[:att] = np.linspace(0, 1, att) + hold = round(dur * SR) + if hold < n: + rel = np.linspace(1, 0, n - hold) + env[hold:] *= rel + amp = (.70 + .85 * vel) + kbpan = np.clip((midi - 60) / 40, -1, 1) + P = pan * .5 + kbpan * .35 + y = y * env * amp + return np.stack([y * (1 - P * .5), y * (1 + P * .5)], axis=1) + + def key(self, mix, midi, at, dur, gain, vel=.7, pan=0.0): + """A humanized press: a hair late, jittered, velocity wobbled (pianotrax's hazy hand). + + On top of the per-note jitter the hand drifts slowly (a few ms ahead or + behind over a couple of bars) and leans into phrases, so no two bars + land the same way. + """ + drift = .007 * math.sin(at * 2 * math.pi / (BAR * 2.7)) + .004 * math.sin(at * 2 * math.pi / (BAR * 1.3) + 1) + t = max(0.0, at + .016 + drift + self.rng.normal(0, .009)) + lean = 1 + .12 * math.sin(at * 2 * math.pi / (BAR * 4) - 1.2) + g = gain * lean * (1 + self.rng.uniform(-.22, .22)) + v = float(np.clip(vel + self.rng.normal(0, .07), .2, 1)) + d = dur * (1 + self.rng.uniform(-.2, .15)) + mix.place_stereo('piano', self.note(midi, d, v, pan), t, g) + + def roll(self, mix, midis, at, dur, gain, vel=.65): + spread = self.rng.uniform(.012, .034) + order = sorted(midis) if self.rng.random() < .75 else sorted(midis, reverse=True) + for k, m in enumerate(order): + self.key(mix, m, at + k * spread, dur, gain, vel) + + def thin(self, notes, keep=(2, 3)): + """A lighter hand: two or three notes of the voicing, a different pick each time.""" + count = min(len(notes), int(self.rng.integers(keep[0], keep[1] + 1))) + picks = sorted(self.rng.choice(len(notes), count, replace=False)) + return [notes[i] for i in picks] + + +# ------------------------------------------------------------- synthesis +def kick(vel=1.0): + n = round(.34 * SR) + t = np.arange(n) / SR + hz = 43 + 105 * np.exp(-t * 26) + phase = 2 * np.pi * np.cumsum(hz) / SR + body = np.sin(phase) * np.exp(-t * 8.5) + click = np.sin(2 * np.pi * np.cumsum(320 * np.exp(-t * 60) + 90) / SR) * np.exp(-t * 90) * .4 + return (np.tanh(1.5 * (body + click)) / 1.25) * vel * fades(n, .0005, .03) + + +def sub_stinger(midi_from=33, midi_to=45, beats=3.8, drive=3.4): + """femrag++'s drop stinger: a driven sub that slides up under the downbeat.""" + n = round(beats * BEAT * SR) + t = np.arange(n) / SR + m = midi_from + (midi_to - midi_from) * np.minimum(1, t / (beats * BEAT * .8)) + phase = 2 * np.pi * np.cumsum(midi_hz(m)) / SR + wave = np.tanh(drive * np.sin(phase)) / np.tanh(drive) + env = np.minimum(1, t / .01) * np.exp(-t * .9) * fades(n, .01, .3) + return wave * env + + +def bass_tone(midi, duration, drive=1.4): + n = round((duration + .08) * SR) + t = np.arange(n) / SR + phase = 2 * np.pi * midi_hz(midi) * t + wave = np.sin(phase) + .35 * np.sin(2 * phase) + .1 * np.sin(3 * phase) + wave = np.tanh(wave * drive) / drive + env = np.minimum(1, t / .006) * np.where(t < duration, 1, np.exp(-(t - duration) / .025)) + return wave * env * (.6 + .4 * np.exp(-t * 6)) + + +from scipy.signal import iirpeak, lfilter + +MALLET = { # ported from pop/marimba/synths/marimba.mjs + 'rosewood': dict(partials=[1, 4, 9.2], amps=[1, .32, .1], decays=[1.6, .32, .09], mallet=.0025, resQ=18, resGain=.65), + 'bass': dict(partials=[1, 4], amps=[1, .18], decays=[2.4, .55], mallet=.0038, resQ=25, resGain=1.0), + 'staccato': dict(partials=[1, 4, 10, 17], amps=[1, .55, .28, .12], decays=[.55, .18, .06, .02], mallet=.001, resQ=12, resGain=0), + 'roll': dict(partials=[1, 4, 9.5], amps=[1, .3, .1], decays=[.65, .2, .07], mallet=.0022, resQ=16, resGain=.5), +} + + +def marimba(midi, preset='rosewood', decay_mul=1.0): + """Modal bar: damped sines at the bar's inharmonic ratios, a half-cosine mallet, a tube at f0.""" + P = MALLET[preset] + f0 = midi_hz(midi) + n = round((max(P['decays']) * decay_mul * 1.2 + .05) * SR) + t = np.arange(n) / SR + y = np.zeros(n) + total = sum(P['amps']) + for ratio, amp, t60 in zip(P['partials'], P['amps'], P['decays']): + f = f0 * ratio + if f > SR * .45: + continue + # a longer mallet contact low-passes the strike: higher modes get less + contact = P['mallet'] + lp = max(0.0, np.cos(np.pi * min(.5, f * contact / 2))) + y += (amp / total) * lp * np.sin(2 * np.pi * f * t) * np.exp(-np.log(1000) / (t60 * decay_mul) * t) + y *= np.minimum(1, t / P['mallet']) + if P['resGain'] and f0 < SR * .45: + b, a = iirpeak(f0, P['resQ'], fs=SR) + y = y + P['resGain'] * lfilter(b, a, y) + return y / max(1e-6, np.abs(y).max()) * fades(n, .0003, .02) + + +def block(midi=89): + return marimba(midi, 'staccato') + + +def bubble(seed=0, hz_from=900, hz_to=260): + """A pop: a sine that falls an octave and a half in forty milliseconds.""" + n = round(.09 * SR) + t = np.arange(n) / SR + hz = hz_to + (hz_from - hz_to) * np.exp(-t * 70) + y = np.sin(2 * np.pi * np.cumsum(hz) / SR) * np.exp(-t * 55) + return y * fades(n, .0005, .02) + + +def brush(seconds=.22, attack=.06, low=1800, high=7000, seed=21): + """A brush swish: a band of noise that swells in and falls away.""" + n = round(seconds * SR) + t = np.arange(n) / SR + noise = np.random.default_rng(seed).normal(0, 1, n) + sos = butter(2, [low, high], 'band', fs=SR, output='sos') + env = np.minimum(1, t / attack) * np.exp(-np.maximum(0, t - attack) * 18) + y = sosfiltfilt(sos, noise) * env + return y / max(1e-6, np.abs(y).max()) * fades(n, .005, .03) + + +def soft_kick(vel=1.0): + """A round thump with no click: 75 → 46 Hz, two hundred milliseconds.""" + n = round(.24 * SR) + t = np.arange(n) / SR + hz = 46 + 29 * np.exp(-t * 30) + y = np.sin(2 * np.pi * np.cumsum(hz) / SR) * np.exp(-t * 11) + return np.tanh(1.2 * y) / 1.1 * vel * fades(n, .002, .04) + + +def gliss(mix, piano, at, top=96, bottom=72, gain=.35): + """A marimba glissando up into the downbeat.""" + notes = [m for m in range(bottom, top + 1) if m % 12 in (0, 2, 4, 7, 9)] + for k, m in enumerate(notes): + mix.place('mallet', marimba(m, 'roll'), at - (len(notes) - k) * .028, gain * (.5 + .5 * k / len(notes)), -.5 + k / len(notes)) + + +def riser(bars_long): + """femrag++'s riser: a noise band sweeping 500→7500 Hz under two detuned sines climbing two octaves.""" + n = round(bars_long * BAR * SR) + t = np.arange(n) / SR + frac = t / t[-1] + noise = np.random.default_rng(11).normal(0, 1, n) + # sweep by crossfading four bands + bands = [(400, 1200), (1000, 3000), (2500, 6000), (5000, 11000)] + swept = np.zeros(n) + for k, (lo, hi) in enumerate(bands): + sos = butter(2, [lo, hi], 'band', fs=SR, output='sos') + w = np.clip(1 - np.abs(frac * (len(bands) - 1) - k), 0, 1) + swept += sosfiltfilt(sos, noise) * w + env = frac ** 2.4 + phase = 2 * np.pi * np.cumsum(midi_hz(57 + 24 * frac)) / SR + tones = (np.sin(phase) + np.sin(phase * 1.011 + 2)) * .5 * frac ** 2.6 + return (swept * .7 + tones * .6) * env * fades(n, .05, .01) + + +def crash(): + n = round(1.8 * SR) + t = np.arange(n) / SR + noise = np.random.default_rng(5).normal(0, 1, n) + sos = butter(2, [2500, 14000], 'band', fs=SR, output='sos') + return sosfiltfilt(sos, noise) * np.exp(-t * 2.6) * fades(n, .001, .4) + + +# ------------------------------------------------------------- harmony +CHORDS = { + 'Cmaj7': dict(bass=36, tones=[60, 64, 67, 71], piano=[48, 55, 60, 64, 67, 71]), + 'Am7': dict(bass=33, tones=[57, 60, 64, 67], piano=[45, 52, 57, 60, 64, 67]), + 'Fmaj7': dict(bass=41, tones=[57, 60, 64, 65], piano=[41, 48, 53, 57, 60, 64]), + 'G7': dict(bass=43, tones=[59, 62, 65, 67], piano=[43, 50, 55, 59, 62, 65]), + 'Bbmaj7': dict(bass=46, tones=[58, 62, 65, 69], piano=[46, 53, 58, 62, 65, 69]), +} +HUM_E4, HUM_E4b, HUM_B3, HUM_AS3 = 16, 17, 18, 19 +HYMN = { + 'Cmaj7': [(HUM_B3, +1), (HUM_E4, 0), (HUM_E4b, +3), (HUM_B3, 0)], + 'Am7': [(HUM_B3, -2), (HUM_B3, +1), (HUM_E4, 0), (HUM_E4b, +3)], + 'Fmaj7': [(HUM_AS3, -5), (HUM_B3, -2), (HUM_B3, +1), (HUM_E4, 0)], + 'G7': [(HUM_B3, 0), (HUM_E4, -2), (HUM_E4b, +1), (HUM_E4, +3)], + 'Bbmaj7': [(HUM_AS3, 0), (HUM_E4, -2), (HUM_E4b, +1), (HUM_B3, -2)], +} +MAIN_LOOP = ['Cmaj7', 'Am7', 'Fmaj7', 'G7'] +HYMN_LOOP = ['Cmaj7', 'Am7', 'Bbmaj7', 'G7'] + +# ------------------------------------------------------------- melodies +# New lines written for the record (the takes' notes are the instrument, not +# the tune). Each entry: (beat offset within the phrase, length in beats, midi). +# Phrases are four bars long and sit on Cmaj7 | Am7 | Fmaj7 | G7. +C4, D4, E4, F4, G4, A4, B4, C5, D5, E5 = 60, 62, 64, 65, 67, 69, 71, 72, 74, 76 +VERSE_A = [ # a question that leans on the sixth and falls home + (0, .5, E4), (.5, .5, G4), (1, 1.5, A4), (2.5, .5, G4), (3, 1, E4), + (4, 2, C4), (6.5, .5, D4), (7, 1, E4), + (8, .5, A4), (8.5, .5, A4), (9, 1, G4), (10, .5, F4), (10.5, .5, E4), (11, 1, F4), + (12, 1.5, D4), (13.5, .5, E4), (14, 2, D4), +] +VERSE_B = [ # the answer, up to the fifth and down through the fourth + (0, .5, G4), (.5, .5, A4), (1, 1, G4), (2, .5, E4), (2.5, 1.5, G4), + (4, 1, E4), (5, 1, C4), (6, 2, E4), + (8, .5, F4), (8.5, .5, A4), (9, 1, C5), (10, .5, A4), (10.5, 1.5, F4), + (12, 1, D4), (13, .5, E4), (13.5, .5, F4), (14, 2, G4), +] +CHORUS_A = [ # the hook: up the arpeggio, hold the ninth, settle + (0, .5, G4), (.5, .5, A4), (1, 1.5, C5), (2.5, .5, B4), (3, 1, A4), + (4, 1, G4), (5, 1, E4), (6, 2, G4), + (8, .5, A4), (8.5, .5, C5), (9, 1.5, D5), (10.5, .5, C5), (11, 1, A4), + (12, 1, G4), (13, 1, F4), (14, 2, D4), +] +CHORUS_B = [ # the second half climbs to the high E and comes home + (0, .5, G4), (.5, .5, A4), (1, 1.5, C5), (2.5, .5, D5), (3, 1, E5), + (4, 1.5, D5), (5.5, .5, C5), (6, 2, A4), + (8, .5, F4), (8.5, .5, G4), (9, 1.5, A4), (10.5, .5, G4), (11, 1, F4), + (12, 1, D4), (13, 1, E4), (14, 2, C5), +] +LONG_LINE = [ # Saffron's whole-note counter line + (0, 4, E4), (4, 4, E4), (8, 4, F4), (12, 4, D4), +] +LONG_LINE_B = [(0, 4, G4), (4, 4, E4), (8, 4, F4), (12, 3.5, D4)] +PULSE = [ # Indigo's short notes as a rhythmic chord-tone pulse (off-beats) + (1.5, .5, E4), (3.5, .5, E4), (5.5, .5, E4), (7.5, .5, C4), (9.5, .5, C4), (11.5, .5, A4 - 12 + 12), (13.5, .5, D4), (15.5, .5, D4), +] + + +class Singer: + """Sings a written midi line from one take's note bank, picking the nearest sample.""" + def __init__(self, bank, max_shift=5): + self.bank = bank + self.max_shift = max_shift + self.turn = 0 + self.pool = [n for n in bank.manifest['notes'] if n['pitchable']] + + def pick(self, midi, dur): + best = [] + for n in self.pool: + target = n['samples']['lead']['targetMidi'] + natural = n['end'] - n['start'] + shift = midi - target + octaves = round(shift / 12) + residue = shift - 12 * octaves + if abs(residue) > self.max_shift: + continue + cost = abs(residue) + .8 * abs(octaves) + (dur / natural if natural < dur * .7 else 0) + best.append((cost, n['index'], shift, natural)) + best.sort() + top = [b for b in best if b[0] <= best[0][0] + .6] or best[:1] + self.turn += 1 + return top[self.turn % len(top)] + + def sing(self, mix, bus, line, at, gain, pan=0.0, part='lead', shift_extra=0, harmony=False, stretch_mul=1.0): + for beat, beats, midi in line: + if shift_extra < 0 and midi + shift_extra < 55: + continue + dur = beats * BEAT + cost, idx, shift, natural = self.pick(midi, dur) + stretch = min(3.5, max(1.0, dur * 1.15 / natural)) * stretch_mul + length = dur * 1.12 + chop = self.bank.chop(idx, length, part=part, shift=shift + shift_extra, skip=.01, + stretch=round(stretch, 2), attack=.015, release=min(.25, dur * .35)) + mix.place(bus, chop, at + beat * BEAT - .02, gain, pan) + + +# ------------------------------------------------------------- the form +SECTIONS = [ + ('intro', 4, dict(piano='solo', long_line=.7, hymn=.5, verse=.6)), + ('verse', 8, dict(piano='comp', kick=.7, brush=.7, clave=.8, bubbles=.6, bass=1, verse=1, long_line=.5, fills=1)), + ('build', 4, dict(piano='comp', kick=.8, clave=1, blocks=.8, bass=1, verse=.8, roll=1, riser=1, breath=1)), + ('chorus', 16, dict(piano='block', kick=1, brush=1, clave=1, blocks=.7, bubbles=.8, bass=1, chorus=1, arp=.8, pulse=.5, hymn=.5, stinger=1, gliss=1, fills=1, sparkle=1)), + ('hymn', 8, dict(piano='arp', hymn=1, arch=1, long_line=.6, bass=.4, fills=1, kick=.6, hook=.7)), + ('verse2', 8, dict(piano='comp', kick=.8, brush=.8, clave=.8, blocks=.5, bubbles=.6, bass=1, verse=1, long_line=.5, fills=1)), + ('build2', 4, dict(piano='comp', kick=.9, clave=1, blocks=.9, bass=1, verse=.8, roll=1, riser=1, breath=1)), + ('chorus2', 16, dict(piano='block', kick=1, brush=1, clave=1, blocks=.7, bubbles=.8, bass=1, chorus=1, arp=.8, pulse=.6, hymn=.6, stinger=1, gliss=1, high=1, fills=1, sparkle=1)), + ('outro', 8, dict(piano='solo', hymn=.6, long_line=.7, hook=.8, kick=.8, fade=1)), +] +BUBBLES_VERSE = [2, 6, 10, 14] # the off-eighths, sparsely +BUBBLES_CHORUS = [1, 3, 6, 9, 11, 14] # the "e"s and "a"s, a little pattern +PENTATONIC = [0, 2, 4, 7, 9] +CLAVE = [0, 6, 12, 20, 24] # son clave 3-2 in sixteenths over two bars +HEMIOLA_STEP = 3 # shaker every three sixteenths (3:4 against the kick) + + +def chart_text(): + rows = [('kick', [0, 8, 16, 24]), ('brush', [4, 12, 20, 28]), + ('marimba', CLAVE), ('block', list(range(0, 48, HEMIOLA_STEP))), ('bubble', [s + 16 * k for k in range(2) for s in BUBBLES_VERSE])] + lines = ['slot ' + ' '.join(f'{b + 1}e&a' for b in range(8))] + for name, slots in rows: + cells = ''.join('x' if s in slots else '.' for s in range(32)) + lines.append(f'{name:6s} ' + ' '.join(cells[k:k + 4] for k in range(0, 32, 4)) + + (' (3-bar cycle)' if name == 'block' else '')) + return '\n'.join(lines) + + +def chart_svg(): + rows = [('soft kick', [0, 8, 16, 24], '#c0392b'), ('brush swish', [4, 12, 20, 28], '#e67e22'), + ('marimba · clave (chord tones)', CLAVE, '#8e44ad'), ('woodblock · 3:4 hemiola', list(range(0, 48, 3)), '#2980b9'), + ('bubble pops · off-eighths', [s + 16 * k for k in range(3) for s in BUBBLES_VERSE], '#16a085')] + cw, ch, left, top = 22, 30, 190, 40 + w, h = left + 48 * cw + 20, top + len(rows) * ch + 30 + out = [f'', + f'', + f'Afternoon Study · percussion chart · 118 BPM · three bars of sixteenths'] + for s in range(48): + x = left + s * cw + stroke = '#333' if s % 16 == 0 else '#999' if s % 4 == 0 else '#e5e0d5' + out.append(f'') + if s % 16 == 0: + out.append(f'bar {s // 16 + 1}') + for r, (name, slots, color) in enumerate(rows): + y = top + r * ch + out.append(f'{name}') + for s in range(48): + key = s if name.startswith('woodblock') or name.startswith('bubble') else s % 32 + if key in slots: + out.append(f'') + out.append('') + return '\n'.join(out) + + +class Mixer: + def __init__(self, total): + self.total = round(total * SR) + self.buses = {} + + def bus(self, name): + if name not in self.buses: + self.buses[name] = np.zeros((self.total, 2)) + return self.buses[name] + + def place(self, bus, signal, at, gain=1.0, pan=0.0): + angle = (np.clip(pan, -1, 1) + 1) * np.pi / 4 + self.place_stereo(bus, np.stack([signal * np.cos(angle), signal * np.sin(angle)], axis=1), at, gain) + + def place_stereo(self, bus, signal, at, gain=1.0): + data = self.bus(bus) + start = round(at * SR) + if start < 0: + signal, start = signal[-start:], 0 + count = min(len(signal), self.total - start) + if count <= 0: + return + data[start:start + count] += signal[:count] * gain + + +def arrange(banks, thumps, piano): + lemon, indigo, saffron = (banks[n] for n in NAMES) + singers = dict(lemon=Singer(lemon), saffron=Singer(saffron, max_shift=4), indigo=Singer(indigo, max_shift=4)) + total_bars = sum(s[1] for s in SECTIONS) + mix = Mixer(total_bars * BAR + 4) + events, breaths, bar0 = [], [], 0 + for si, (name, bars, layers) in enumerate(SECTIONS): + for b in range(bars): + bar = bar0 + b + t0 = bar * BAR + loop = HYMN_LOOP if name == 'hymn' else MAIN_LOOP + chord_name = loop[bar % 4] + chord = CHORDS[chord_name] + events.append(dict(bar=bar, section=name, chord=chord_name)) + fade = 1 - b / bars if layers.get('fade') else 1.0 + phrase_bar = b % 4 + # ---- hymn: Indigo's hums as chord tones, overlapping at the seams + if layers.get('hymn'): + for k, (idx, shift) in enumerate(HYMN[chord_name][1:]): + stretch = BAR / (len(indigo.raw(idx)) / 24000) * 1.08 + tone = indigo.sample(idx, 'lead', shift, stretch=stretch) + mix.place('hymn', tone * fades(len(tone), .09, .25), t0 - .06, .22 * layers['hymn'] * fade, -.85 + 1.7 * k / 3) + # ---- piano + mode = layers.get('piano') + root, tones, voicing = chord['bass'], chord['tones'], chord['piano'] + high = [m + 12 for m in voicing[2:]] # the right hand lives an octave up + # v6: a lighter, looser hand. Each bar picks two or three notes of the + # voicing and one of a few rhythms, so the piano breathes around the + # voice instead of filling every beat. + if mode == 'solo': + if phrase_bar % 2 == 0: + piano.roll(mix, [root + 12] + piano.thin(high), t0, BAR * 1.9, .5 * fade, vel=.55) + if phrase_bar == 3 and piano.rng.random() < .6: + run = [96, 93, 91, 88, 84, 81, 79, 76][:int(piano.rng.integers(4, 9))] + for k, m in enumerate(run): + piano.key(mix, m, t0 + 2 * BEAT + k * STEP, STEP * 1.5, .26 * fade, vel=.5 - .03 * k, pan=.4 - .1 * k) + elif mode == 'comp': + piano.key(mix, root + 12, t0, BEAT * 1.6, .45, vel=.55) + hand = piano.thin(high) + rhythm = [(1.5, .5), (3, .8)], [(1, .9), (2.5, .5)], [(2, 1.4)], [(.5, .4), (2.5, .9)] + for beat, beats in rhythm[int(piano.rng.integers(len(rhythm)))]: + for m in hand: + piano.key(mix, m, t0 + beat * BEAT, BEAT * beats, .32, vel=.5, pan=.2) + elif mode == 'block': + hand = piano.thin(high, (2, 3)) + piano.key(mix, root + 12, t0, BEAT * 1.6, .45, vel=.7) + for m in hand: + piano.key(mix, m, t0, BEAT * 1.5, .34, vel=.72, pan=.15) + if piano.rng.random() < .5: + for m in piano.thin(high, (1, 2)): + piano.key(mix, m, t0 + 2.5 * BEAT, BEAT * .6, .26, vel=.6, pan=.25) + elif mode == 'arp': + piano.key(mix, root + 12, t0, BAR * .95, .45, vel=.5) + pattern = [high[0], high[2], high[1], high[3], high[2] + 12, high[0] + 12, high[1] + 12, high[3]] + for k, m in enumerate(pattern): + if k and piano.rng.random() < .3: + continue + piano.key(mix, m, t0 + k * BEAT / 2, BEAT * .7, .28, vel=.42 + .12 * (k % 2 == 0), pan=.25) + # ---- right-hand fun, now occasional: a sparkle, a run or a trill, never all at once + if layers.get('sparkle') and phrase_bar == 2 and piano.rng.random() < .5: + for step in (1, 5, 9, 13): + piano.key(mix, tones[(step // 4) % 4] + 24, t0 + step * STEP, STEP * 1.2, .18, vel=.42, pan=.5) + if layers.get('fills') and phrase_bar == 3 and (b // 4) % 2 == 1: + run = [72 + o + p for o in (0, 12, 24) for p in PENTATONIC][:int(piano.rng.integers(6, 11))] + start = t0 + BAR - len(run) * STEP / 2 - .5 * BEAT + for k, m in enumerate(run): + piano.key(mix, m, start + k * STEP / 2, STEP * 1.1, .24 + .02 * k, vel=.42 + .04 * k, pan=-.4 + .08 * k) + if layers.get('fills') and phrase_bar == 1 and name.startswith('chorus') and piano.rng.random() < .4: + top = high[-1] + 12 + for k in range(6): # a trill on the high tone + piano.key(mix, top + (2 if k % 2 else 0), t0 + 2 * BEAT + k * STEP / 2, STEP * .6, .2, vel=.48, pan=.45) + # ---- the mallet kit + chorus_like = name.startswith('chorus') + if layers.get('kick'): + # v6: four to the floor everywhere the kick plays, with a punch layer + # (the clicked kick) under it, and a short brush tick on every off-beat + for beat in range(4): + k = layers['kick'] * fade + mix.place('kick', soft_kick(1 if beat in (0, 2) else .9), t0 + beat * BEAT, .9 * k) + mix.place('kick', kick(.8 if chorus_like else .55), t0 + beat * BEAT, .45 * k) + mix.place('brush', brush(.07, .004, 5000, 12000, seed=bar * 8 + beat), t0 + (beat + .5) * BEAT, (.4 if chorus_like else .28) * k, .25 - .5 * (beat % 2)) + if layers.get('brush'): + for beat in (1, 3): + mix.place('brush', brush(seed=bar * 4 + beat), t0 + beat * BEAT - .05, .8 * layers['brush'], .1) + if chorus_like: + mix.place('brush', brush(.14, .03, 3000, 9000, seed=bar), t0 + 3.5 * BEAT - .02, .3, -.3) + if layers.get('clave'): + for slot in range(16): + if ((bar - bar0) % 2 * 16 + slot) in CLAVE: + midi = tones[(slot // 4) % 4] - 12 + mix.place('mallet', marimba(midi, 'rosewood'), t0 + slot * STEP, .55 * layers['clave'], -.2) + mix.place('perc', thumps[(bar + slot) % len(thumps)], t0 + slot * STEP, .25 * layers['clave'], .2) + if layers.get('blocks'): + for slot in range(16): + s3 = ((bar - bar0) % 3) * 16 + slot + if s3 % HEMIOLA_STEP == 0: + mix.place('mallet', block(89 if (s3 // 3) % 2 else 84), t0 + slot * STEP, .28 * layers['blocks'], .5 if (s3 // 3) % 2 else -.5) + if layers.get('bubbles'): + for slot in (BUBBLES_CHORUS if chorus_like else BUBBLES_VERSE): + mix.place('mallet', bubble(hz_from=700 + 120 * (slot % 5), hz_to=220 + 40 * (slot % 3)), t0 + slot * STEP, .4 * layers['bubbles'], .6 - 1.2 * (slot % 2)) + # ---- the build: roll 4 → 8 → 16 → 32, riser, breath (femrag++) + if layers.get('roll'): + division = [4, 8, 16, 32][b] + heat = b / 4 + for s in range(division): + at = t0 + s * BAR / division + if b == 3 and at >= t0 + 3.5 * BEAT: + break + ghost = division <= 8 and s % 2 == 1 + g = (.25 + heat * .5 + s / division * .2) * (.5 if ghost else 1) + mix.place('mallet', block(84 + [0, 2, 4, 7][s % 4]), at, g * .7, .3 * (s % 2) - .15) + if s % 2 == 0: + mix.place('mallet', bubble(hz_from=600 + 400 * heat), at, g * .5, -.3) + if b == 3: + mix.place('brush', brush(.3, .04, 2000, 9000, seed=bar), t0 + 3.5 * BEAT - .04, .9, 0) + mix.place('mallet', marimba(tones[0], 'rosewood'), t0 + 3.5 * BEAT, .6, 0) + if layers.get('riser') and b == 0: + mix.place('riser', riser(4 - .125), t0, .45, 0) + if layers.get('breath') and b == bars - 1: + breaths.append((t0 + 3.5 * BEAT + .1, t0 + BAR)) + if layers.get('stinger') and b == 0: + mix.place('bass', sub_stinger(beats=2.2), t0, .4, 0) + mix.place('brush', brush(1.2, .01, 2500, 12000, seed=7), t0, .35, 0) + if layers.get('gliss') and b % 8 == 0: + gliss(mix, piano, t0) + # a reversed Saffron C4 swells into the downbeat: the strike that arrives where it ends + swell = saffron.sample(0)[:round(1.6 * SR)][::-1].copy() + swell *= np.linspace(0, 1, len(swell)) ** 2 + mix.place('shimmer', swell, t0 - 1.6, .5, 0) + # ---- bass + if layers.get('bass'): + if name in ('hymn', 'intro', 'outro'): + mix.place('bass', bass_tone(root, BEAT * 1.5, drive=1.1), t0, .4 * layers['bass']) + else: + chorus = name.startswith('chorus') + drive = 1.8 if chorus else 1.3 + if not (layers.get('stinger') and b == 0): + mix.place('bass', bass_tone(root, BEAT * 1.2, drive=drive), t0, .55) + # then it rests: one answer on the "and" of two, a pickup before the next bar + mix.place('bass', bass_tone(root + 12, BEAT * .35, drive=drive), t0 + 1.5 * BEAT, .38) + if chorus or b % 2 == 1: + mix.place('bass', bass_tone(root + (12 if b % 2 else 7), BEAT * .3, drive=drive), t0 + 3.5 * BEAT, .34) + # ---- vocals: the written lines, sung from the banks + if phrase_bar == 0: + if layers.get('verse'): + line = VERSE_A if (b // 4) % 2 == 0 else VERSE_B + singers['lemon'].sing(mix, 'lead', line, t0, .62 * layers['verse'], 0) + singers['lemon'].sing(mix, 'lead', line, t0, .26 * layers['verse'], .5, part='harmony') + singers['lemon'].sing(mix, 'lead-oct', line, t0, .14 * layers['verse'], -.5, shift_extra=-12) + if layers.get('hook'): + line = CHORUS_A if (b // 4) % 2 == 0 else CHORUS_B + singers['lemon'].sing(mix, 'lead', line, t0, .5 * layers['hook'] * fade, 0) + singers['lemon'].sing(mix, 'lead', line, t0, .24 * layers['hook'] * fade, -.5, part='harmony') + if layers.get('chorus'): + line = CHORUS_A if (b // 4) % 2 == 0 else CHORUS_B + singers['lemon'].sing(mix, 'lead', line, t0, .66, 0) + singers['lemon'].sing(mix, 'lead', line, t0, .3, .55, part='harmony') + singers['lemon'].sing(mix, 'lead-oct', line, t0, .26, -.5, shift_extra=-12) + if layers.get('high') and (b // 4) % 2 == 1: + singers['lemon'].sing(mix, 'lead-oct', line, t0, .18, .5, shift_extra=12) + for beat, beats, midi in line: # the grand shadows the hook's long notes only + if beats >= 1.5 and piano.rng.random() < .6: + piano.key(mix, midi + 12, t0 + beat * BEAT, beats * BEAT * .8, .28, vel=.6, pan=.2) + if layers.get('long_line'): + line = LONG_LINE if (b // 4) % 2 == 0 else LONG_LINE_B + singers['saffron'].sing(mix, 'long', line, t0, .4 * layers['long_line'] * fade, -.25) + singers['saffron'].sing(mix, 'long', line, t0, .18 * layers['long_line'] * fade, .45, part='harmony') + if layers.get('pulse'): + singers['indigo'].sing(mix, 'pulse', PULSE, t0, .35 * layers['pulse'], .6, stretch_mul=.9) + singers['indigo'].sing(mix, 'pulse', [(bt + .25, d, m) for bt, d, m in PULSE], t0, .22 * layers['pulse'], -.6) + # ---- Saffron's arpeggio: chord tones on sixteenths, octaves trading every beat + if layers.get('arp'): + for step in range(16): + midi = tones[step % 4] + [12, 0, -12, 0][(step // 4 + b) % 4] + dur = STEP + cost, idx, shift, natural = singers['saffron'].pick(midi, dur) + chop = saffron.chop(idx, STEP * 1.25, shift=shift, skip=.12 if idx in (0, 7) else .04, attack=.012, release=.06) + gain = .42 if step % 4 == 0 else .3 + mix.place('arp', chop, t0 + step * STEP - .01, gain * layers['arp'], -.55 + 1.1 * step / 15) + # ---- Saffron's actual slow arch, un-chopped, over the hymn + if layers.get('arch') and b == 0: + for idx in [0, 1, 2, 3, 4, 5, 6]: + note = saffron.manifest['notes'][idx] + x = saffron.sample(idx) + mix.place('long', x * fades(len(x), .02, .25), t0 + note['start'], .55, 0) + h = saffron.sample(idx, 'harmony') + mix.place('long', h * fades(len(h), .05, .25), t0 + note['start'], .2, .5) + bar0 += bars + return mix, events, total_bars, breaths + + +def sidechain(total_samples, bars_with_kick, depth=.62): + t = np.arange(total_samples) / SR + env = np.ones(total_samples) + within = t % BEAT + pump = 1 - depth * np.exp(-within / .1) + for start, end in bars_with_kick: + a, b = round(start * BAR * SR), round(end * BAR * SR) + env[a:b] = pump[a:b] + return env + + +def vocal_duck(vocal, floor=.7, threshold=.05): + energy = np.sqrt(np.convolve(np.mean(vocal ** 2, axis=1), np.ones(round(.02 * SR)) / round(.02 * SR), 'same')) + release = np.exp(-1 / (.12 * SR)) + held = np.empty_like(energy) + level = 0.0 + for i, e in enumerate(energy): + level = e if e > level else level * release + held[i] = level + return np.clip((threshold / np.maximum(held, 1e-4)) ** .5, floor, 1) + + +VOCAL_BUSES = ['lead', 'lead-oct', 'long'] +DUCKED = dict(piano=.45, arp=.8, hymn=.8, pulse=.6, perc=.4, mallet=.3) + + +def main(): + global STUDY + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--study', type=Path, default=STUDY) + ap.add_argument('--out', type=Path, default=Path(__file__).resolve().parent.parent / 'out') + ap.add_argument('--lufs', type=float, default=-12.0, help='the mix level') + ap.add_argument('--master-lufs', type=float, default=-9.5, help='the master level') + args = ap.parse_args() + STUDY = args.study + out = args.out + (out / 'cache').mkdir(parents=True, exist_ok=True) + (out / 'stems').mkdir(exist_ok=True) + + banks = {n: Bank(n, out / 'cache') for n in NAMES} + thumps, thump_report = indigo_thumps(out / 'cache') + piano = Piano(np.random.default_rng(515)) + (out / 'chart.txt').write_text(chart_text() + '\n') + (out / 'chart.svg').write_text(chart_svg()) + print(chart_text(), flush=True) + mix, events, total_bars, breaths = arrange(banks, thumps, piano) + print('arranged', total_bars, 'bars', round(total_bars * BAR, 1), 's; breaths at', [round(a, 2) for a, _ in breaths], flush=True) + + kick_bars, bar0 = [], 0 + for name, bars, layers in SECTIONS: + if layers.get('kick'): + kick_bars.append((bar0, bar0 + bars)) + bar0 += bars + pump = sidechain(mix.total, kick_bars) + for bus, depth in [('hymn', 1.0), ('arp', .7), ('piano', .3), ('long', .4), ('mallet', .35), ('bass', .55)]: + if bus in mix.buses: + mix.buses[bus] *= (1 - depth + depth * pump)[:, None] + # The breath: everything but the riser's last gasp goes silent for the last half-beat before each drop. + for a, b in breaths: + i, j = round(a * SR), round(b * SR) + edge = round(.004 * SR) + for bus, data in mix.buses.items(): + data[i + edge:j - edge] = 0 + data[i:i + edge] *= np.linspace(1, 0, edge)[:, None] + data[j - edge:j] *= np.linspace(0, 1, edge)[:, None] + + carve = 'equalizer=f=3600:t=q:w=1.2:g=-2.5' + vocal_eq = ('highpass=f=130,equalizer=f=280:t=q:w=1.1:g=-2.5,equalizer=f=4000:t=q:w=1:g=2,highshelf=f=9000:g=1.5,' + 'deesser=i=0.2:m=0.4:f=0.55,acompressor=threshold=0.12:ratio=3:attack=6:release=100:makeup=1.3') + treat = { + 'kick': 'highpass=f=34,equalizer=f=70:t=q:w=1:g=1.5,equalizer=f=2500:t=q:w=1:g=1', + 'brush': 'highpass=f=900,aecho=0.8:0.4:70|140:0.18|0.09', + 'mallet': f'highpass=f=90,{carve},aecho=0.85:0.5:110|230:0.2|0.1', + 'perc': f'highpass=f=80,equalizer=f=250:t=q:w=1.2:g=-2,{carve},acompressor=threshold=0.25:ratio=2.5:attack=4:release=90', + 'riser': 'highpass=f=300', + 'bass': 'highpass=f=38,lowpass=f=1400,equalizer=f=200:t=q:w=1:g=1.5,acompressor=threshold=0.25:ratio=2.5:attack=6:release=80', + 'piano': f'highpass=f=45,{carve},aecho=0.85:0.5:80|165:0.18|0.09', + 'hymn': f'highpass=f=150,lowpass=f=8000,equalizer=f=320:t=q:w=1.1:g=-2,{carve},aecho=0.8:0.6:120|260:0.16|0.08', + 'arp': f'highpass=f=160,{carve},deesser=i=0.2:m=0.4:f=0.55,aecho=0.8:0.5:190|380:0.2|0.1', + 'shimmer': 'highpass=f=200,lowpass=f=11000,aecho=0.8:0.6:250|500:0.25|0.12', + 'pulse': f'highpass=f=200,{carve},aecho=0.8:0.5:150|300:0.2|0.1', + 'lead': vocal_eq + ',aecho=0.8:0.35:95:0.22', + 'lead-oct': 'highpass=f=140,equalizer=f=280:t=q:w=1.1:g=-2,deesser=i=0.2:m=0.4:f=0.55,acompressor=threshold=0.12:ratio=3:attack=6:release=100:makeup=1.3,aecho=0.8:0.35:120:0.2', + 'long': 'highpass=f=100,lowpass=f=10500,equalizer=f=4000:t=q:w=1:g=2,deesser=i=0.18:m=0.4:f=0.55,acompressor=threshold=0.15:ratio=2.5:attack=8:release=120,aecho=0.8:0.6:220|440:0.22|0.11', + } + levels = {'kick': -18.5, 'brush': -27, 'mallet': -23, 'perc': -30, 'riser': -30, 'bass': -21.5, 'piano': -21, + 'hymn': -29, 'arp': -25, 'shimmer': -31, 'pulse': -28, 'lead': -15, 'lead-oct': -21.5, 'long': -21} + stems, trims = {}, {} + for bus, data in mix.buses.items(): + raw = out / 'stems' / f'{bus}-raw.wav' + sf.write(raw, data, SR, subtype='FLOAT') + treated = out / 'stems' / f'{bus}-treated.wav' + ffmpeg('-i', raw, '-af', treat.get(bus, 'anull'), '-ar', SR, '-ac', 2, '-c:a', 'pcm_f32le', treated) + final = out / 'stems' / f'{bus}.wav' + trims[bus] = round(normalize(treated, final, levels[bus]), 2) + raw.unlink(); treated.unlink() + stems[bus] = sf.read(final, always_2d=True)[0][:mix.total] + print('bus trims', trims, flush=True) + n = mix.total + vocal = np.zeros((n, 2)) + for bus in VOCAL_BUSES: + if bus in stems: + vocal[:len(stems[bus])] += stems[bus] + duck = vocal_duck(vocal) + summed = np.zeros((n, 2)) + for bus, data in stems.items(): + depth = DUCKED.get(bus, 0) + summed[:len(data)] += data * (1 - depth + depth * duck[:len(data)])[:, None] + sf.write(out / 'stems' / 'vocals.wav', vocal, SR, subtype='FLOAT') + sf.write(out / 'stems' / 'instrumental.wav', summed - vocal, SR, subtype='FLOAT') + tail = round(1.5 * SR) + summed[-tail:] *= np.linspace(1, 0, tail)[:, None] + assert np.isfinite(summed).all() + premaster = out / 'premaster.wav' + sf.write(premaster, summed, SR, subtype='FLOAT') + glue = out / 'glue.wav' + ffmpeg('-i', premaster, '-af', 'highpass=f=26,equalizer=f=180:t=q:w=1.2:g=-1.5,equalizer=f=4200:t=q:w=1.2:g=1,' + 'highshelf=f=8500:g=1.5,acompressor=threshold=0.35:ratio=1.3:attack=30:release=220', + '-ar', SR, '-ac', 2, '-c:a', 'pcm_f32le', glue) + measured = loudness(glue) + gain = args.lufs - float(measured['input_i']) + master = out / 'afternoon-study.wav' + ffmpeg('-i', glue, '-af', f'volume={gain:.3f}dB,alimiter=limit=0.8:attack=6:release=80:level=false', + '-ar', SR, '-ac', 2, '-c:a', 'pcm_s24le', master) + checked = loudness(master) + if float(checked['input_tp']) > -1.5: + trim = -1.5 - float(checked['input_tp']) + ffmpeg('-i', master, '-af', f'volume={trim:.3f}dB', '-c:a', 'pcm_s24le', out / 'trimmed.wav') + (out / 'trimmed.wav').replace(master) + checked = loudness(master) + # The mix above stays as afternoon-study-mix.wav; the master is its own pass: + # tidy the lows, a slow 2:1 bus compressor, a touch of air, then two + # limiters (a gentle catch, then the ceiling) up to pop level, and a final + # trim so the true peak sits at -1 dBTP. + mix_wav = out / 'afternoon-study-mix.wav' + master.replace(mix_wav) + mixed = checked + stage = out / 'master-stage.wav' + ffmpeg('-i', mix_wav, '-af', 'highpass=f=30,equalizer=f=55:t=q:w=1:g=1.2,equalizer=f=250:t=q:w=1.3:g=-1,' + 'equalizer=f=3000:t=q:w=1.5:g=.8,highshelf=f=11000:g=1.5,' + 'acompressor=threshold=0.2:ratio=2:attack=25:release=180:knee=6:makeup=1', + '-ar', SR, '-ac', 2, '-c:a', 'pcm_f32le', stage) + push = args.master_lufs - float(loudness(stage)['input_i']) + ffmpeg('-i', stage, '-af', f'volume={push:.3f}dB,alimiter=limit=0.95:attack=10:release=120:level=false,' + 'alimiter=limit=0.85:attack=2:release=60:level=false', + '-ar', SR, '-ac', 2, '-c:a', 'pcm_f32le', out / 'master-loud.wav') + loud = loudness(out / 'master-loud.wav') + trim = min(0.0, -1.0 - float(loud['input_tp'])) + ffmpeg('-i', out / 'master-loud.wav', '-af', f'volume={trim:.3f}dB', '-ar', SR, '-ac', 2, '-c:a', 'pcm_s24le', master) + stage.unlink(); (out / 'master-loud.wav').unlink() + checked = loudness(master) + mp3 = out / 'afternoon-study.mp3' + ffmpeg('-i', master, '-c:a', 'libmp3lame', '-b:a', '320k', mp3) + premaster.unlink(); glue.unlink() + receipts = dict( + title='Afternoon Study', artist='Aesthetic Dot Computer', bpm=BPM, bars=total_bars, + seconds=round(total_bars * BAR, 2), study=str(STUDY), + sections=[dict(name=s[0], bars=s[1], layers=s[2]) for s in SECTIONS], + melodies=dict(verseA=VERSE_A, verseB=VERSE_B, chorusA=CHORUS_A, chorusB=CHORUS_B, longLine=LONG_LINE, longLineB=LONG_LINE_B, pulse=PULSE), + percussionChart=dict(clave=CLAVE, hemiolaStep=HEMIOLA_STEP, text=chart_text()), + indigoThumps=thump_report, + kit='synthesized: modal marimba (pop/marimba presets), woodblock (staccato preset), bubble pops, brush swishes, soft kick', + piano=dict(bank=str(PIANO_DIR), level='forward: -17 LUFS bus, light duck', anchors=sorted(piano.anchors), source='Salamander Grand Piano V3 (CC0, Alexander Holm), the AC OS bank', release=PIANO_RELEASE), + hymnVoicings=HYMN, chords=dict(main=MAIN_LOOP, hymn=HYMN_LOOP), + vocalSamples={n: sorted([dict(index=i, part=p, shift=s, stretch=x, targetMidi=banks[n].midi(i, p) + s) + for i, p, s, x in banks[n].used], key=lambda d: (d['index'], d['part'], d['shift'])) + for n in NAMES}, + breaths=[dict(fromSeconds=round(a, 3), toSeconds=round(b, 3)) for a, b in breaths], + separation=dict(vocalBuses=VOCAL_BUSES, duckDepths=DUCKED, instrumentCarveHz=3600, floor=.7), + busLevelsLUFS=levels, busTrimsDB=trims, masterGainDB=round(gain, 2), premasterLUFS=float(measured['input_i']), + mixLUFS=float(mixed['input_i']), masterPushDB=round(push, 2), masterTrimDB=round(trim, 2), + masterLUFS=float(checked['input_i']), masterTruePeakDBTP=float(checked['input_tp']), masterLRA=float(checked['input_lra']), + sha256=dict(wav=hashlib.sha256(master.read_bytes()).hexdigest(), mp3=hashlib.sha256(mp3.read_bytes()).hexdigest()), + barChords=events) + (out / 'receipts.json').write_text(json.dumps(receipts, indent=2)) + print('master', checked['input_i'], 'LUFS', checked['input_tp'], 'dBTP', 'LRA', checked['input_lra']) + + +if __name__ == '__main__': + main()