# AFTERLIGHT trailer (third cut): a plunderphonic score cut from the archive's own sound, over a warm sampled-and-played groove. # Writes score3.wav and bands.npy (48 log bands at 24 fps, which drive the light-speed streaks in the picture). import sys, wave, subprocess, numpy as np from scipy import signal sys.path.insert(0, '/home/claude/afterlight/trailer3') import timeline3 as T SR = 44100; END = T.END; N = int(SR * (END + 4)) rng = np.random.default_rng(77) A = '/home/claude/afterlight/archive/clips/' def load(fid, t0=0, dur=None, sr=SR): import glob p = glob.glob(A + fid + '*.mp4')[0] cmd = ['ffmpeg', '-v', 'error', '-ss', str(t0), '-i', p] + (['-t', str(dur)] if dur else []) + ['-ac', '1', '-ar', str(sr), '-f', 'f32le', '-'] x = np.frombuffer(subprocess.run(cmd, capture_output=True).stdout, np.float32).astype(np.float64) return x / (np.abs(x).max() + 1e-9) L = np.zeros(N); R = np.zeros(N) # stereo bus (dry) RV = np.zeros(N) # reverb send (mono) def put(sig, t, g=1.0, pan=0.0, rev=0.0): i = int(t * SR); n = min(len(sig), N - i) if n <= 0 or i < 0: return s = sig[:n] * g; L[i:i + n] += s * np.sqrt(0.5 * (1 - pan)); R[i:i + n] += s * np.sqrt(0.5 * (1 + pan)); RV[i:i + n] += s * rev def env(n, a, r): e = np.ones(n); A_ = max(1, int(a * SR)); R_ = max(1, int(r * SR)); e[:A_] = np.linspace(0, 1, A_); e[-R_:] *= np.linspace(1, 0, R_); return e def bp(x, lo, hi, o=2): b, a = signal.butter(o, [lo / (SR / 2), hi / (SR / 2)], 'band'); return signal.lfilter(b, a, x) def lp(x, f, o=2): b, a = signal.butter(o, min(f, SR / 2.2) / (SR / 2), 'low'); return signal.lfilter(b, a, x) def hp(x, f, o=2): b, a = signal.butter(o, f / (SR / 2), 'high'); return signal.lfilter(b, a, x) def mtof(m): return 440 * 2 ** ((m - 69) / 12) def resamp(x, ratio): # ratio > 1 raises pitch (and shortens) n = int(len(x) / ratio); return np.interp(np.arange(n) * ratio, np.arange(len(x)), x) def wow(x, depth=0.0025, rate=0.6): n = len(x); tt = np.arange(n) / SR; d = depth * SR * (np.sin(2 * np.pi * rate * tt) + 0.3 * np.sin(2 * np.pi * 2.3 * rate * tt)) return np.interp(np.clip(np.arange(n) - d - depth * SR * 1.5, 0, n - 1), np.arange(n), x) def tape(x, drive=1.6): return np.tanh(x * drive) / np.tanh(drive) BPM = 92; BEAT = 60 / BPM; BAR = 4 * BEAT; DROP = 19.6 def beat(k): return DROP + k * BEAT # ---------------- archive sources (sound as found) ---------------- cabeza = load('74442368') # Cabeza Prieta National Wildlife Refuge (US Fish & Wildlife film): music and narration apollo = load('c6ed9763') # Apollo 12: Pinpoint for Science (NASA): radio voices hl65 = load('201b010b') # Highlights 1965 (NASA): narration rca = load('8095f755') # RCA Presentation: Television (1939): narration typeset = load('aaf17c56') # Learning to Set Type: the clack of type rail = load('b013393e') # Space Age Railroad: rumble moonwalk = load('60dd29ed') # Moonwalk One (NASA): score and voice facts = load('8266284b') # Facts About Film: narration def frames_rms(x, w=1024): n = len(x) // w; return np.sqrt((x[:n * w].reshape(n, w) ** 2).mean(1)) def speech_bits(x, minlen=0.5, maxlen=1.8): r = frames_rms(bp(x, 300, 3400)); thr = 0.22 * np.percentile(r, 99); on = r > thr; out = []; i = 0; w = 1024 / SR while i < len(on): if on[i]: j = i while j < len(on) and (on[j] or any(on[j + 1:j + 7])): j += 1 a, b = i * w, j * w if b - a >= minlen: out.append((a, min(b, a + maxlen))) i = j else: i += 1 return out def cut(x, a, b, fade=0.03): s = x[int(a * SR):int(b * SR)].copy(); return s * env(len(s), fade, fade * 2) def radio(x): return tape(bp(x, 350, 3200, 3) * 1.8, 1.8) def tonal_window(x, win=2.5, hop=0.25, upto=None): best, bt = 1e9, 0; upto = upto or len(x) / SR - win for t0 in np.arange(0, upto, hop): s = x[int(t0 * SR):int((t0 + win) * SR)] if len(s) < win * SR * 0.9: break f, P = signal.welch(s, SR, nperseg=4096); P = P[(f > 80) & (f < 4000)] + 1e-14 fl = np.exp(np.log(P).mean()) / P.mean() if fl < best and np.sqrt((s ** 2).mean()) > 0.05: best, bt = fl, t0 return bt def pitch_class(x): f, P = signal.welch(x, SR, nperseg=16384); ch = np.zeros(12) for fi, p in zip(f, P): if 60 < fi < 2000: ch[int(round(12 * np.log2(fi / 440) + 69)) % 12] += p return int(ch.argmax()) def freeze(x, t0, dur, out_len, grain=0.18, dens=40, jitter=0.6): """granular freeze: hold one moment of a recording as a sustained chord""" out = np.zeros(int(out_len * SR) + int(grain * SR) + 10); g = int(grain * SR); w = np.hanning(g) src = x[int(t0 * SR):int((t0 + dur) * SR)] for k in range(int(out_len * dens)): at = int(k / dens * SR); o = int(rng.random() * jitter * (len(src) - g)) if o + g < len(src): out[at:at + g] += src[o:o + g] * w return out[:int(out_len * SR)] / (np.abs(out).max() + 1e-9) # the found chord: the most tonal moment of the Fish & Wildlife score, frozen and turned to D ct = tonal_window(cabeza[:int(24 * SR)]) pc = pitch_class(cabeza[int(ct * SR):int((ct + 2.5) * SR)]) shift = ((2 - pc + 6) % 12) - 6 # nearest transposition to D chord = freeze(cabeza, ct, 2.5, (END + 2) * 1.5) chord = resamp(chord, 2 ** (shift / 12)) chord = lp(wow(chord, 0.003, 0.4), 2400) print('found chord at', round(ct, 2), 's, pitch class', pc, '-> shift', shift) # ---------------- played instruments (warm, detuned) ---------------- def rhodes(m, t, d, g=0.12, pan=0.0, rev=0.25): f = mtof(m) * 2 ** (rng.normal(0, 0.06) / 12); n = int(d * SR); tt = np.arange(n) / SR mod = np.sin(2 * np.pi * f * 1.0 * tt) * 1.3 * np.exp(-tt * 3) s = np.sin(2 * np.pi * f * tt + mod) + 0.25 * np.sin(2 * np.pi * 2 * f * tt) * np.exp(-tt * 2) + 0.12 * np.sin(2 * np.pi * 7.03 * f * tt) * np.exp(-tt * 9) s *= np.exp(-tt * 1.1) * (1 + 0.18 * np.sin(2 * np.pi * 4.6 * tt)) * np.minimum(1, tt / 0.004) put(lp(s, 3200) * env(n, 0.004, 0.3), t, g, pan, rev) def strings(ms, t, d, g=0.05, bright=1800, pan=0.0): n = int(d * SR); tt = np.arange(n) / SR; s = np.zeros(n) for m in ms: for det in (-0.13, 0.0, 0.12): f = mtof(m) * 2 ** (det / 12); s += signal.sawtooth(2 * np.pi * f * tt * (1 + 0.003 * np.sin(2 * np.pi * 5.5 * tt)) + rng.random() * 6.28) put(lp(s, bright) * env(n, min(2.5, d * 0.4), min(2.5, d * 0.4)) / len(ms) / 3, t, g, pan, 0.5) def bass(m, t, d, g=0.28): f = mtof(m); n = int(d * SR); tt = np.arange(n) / SR s = np.sin(2 * np.pi * f * tt) + 0.3 * lp(signal.sawtooth(2 * np.pi * f * tt), 600) put(tape(s * env(n, 0.01, 0.12), 2.0), t, g, 0, 0.0) def kick(t, g=0.55): n = int(0.45 * SR); tt = np.arange(n) / SR; f = 50 + 90 * np.exp(-tt * 30) put(np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 7) + lp(rng.standard_normal(n), 900) * np.exp(-tt * 60) * 0.3, t, g) def snare(t, g=0.28): n = int(0.35 * SR); tt = np.arange(n) / SR s = bp(rng.standard_normal(n), 900, 6000) * np.exp(-tt * 14) + np.sin(2 * np.pi * 185 * tt) * np.exp(-tt * 22) * 0.6 put(s, t, g, 0.05, 0.35) def heart(t, g=0.35): for dt, gg in ((0, 1), (0.28, 0.55)): n = int(0.3 * SR); tt = np.arange(n) / SR; put(np.sin(2 * np.pi * 46 * tt) * np.exp(-tt * 14), t + dt, g * gg) # the hats are type: transients cut from Learning to Set Type r = frames_rms(hp(typeset, 2000), 256); pk = [i for i in range(1, len(r) - 1) if r[i] > r[i - 1] and r[i] > r[i + 1] and r[i] > 0.4 * r.max()] CLACKS = [hp(typeset[i * 256:i * 256 + int(0.09 * SR)], 1500) * env(int(0.09 * SR), 0.001, 0.06) for i in pk[:24]] print('type clacks', len(CLACKS)) def clack(t, g=0.12, pan=0.3): put(CLACKS[int(rng.integers(len(CLACKS)))] if CLACKS else rng.standard_normal(2000) * 0.1, t, g, pan, 0.1) # progression in D, two bars each: Dmaj9 | Bm11 | Gmaj9 | A6sus PROG = [([50, 62, 66, 69, 73, 76], 38), ([47, 59, 62, 66, 69, 76], 35), ([43, 59, 62, 66, 69, 71], 31), ([45, 57, 62, 64, 66, 69], 33)] # ---------------- ACT I: the dark room, the first scanline, the shadows ---------------- hum_n = int(T.CARDS[2][1] * SR) + int(8 * SR); tt = np.arange(hum_n) / SR proj = (np.sin(2 * np.pi * 60 * tt) * 0.4 + np.sin(2 * np.pi * 120 * tt) * 0.25 + lp(rng.standard_normal(hum_n), 300) * 0.5) * (1 + 0.25 * np.sin(2 * np.pi * 24 * tt)) put(proj * env(hum_n, 0.4, 5), 0.0, 0.05) crack = np.zeros(N); idx = rng.integers(0, N, int(END * 22)); crack[idx] = rng.choice([-1, 1], len(idx)) * rng.random(len(idx)) ** 3 crack = hp(crack, 1200) * 0.9; L[:] += crack * 0.08; R[:] += np.roll(crack, 37) * 0.08 # the optical track's dust, all the way through put(lp(rail[:int(14 * SR)], 400) * env(int(14 * SR), 2, 3), 0.5, 0.18, 0, 0.0) # a rumble under the dark for k in range(9): heart(1.2 + k * 2.05, 0.28 + 0.03 * k) put(chord[:int((DROP + 1) * SR)] * env(int((DROP + 1) * SR), 6, 1.0), 0.6, 0.18, -0.2, 0.6) # the found chord rises out of the hum bits_a = speech_bits(apollo, 0.5, 1.4); bits_h = speech_bits(hl65, 0.7, 1.9); bits_r = speech_bits(rca, 0.6, 1.6); bits_f = speech_bits(facts, 0.6, 1.5) print('voice bits', len(bits_a), len(bits_h), len(bits_r), len(bits_f)) def voice(src, bits, i, t, g=0.5, pan=0.0, slow=1.0, rev=0.5): if not bits: return a, b = bits[i % len(bits)]; s = cut(src, a, b); s = radio(resamp(s, 1 / slow)) if slow != 1 else radio(s) put(wow(s, 0.002, 0.8), t, g, pan, rev) voice(apollo, bits_a, 0, 2.0, 0.45, 0.4) voice(hl65, bits_h, 0, 7.2, 0.4, -0.3, slow=0.92) voice(apollo, bits_a, 1, 10.6, 0.35, 0.5) # ---------------- SSTV: each scanline that deposits a picture is heard as the picture ---------------- def sstv(rows, t, d, g=0.06, pan=0.6): """rows: (n_lines, n_px) luminance 0..1. tone 1500 Hz black .. 2300 Hz white, 1200 Hz sync between lines""" nl = rows.shape[0]; per = d / nl; out = [] for r_ in rows: sync = np.full(int(0.004 * SR), 1200.0) px = np.repeat(1500 + 800 * r_, max(1, int((per - 0.004) * SR / len(r_)))) out += [sync, px] f = np.concatenate(out); s = np.sin(2 * np.pi * np.cumsum(f) / SR) put(bp(s, 900, 3000) * env(len(s), 0.01, 0.05), t, g, pan, 0.15) CACHE = '/home/claude/afterlight/trailer/cache/' def rows_of(vol): fr = np.load(CACHE + vol + '.npz')['f']; img = fr[min(len(fr) - 1, 6)] @ np.array([0.3, 0.59, 0.11]) ys = np.linspace(0, img.shape[0] - 1, 36).astype(int); return img[ys][:, ::4] for s in T.SHOTS: vol, a = s[0], s[1]; d = s[5].get('decode', 0.5) try: sstv(rows_of(vol), a, d, 0.05 if a > DROP else 0.06) except Exception as e: print('sstv skip', vol, e) # ---------------- ACT II: the groove, the six questions ---------------- # BORROW: played chords enter, no drums; a borrowed voice c0 = T.CARDS[3][0] for k in range(2): ch, _ = PROG[k % 4]; tk = c0 + k * BAR * 0.9 for j, m in enumerate(ch[1:]): rhodes(m, tk + j * 0.045, BAR * 1.1, 0.06, -0.3 + j * 0.12, 0.35) voice(rca, bits_r, 0, c0 + 1.2, 0.35, -0.4) strings([62, 66, 69], c0, DROP - c0 + 0.4, 0.035, 1200) # the drop: FOCUS. wax and the beat. n = int(1.4 * SR); tt = np.arange(n) / SR put(bp(rng.standard_normal(n), 2000, 9000) * (tt / 1.4) ** 3, DROP - 1.4, 0.25) # the sizzle before put(chord[int(DROP * SR):int(T.AV1 * SR)] * env(int((T.AV1 - DROP) * SR), 0.05, 1.0), DROP, 0.14, -0.2, 0.4) nb = int((T.AV1 - DROP) / BEAT) for k in range(nb): t = beat(k); bar = k // 4; pos = k % 4; sec = sum(1 for c in T.CARDS[4:] if t >= c[0]) ch, root = PROG[(bar // 2) % 4] if pos == 0: kick(t); kick(t + BEAT * 1.5, 0.35) if pos == 2: kick(t + BEAT * 0.5, 0.3) if pos in (1, 3): snare(t, 0.22 + 0.02 * sec) for h in range(2): clack(t + h * BEAT / 2, 0.07 + 0.012 * sec, 0.35 if h else -0.25) if sec >= 3 and pos in (1, 3): clack(t + BEAT * 0.75, 0.05, 0.6) # bass: root, a push on the and of 2 if pos == 0: bass(root, t, BEAT * 1.4) if pos == 1: bass(root, t + BEAT * 0.5, BEAT * 0.45, 0.2) if pos == 2: bass(root + (7 if bar % 2 else 12), t, BEAT * 0.9, 0.22) # rhodes comps on beats 1 and the and of 2 if pos == 0: for j, m in enumerate(ch[1:]): rhodes(m, t + j * 0.03, BEAT * 1.6, 0.05 + 0.006 * sec, -0.3 + j * 0.12) if pos == 1: for j, m in enumerate(ch[2:5]): rhodes(m + 12, t + BEAT * 0.5 + j * 0.025, BEAT * 0.8, 0.03, 0.2) # strings swell by section, the melody (a slow line) arrives at REVEAL for i, c in enumerate(T.CARDS[4:]): strings([62, 66, 69, 74] if i % 2 == 0 else [59, 62, 66, 71], c[0] - 0.3, c[1] - c[0] + 0.8, 0.03 + 0.008 * i, 1400 + 300 * i) MEL = [(78, 2), (76, 1), (74, 1), (76, 3), (69, 1), (74, 2), (73, 1), (71, 1), (69, 4)] t = T.CARDS[5][0] + 0.1 for rep in range(3): for m, b in MEL: if t > T.CARDS[8][0] + 1: break n = int(b * BEAT * 1.02 * SR); tt = np.arange(n) / SR; f = mtof(m + (12 if rep == 2 else 0)) ph = 2 * np.pi * np.cumsum(f * (1 + 0.007 * np.sin(2 * np.pi * 5.2 * tt) * np.minimum(1, tt / 0.5))) / SR s = (np.sin(ph) + 0.4 * np.sin(2 * ph) + 0.15 * np.sin(3 * ph)) * env(n, 0.06, 0.25) put(lp(tape(s, 1.4), 2600), t, 0.07 + 0.015 * rep, 0.1, 0.45); t += b * BEAT # voices in the groove voice(hl65, bits_h, 1, T.CARDS[5][0] + 2.0, 0.3, 0.5) voice(apollo, bits_a, 2, T.CARDS[6][0] + 0.6, 0.35, -0.5) voice(apollo, bits_a, 3, T.CARDS[6][0] + 3.4, 0.3, 0.5) # EMBODY: breath close to the mic for k in range(3): n = int(1.1 * SR); tt = np.arange(n) / SR; put(bp(rng.standard_normal(n), 400, 2600) * np.sin(np.pi * tt / 1.1) ** 1.6, T.CARDS[7][0] + 0.5 + k * 2.2, 0.09, 0, 0.2) # REMAIN: the beam goes out; the voice slows into the dark; the warmth stays as the found chord bo = [s for s in T.SHOTS if s[5].get('beamout')][0][5]['beamout'] n = int(0.6 * SR); tt = np.arange(n) / SR; put(np.sin(2 * np.pi * np.cumsum(220 * np.exp(-tt * 3)) / SR) * np.exp(-tt * 5), bo, 0.12, 0, 0.6) # the lamp dies voice(hl65, bits_h, 2, bo + 0.6, 0.32, 0.0, slow=0.8, rev=0.9) # ---------------- ACT III: the avalanche of the twenty-four swarms ---------------- SOURCES = [cabeza, apollo, hl65, rca, typeset, rail, moonwalk, facts] for i, (a, b) in enumerate(T.AV_T): src = SOURCES[i % len(SOURCES)]; o = rng.random() * (len(src) / SR - 0.6) put(radio(cut(src, o, o + (b - a) + 0.05, 0.005)) if i % 3 else lp(cut(src, o, o + (b - a) + 0.05, 0.005), 3000), a, 0.38, (-1) ** i * 0.4, 0.2) # channel flip n = int(0.12 * SR); tt = np.arange(n) / SR; put(np.sin(2 * np.pi * np.cumsum(3400 * np.exp(-tt * 28) + 300) / SR) * np.exp(-tt * 20), a, 0.05, (-1) ** (i + 1) * 0.6) # the shear kick(a, 0.62); snare(a + 0.004, 0.18); clack(a, 0.16, 0) n = int((T.AV1 - T.AV0 + 0.4) * SR); tt = np.arange(n) / SR; u = tt / tt[-1] rise = bp(rng.standard_normal(n), 400, 9000) * u ** 2.5 + np.sin(2 * np.pi * np.cumsum(70 * 2 ** (u * 3.2)) / SR) * u ** 2 * 0.6 put(rise, T.AV0 - 0.4, 0.42, 0, 0.3) strings([50, 62, 66, 69, 74, 78], T.AV0 - 0.5, T.AV1 - T.AV0 + 0.5, 0.11, 3500) for k in range(int((T.AV1 - T.AV0) / (BEAT / 2))): clack(T.AV0 + k * BEAT / 2, 0.08, (-1) ** k * 0.5) # ---------------- the ember: silence, a projector room, one chord ---------------- hiss_n = int((END - T.EMBER + 3) * SR); tt = np.arange(hiss_n) / SR hiss = (lp(rng.standard_normal(hiss_n), 5000) * 0.12 + hp(rng.standard_normal(hiss_n), 6000) * 0.05) * (1 + 0.2 * np.sin(2 * np.pi * 24 * tt)) put(hiss * env(hiss_n, 0.02, 3), T.EMBER, 0.12) n = int(0.5 * SR); tt = np.arange(n) / SR; put(bp(rng.standard_normal(n), 3000, 9000) * (rng.random(n) > 0.995) * 6, T.EMBER + 0.4, 0.08) # the ember ticks for j, m in enumerate([50, 57, 62, 66, 69, 73, 76]): rhodes(m, T.TITLE0 + 0.15 + j * 0.06, END - T.TITLE0, 0.075, -0.4 + j * 0.13, 0.8) put(chord[:int((END - T.TITLE0) * SR)] * env(int((END - T.TITLE0) * SR), 2.5, 3.5), T.TITLE0 + 0.5, 0.12, 0, 0.9) voice(apollo, bits_a, 4, T.CRED0 + 0.8, 0.22, 0.3, slow=0.88, rev=1.0) # ---------------- mix ---------------- # the avalanche climbs: about +5 dB from its first cut to its last a_, b_ = int(T.AV0 * SR), int(T.AV1 * SR); ramp = np.linspace(1.0, 1.8, b_ - a_); L[a_:b_] *= ramp; R[a_:b_] *= ramp; RV[a_:b_] *= ramp def reverb(x, dur=4.2, damp=3200): n = int(dur * SR); tt = np.arange(n) / SR; ir = rng.standard_normal(n) * np.exp(-tt * 2.1); ir = lp(ir, damp); ir /= np.sqrt((ir ** 2).sum()) return signal.fftconvolve(x, ir)[:len(x)] wet = reverb(RV) mixL = L + wet * 0.55; mixR = R + np.roll(wet, int(0.013 * SR)) * 0.55 # duck everything at the ember: the drop to silence duck = np.ones(N); a = int(T.EMBER * SR); duck[a - int(0.03 * SR):a] = np.linspace(1, 0.0, int(0.03 * SR)); duck[a:] = 0.0 keep = np.zeros(N); keep[a:] = 1.0 # what plays after the ember was put in the same buses: rebuild it by re-rendering only the tail mixL = mixL * duck; mixR = mixR * duck L[:] = 0; R[:] = 0; RV[:] = 0 put(hiss * env(hiss_n, 0.02, 3), T.EMBER, 0.12) for j, m in enumerate([50, 57, 62, 66, 69, 73, 76]): rhodes(m, T.TITLE0 + 0.15 + j * 0.06, END - T.TITLE0, 0.075, -0.4 + j * 0.13, 0.8) put(chord[:int((END - T.TITLE0) * SR)] * env(int((END - T.TITLE0) * SR), 2.5, 3.5), T.TITLE0 + 0.5, 0.12, 0, 0.9) voice(apollo, bits_a, 4, T.CRED0 + 0.8, 0.22, 0.3, slow=0.88, rev=1.0) crk2 = crack * keep * 0.06 wet2 = reverb(RV, 6.0, 2400) mixL += (L + wet2 * 0.7 + crk2); mixR += (R + np.roll(wet2, int(0.017 * SR)) * 0.7 + crk2) M = np.stack([mixL, mixR], 1)[:int(END * SR)] fo = int(2.0 * SR); M[-fo:] *= np.linspace(1, 0, fo)[:, None] M = tape(M / (np.abs(M).max() * 0.8), 1.3) * 0.92 with wave.open('/home/claude/afterlight/trailer3/score3.wav', 'wb') as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes((M * 32767).astype(np.int16).tobytes()) # bands for the picture mono = M.mean(1); hop = SR // T.FPS; nf = int(END * T.FPS) edges = np.geomspace(60, 9000, 49); win = np.hanning(4096); B = np.zeros((nf, 48), np.float32) fq = np.fft.rfftfreq(4096, 1 / SR) for i in range(nf): s = mono[i * hop: i * hop + 4096] if len(s) < 4096: s = np.pad(s, (0, 4096 - len(s))) P = np.abs(np.fft.rfft(s * win)) ** 2 for b in range(48): B[i, b] = P[(fq >= edges[b]) & (fq < edges[b + 1])].sum() B = np.log1p(B / (np.percentile(B, 99) + 1e-9) * 20); B /= B.max() np.save('/home/claude/afterlight/trailer3/bands.npy', B) print('ok', M.shape)