diff --git a/CHANGELOG.md b/CHANGELOG.md index 15bc7d1..027bb7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.61.0-test1 — Passages: one idea at a time + +- The show plays in passages of 16 or 32 bars. Each passage has one spatial idea: one group leads it, others + support it, the rest hold a quiet figure. A passage begins, develops and ends; it does not restart every few bars. +- Scenes, room compositions, single-colour blocks and bar figures change with the passage instead of on their own + clocks. Raw / Deep / Hypnotic no longer changes look, movement, gate, colour spread and beam every eight bars. +- Five stage figures that read with the hall beams and the stage row off: crossing planes and a held asymmetric + rake from the back towers, columns that slowly open and lean into the hall, and a diagonal cross and a widening + fan from the roof spots. With the hall fader down, the stage carries every passage. +- Fewer whole-rig brightness steps with the strobes off: only the leading group pulses with the kick, holding + groups keep their light, the stage no longer switches halves every two beats, and the foreground accent moves + every four bars instead of every two beats. The screen dims while the stage leads. +- Episodes (lightning, depth chase, spatial sequence) only enter at the start of a passage. A depth chase starts as + an aligned wave, develops two variants and returns to the wave. +- Repetition memory looks at figure, lean and roles; a colour change is not a new passage. +- Listening hears six parts of the track from the spectrum (kick, bass, body, lead, clap, hats): how loud each + is, when it hits and when it comes in as a new layer. A new lead or body opens the figure, the bass widens it. +- Groove: the bars tick with the hats, and without part data the beat grid plays hats on the off-beat. +- Techno and Hard: the heads stand still. Each passage holds a position for a quarter of its length and moves only + in the dark. The rhythm is in the light: the leading group plays a beat-locked chase (across, from the centre + out, bottom to top, odd/even, ping-pong), quarters, and eighths late in a peak. The passage opens and closes + with everything on. +- The roof spots weave instead of crossing on one line, and the support figures keep each beam in its own lane, + so beams no longer pile up into one knot from the DJ's view. +- Depth chases and lightning come by more often in Raw / Deep / Hypnotic. +- `tools/measure_show.gd` measures a sixteen-minute generated set: changes per minute, changes at once, light + jumps with the strobes off, stage-head level and quick returns. + ## 0.60.0-test3 — Roof bars and upper back-tower spots - Adds eight individually addressable LED bars along the stage roof, evenly placed between the existing washes. diff --git a/project.godot b/project.godot index d956fef..7d9b71d 100644 --- a/project.godot +++ b/project.godot @@ -5,7 +5,7 @@ config_version=5 [application] config/name="Stage Visualizer" -config/version="0.60.0" +config/version="0.61.0-test1" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7", "Forward Plus") diff --git a/scripts/listen.gd b/scripts/listen.gd index d8de550..119ff2b 100644 --- a/scripts/listen.gd +++ b/scripts/listen.gd @@ -28,6 +28,7 @@ var bass := 0.0 # 0..1, the three bands var mid := 0.0 var treble := 0.0 var accent := 0.0 # 1 on a high hit, falling away +var parts := ListenParts.new() # kick, bass, body, lead, clap, hats: level, hits and layers coming in var hype := 0.0 # stays up for a while after a drop var ev_drop := false # this frame: the kick came back after a break (the drop) var ev_broke := false # this frame: a break began @@ -184,6 +185,7 @@ func stop() -> void: mid = 0.0 treble = 0.0 accent = 0.0 + parts.reset() hype = 0.0 kick = false begin_frame() @@ -225,6 +227,7 @@ func update(dt: float) -> void: func begin_frame() -> void: kick = false + parts.clear_events() ev_drop = false ev_broke = false ev_back = false @@ -265,6 +268,7 @@ func _hear(bands: Array, rms: float, t: float) -> void: ev_lift = ev_lift or ev.lift ev_silent = ev_silent or (ev.silent and not was_silent) accent = maxf(accent, a.accent) + if bands.size() >= 9: parts.update(bands.slice(3), t, STEP, clampf((rms - 0.0025) / 0.0075, 0.0, 1.0)) bass = a.bass mid = minf(1.0, a.mid) treble = a.high @@ -391,6 +395,7 @@ func _reset_detection() -> void: _tempo_hits = 0 func _new_track() -> void: + parts.new_track() _reset_detection() _listener.new_track() _stable_bpm = 0.0 diff --git a/scripts/listen_ear.gd b/scripts/listen_ear.gd index d774d69..9086459 100644 --- a/scripts/listen_ear.gd +++ b/scripts/listen_ear.gd @@ -91,7 +91,10 @@ func push(samples: PackedFloat32Array, step: Callable) -> void: if count >= next: next += every var rms: float = spectrum() - step.call([band(35, 170), band(250, 3000), band(4000, 14000)], rms, count / rate) + # the three bands of SV's listening, then the six parts (listen_parts.gd) + var bands := [band(35, 170), band(250, 3000), band(4000, 14000)] + for b in ListenParts.BANDS: bands.append(band(b[0], b[1])) + step.call(bands, rms, count / rate) func skip(frames: int) -> void: count += frames diff --git a/scripts/listen_parts.gd b/scripts/listen_parts.gd new file mode 100644 index 0000000..f256b4e --- /dev/null +++ b/scripts/listen_parts.gd @@ -0,0 +1,74 @@ +class_name ListenParts +extends RefCounted +# The parts of a track, from six bands of the spectrum: how loud each is against the fullest it has been in this +# track, a hit when it strikes (a kick, a clap, a hat), and when a part comes in as a new layer. +# Bands, not separated instruments: a hat and a shaker share the top band, a lead and a vocal the middle one. + +const NAMES := ["kick", "bass", "body", "lead", "clap", "hats"] +const BANDS := [[40.0, 90.0], [90.0, 250.0], [250.0, 600.0], [600.0, 2500.0], [2500.0, 5500.0], [8000.0, 15000.0]] +const THRESHOLD := [0.25, 0.22, 0.18, 0.22, 0.3, 0.12] # how hard a rise must be to count as a hit +const GAP := [0.12, 0.1, 0.07, 0.09, 0.2, 0.05] # seconds before the same part can hit again + +var level := PackedFloat32Array([0, 0, 0, 0, 0, 0]) # 0..1 against this track's fullest +var hit := PackedFloat32Array([0, 0, 0, 0, 0, 0]) # 1 on a hit, falling away quickly +var present := PackedFloat32Array([0, 0, 0, 0, 0, 0]) # 0..1, how much the part plays over the last seconds +var came_in := [false, false, false, false, false, false] # this frame: the part came in as a layer +var hits := PackedInt32Array([0, 0, 0, 0, 0, 0]) # hits counted, so a show can walk on with each + +var _fast := PackedFloat32Array([0, 0, 0, 0, 0, 0]) +var _slow := PackedFloat32Array([0, 0, 0, 0, 0, 0]) +var _bed := PackedFloat32Array([0, 0, 0, 0, 0, 0]) +var _ref := PackedFloat32Array([1e-4, 1e-4, 1e-4, 1e-4, 1e-4, 1e-4]) +var _yard := PackedFloat32Array([1e-4, 1e-4, 1e-4, 1e-4, 1e-4, 1e-4]) +var _armed := [true, true, true, true, true, true] +var _last := PackedFloat32Array([-9, -9, -9, -9, -9, -9]) +var _out_for := PackedFloat32Array([0, 0, 0, 0, 0, 0]) + +static func _follow(value: float, target: float, dt: float, seconds: float) -> float: + return value + (target - value) * (1.0 - exp(-dt / maxf(seconds, 0.0001))) + +func reset() -> void: + for i in 6: + level[i] = 0.0 + hit[i] = 0.0 + present[i] = 0.0 + came_in[i] = false + _fast[i] = 0.0 + _slow[i] = 0.0 + _bed[i] = 0.0 + _ref[i] = 1e-4 + _yard[i] = 1e-4 + _out_for[i] = 0.0 + +# Forget the yardstick of the last track (a new track can be quieter or fuller). +func new_track() -> void: + for i in 6: _yard[i] = maxf(1e-4, _bed[i]) + +# One step of sound: raw band magnitudes, the time on the sound clock, the step length. +func update(raw: Array, t: float, dt: float, gate: float) -> void: + for i in 6: + var x: float = raw[i] if i < raw.size() else 0.0 + _ref[i] = maxf(1e-4, _follow(_ref[i], x, dt, 0.025 if x > _ref[i] else 8.0)) + _fast[i] = _follow(_fast[i], x, dt, 0.012) + _slow[i] = _follow(_slow[i], x, dt, 0.2) + _bed[i] = _follow(_bed[i], x, dt, 1.5) + _yard[i] = maxf(_bed[i], _yard[i] * exp(-dt / 90.0)) + level[i] = _follow(level[i], clampf(pow(x / _ref[i], 0.75), 0.0, 1.0) * gate, dt, 0.02 if x > _slow[i] else 0.12) + var rise := maxf(0.0, (_fast[i] - _slow[i]) / _ref[i]) * gate + if rise < THRESHOLD[i] * 0.4: _armed[i] = true + if _armed[i] and rise > THRESHOLD[i] and t - _last[i] > GAP[i]: + _armed[i] = false + _last[i] = t + hit[i] = maxf(hit[i], clampf(rise * 1.6, 0.4, 1.0)) + hits[i] += 1 + hit[i] *= exp(-dt * 14.0) + var p := clampf(_bed[i] / _yard[i], 0.0, 1.0) * gate + # a layer comes in when the part rises well above where it was for a while + if p < 0.3: _out_for[i] += dt + elif p > 0.6: + if _out_for[i] > 4.0: came_in[i] = true + _out_for[i] = 0.0 + present[i] = p + +func clear_events() -> void: + for i in 6: came_in[i] = false diff --git a/scripts/listen_parts.gd.uid b/scripts/listen_parts.gd.uid new file mode 100644 index 0000000..ad65d87 --- /dev/null +++ b/scripts/listen_parts.gd.uid @@ -0,0 +1 @@ +uid://b42xtdfy24pmt diff --git a/scripts/show/director.gd b/scripts/show/director.gd index f8daed4..ffbf14c 100644 --- a/scripts/show/director.gd +++ b/scripts/show/director.gd @@ -224,6 +224,12 @@ var blind := 0.0 var visual_master := 0.0 var _screen_focus := 0.0 var phrasing := ShowPhrasing.new() +var passage := ShowPassage.new() +var _passage_seen := -1 +# The groove: what the parts of the track do, each with its own place in the rig (see _groove). +var groove := {"clap": 0.0, "hats": 0.0, "body": 0.0, "bass": 0.0, "claps": 0, "hat_count": 0, "bodies": 0} +var _grid_step := -1 +var _frozen := {"m": 0.0, "spin": 0.0, "phase": 0.0} # where the movement stood when the passage began (techno holds it) var gestures := ShowGestures.new() var _drop_count := 0 # The DJ's own buttons (keys or a controller), on top of the automatic show, as `manual` in the browser's director: @@ -316,9 +322,7 @@ func _sequence(dt: float, grid: float, sec: String) -> void: _blk_section = sec _blk_start = bar _blk_len = LightSequencer.length_for(sec, _blk_index, salt) - if not first and _t - _scene_at >= 8.0: _next(sec) # a new block is a new scene, on the bar line - elif bar != _seq_bar and (bar - _blk_start) % 8 == 0 and _t - _scene_at >= 8.0: - _next(sec) # a long block keeps its pattern, but the look and the stack change every eight bars + # Scenes change with the passage (passage.gd), not with every block: the block only keeps the grid. _seq_bar = bar if step != _seq_step: _seq_step = step @@ -691,9 +695,10 @@ func _direct(dt: float) -> void: _bar_start = _beat == 1 or first.call(8) # hold a scene for a musical phrase, changing on its first beat; a calm part holds its look twice as long var hold := 2 if calm else 1 - if not _multi() and first.call(int(mood.sceneKicks) * hold) and _t - _scene_at >= float(mood.sceneMin) * hold: _next(sec) + if mood.flow and first.call(int(mood.sceneKicks) * hold) and _t - _scene_at >= float(mood.sceneMin) * hold: _next(sec) var n := rig.battens.size() - var pattern := floori((_beat - 1) / 32.0) % 4 + var pattern := (passage.index if not mood.flow else floori((_beat - 1) / 32.0)) % 4 + var bar_hit: float = ev.kick * ({"lead": 1.0, "support": 0.7, "hold": 0.3}[passage.role("bars")] if not mood.flow else 1.0) var middle := (n - 1) / 2.0 for i in n: var on := true @@ -701,7 +706,7 @@ func _direct(dt: float) -> void: 1: on = i % 2 == _beat % 2 2: on = absf(i - middle) < (_beat % 4 + 1) * n / 8.0 3: on = i % 4 == _beat % 4 - if on: _bar_level[i] = ev.kick + if on: _bar_level[i] = bar_hit if _bar_start: for h: L.Head in _heads: h.rp = randf() * 2.0 - 1.0 @@ -723,13 +728,26 @@ func _direct(dt: float) -> void: # a new part, or a layer coming in, changes the look on the first beat of the next pair of bars if not _pending.is_empty(): if ev.drop or (not _pending.has("lift") and _pending.section != sec) or sec == "break": _pending = {} - elif _bar_start and _t - _pending.at >= 3.0 and _t - _scene_at >= 12.0: + elif mood.flow and _bar_start and _t - _pending.at >= 3.0 and _t - _scene_at >= 12.0: _next(sec) _pending = {} if _multi() and not brk: _sequence(dt, sig.phase_clock, sec) else: seq_gain = [1.0, 1.0, 1.0, 1.0] + # One passage at a time: a new one is a new scene, and the room, the bars and the episodes follow it. + if not mood.flow and passage.update(sig.phase_clock, sec, ev.drop, tune.groups, "%s:%s" % [genre, title]): + if _passage_seen >= 0 and not ev.drop and not starting and _t - _scene_at >= 8.0: + _next(sec) + _pending = {} + _passage_seen = passage.index + _frozen = {"m": _motion, "spin": _spin, "phase": sig.phase_clock} + var passages: bool = not mood.flow and not brk + # Techno: the heads stand still and the rhythm comes from light moving between them, as in a depth chase. + var still: bool = passages and mood.family in ["Techno", "Hard"] + passage.stepped = still + _groove(dt, sig.phase_clock, brk) + # the DJ asked for the next look: a new scene now, with the next look and formation if int(manual.looks) != _looks_seen: _looks_seen = int(manual.looks) @@ -762,7 +780,9 @@ func _direct(dt: float) -> void: var held: bool = manual.strobe or manual.blinder var master: float = 0.0 if manual.blackout else maxf(_present * _level, 1.0 if held else 0.0) * float(mood.level) * float(tune.master) * (1.0 - blackout) visual_master = master - phrasing.update(dt, sig.phase_clock, sec, sig.build, sig.drop, mood.flow, safe, "%s:%s" % [genre, title]) + phrasing.update(dt, sig.phase_clock, sec, sig.build, sig.drop, mood.flow, safe, "%s:%s" % [genre, title], passage.index if passages else -1, not passages or not passage.stage_leads()) + # the screen steps back while the stage carries the passage + if passages and passage.stage_leads(): visual_master *= 0.65 var screen_turn := not brk and phrasing.cue == 7 and hall.stage_screen != null and hall.stage_screen.visible _screen_focus = lerpf(_screen_focus, 1.0 if screen_turn else 0.0, ease.call(2.0)) var single_colour := not multi and not brk and phrasing.cue == 1 @@ -785,7 +805,7 @@ func _direct(dt: float) -> void: rig.blinder = blind * master # ----- the moving heads ----- - var k := {"m": _motion, "spin": _spin, "phase": sig.phase_clock, "build": sig.build, "hangar_look": _scene % 3} + var k := {"m": _frozen.m if still else _motion, "spin": _frozen.spin if still else _spin, "phase": _frozen.phase if still else sig.phase_clock, "build": sig.build, "hangar_look": _scene % 3} _hangar_looks(tier, brk) var look: Array = L.HEAD_LOOKS[_head_look] var stage_z := Rig.TRUSS_Z[0] - 8.0 # over the booth @@ -813,10 +833,10 @@ func _direct(dt: float) -> void: var e_moving: float = ease.call(0.1) var e_settle: float = ease.call(0.7) var e_gate: float = ease.call(0.65 if safe else 0.22) - var cue := "%d:%d" % [_scene, floori((_beat - 1) / 16.0)] + var cue := "%d:%d:%d" % [_scene, floori((_beat - 1) / 16.0), floori(passage.progress * 4.0) if passage.stepped else 0] # a step of a still figure moves in the dark # what is the same for every head this frame, worked out once var stack := _stack(look, tier) - var look_move: String = stack[0] + var look_move: String = "still" if still else stack[0] var look_gate: String = stack[1] var look_colour: String = stack[2] var look_beam: Array = L.BEAMS[stack[3]] @@ -831,9 +851,11 @@ func _direct(dt: float) -> void: var white_k := bld * 0.35 var peak := sec == "peak" var foreground_bank := 2 if brk else _scene % 3 - var accent_bank := floori(beats / 2.0) % 3 + var accent_bank := floori(beats / (16.0 if passages else 2.0)) % 3 var e_kick: float = sig.kick * (0.18 if safe else 1.0) var I_base: float = 0.1 + sig.mid * 0.15 + sig.build * 0.3 if brk else 0.045 + sig.energy * 0.23 + sig.bass * 0.12 + e_kick * 0.55 + sig.accent * 0.1 + var kick_term: float = 0.0 if brk else e_kick * 0.55 + var I_still: float = I_base - kick_term var I_drop: float = sig.drop * 0.5 var I_accent: float = sig.accent * (0.04 if safe else 0.18) * master var chase_gate: bool = not safe and not brk and P.get("gate", false) @@ -844,14 +866,19 @@ func _direct(dt: float) -> void: var p := f.pos var row: bool = h.role == "row" var role: String = "stage" if row else h.role + var prole: String = passage.role(f.group) if passages and not row else "" var a: Vector2 if h.role == "overhead": if brk: a = _toward(p, _break_target(h, p, k, s, sig) if multi else L.floor_target(h, p, k, _beat, sec, sig.build, sig.energy, sig.drop)) else: - var target := L.move(look_move, L.position(look[2], h, p, k, s), h, p, k) + # in a stage passage the rear truss and the side towers keep their own lanes instead of the hall's looks + var lane := passage.stage_target(f.group, f.row, f.mount, h.xn, h.u, k.m) if passages and passage.stage_leads() and f.group in ["rear", "side"] else Vector3.INF + var target := L.move(look_move, L.position(look[2], h, p, k, s), h, p, k) if lane == Vector3.INF else lane a = _toward(p, _inside(target)) elif row: a = _toward(p, L.row_aim(row_look, h, p, k)) - else: a = _program_aim("break" if brk else P.name, h.hanging, h, k) + else: + var pt := passage.stage_target(f.group, f.row, f.mount, h.xn, h.u, k.m) if passages else Vector3.INF + a = _program_aim("break" if brk else P.name, h.hanging, h, k) if pt == Vector3.INF else _toward(p, _inside(pt)) # crossfade the asked aim as well as limiting the motors; a head dims while it travels far if not h.aimed: h.aim = _toward(p, p + f.dir) @@ -867,33 +894,38 @@ func _direct(dt: float) -> void: h.aim.x += pan_gap * e25 h.aim.y += (a.y - h.aim.y) * e25 f.target = p + _dir(h.aim) * 30.0 - var I: float = I_base + var I: float = I_base if prole == "" else I_still + kick_term * ShowPassage.PULSE[prole] / 0.55 var pg := 1.0 - if chase_gate and not row and role == "overhead": + if chase_gate and not row and role == "overhead" and prole in ["", "lead"]: pg = 0.1 + 0.9 * pow(maxf(0.0, cos(h.u * TAU * 1.5 - sig.phase_clock * TAU / 4.0 + h.row * 1.1)), 3.0) - I = (I * (_row_w if row else _w[role]) * pg + I_drop) * master + I = (I * (_row_w if row else _w[role] if prole == "" else ShowPassage.LEVEL[prole]) * pg + I_drop) * master # give the centre, the sides and the stage turns in the foreground; a drop opens the full rig var bank: int = h.bank if not brk and accent_bank == bank: I += I_accent # the genre's share of lit heads: the rest stay dark outside a drop - if not brk and not row and h.lit_key > lit_share: I *= dropk * 0.9 + if not brk and not row and h.lit_key > lit_share and not (prole != "" and role == "stage"): I *= dropk * 0.9 var foreground: bool = bank == foreground_bank - var lv: float = 1.0 if foreground else 0.28 if calm or brk else 0.45 + var lv: float = 1.0 if foreground or prole != "" else 0.28 if calm or brk else 0.45 h.level += (lv - h.level) * e18 I *= h.level + (1.0 - h.level) * dropk - I *= phrasing.gain(bank, f.mount, dropk) + if not (prole != "" and role == "stage"): I *= phrasing.gain(bank, f.mount, dropk) I *= 1.0 - _screen_focus * (0.6 if row or role == "stage" else 0.3) * (1.0 - dropk) # hold each figure for 32 kicks; builds leave a small group near the stage var build_on: bool = absf(h.xn) <= 1.0 - bld * 0.85 and (bld < 0.65 or bank == 2 or h.row % 3 == 0) var gate_name: String = look_gate if h.role == "overhead" else row_gate if row else "patterns" if safe and not row: gate_name = "all" + # in a passage the figure owns the stage's selection, and only the lead plays the look's gate + if prole != "" and (role == "stage" or prole != "lead"): gate_name = "all" var on: bool = build_on if brk else L.gate(gate_name, h, counted, pattern) + if prole != "" and role == "stage": on = passage.stage_on(f.group, f.row) if row and dropk < 0.5: on = on and posmod(h.slot, 4) == posmod(floori(sig.phase_clock / 8.0) + _scene, 4) h.gate += ((1.0 if on else 0.0) - h.gate) * e_gate var gain: float = gains[gate_name] I *= (h.gate + (1.0 - h.gate) * dropk) * (gain + (1.0 - gain) * dropk) I *= h.move_level + (1.0 - h.move_level) * dropk + # the lead's rhythm: light travels between its fixtures on the beat + if still and prole == "lead": I *= passage.chase(f.group, f.row, h.u, sig.phase_clock, sec) + (1.0 - passage.chase(f.group, f.row, h.u, sig.phase_clock, sec)) * dropk f.level = _lamp(I) * float(faders.get(f.group, 1.0)) # the look spreads the two colours, through a slow colour fade var cname: String = look_colour if h.role == "overhead" else row_colour if row else "sides" @@ -901,7 +933,7 @@ func _direct(dt: float) -> void: var target := _vivid(_spread(cname, h, k32, k64, pal).lerp(Color.WHITE, share.y)) if multi else _vivid(col.lerp(col2, share.x).lerp(Color.WHITE, share.y)) if single_colour: target = col # A whole techno block can use a single colour across the rig, including the row and washes. - if multi and _mono_block(): target = mono_colour(_blk_index + _palette * 137) + if multi and _mono_block(): target = mono_colour(_colour_block() + _palette * 137) if warm > 0.0: target = target.lerp(WARM, warm) if brk: target = target.lerp(Color.WHITE, white_k) if not h.colored: @@ -913,6 +945,7 @@ func _direct(dt: float) -> void: z *= 1.35 if calm or brk else 0.75 if peak else 0.9 var gobo: float = 0.7 if not brk and not calm and not row else 0.0 if row: z *= 0.45 # keep space to see the booth and the rear rig + if prole != "" and f.group == "back" and f.kind == Rig.BEAM: z *= 0.75 # tight beams draw the planes if h.role == "overhead" and not brk: # the look's beam: narrow, wide or with a gobo z *= look_beam[0] if look_beam[1] >= 0.0: gobo = look_beam[1] @@ -941,17 +974,17 @@ func _direct(dt: float) -> void: f.level *= phrasing.gain(2 if f.group == "roof" else 1, f.mount, dropk) f.color = tint.call(pal[i % pal.size()] if multi else col2 if i % 2 else col) if single_colour: f.color = tint.call(col) - if multi and _mono_block(): f.color = mono_colour(_blk_index + _palette * 137) + if multi and _mono_block(): f.color = mono_colour(_colour_block() + _palette * 137) for i in rig.battens.size(): var bt: Rig.Batten = rig.battens[i] _bar_level[i] = _bar_level[i] * exp(-dt * mood.decay) var breathe: float = (0.25 + mood.breathe) * sig.mid * (0.5 + 0.5 * sin(_t * 0.8 + i * 0.6)) if mood.breathe > 0.0 or brk else 0.0 var run: float = sig.build * pow(0.5 + 0.5 * sin(bt.u * 5.0 + maxi(bt.truss, 0) + _chase), 4.0) if brk else 0.0 # a chase racing along as the build-up grows - var hit: float = sig.accent * (0.5 if i % 2 == _beat % 2 else 0.1) + var hit: float = sig.accent * (0.5 if i % 2 == _beat % 2 else 0.1) * (0.4 if passages and passage.role("bars") == "hold" else 1.0) bt.level = _lamp(maxf(maxf(maxf(_bar_level[i], breathe), maxf(run, hit)) * _w.bars, maxf(sig.drop, sig.lift * 0.45)) * master) * float(tune.groups.get("batten", 1.0)) - bt.color = mono_colour(_blk_index + _palette * 137) if multi and _mono_block() else pal[i % pal.size()] if multi else col2 if i % 2 else col + bt.color = mono_colour(_colour_block() + _palette * 137) if multi and _mono_block() else pal[i % pal.size()] if multi else col2 if i % 2 else col if single_colour: bt.color = col - var bar_phrase := floori(sig.phase_clock / 32.0) + var bar_phrase := passage.index if passages else floori(sig.phase_clock / 32.0) var bar_pattern := BarPixels.pattern_for(bar_phrase, i, bt.group, safe) bt.pixel_levels = BarPixels.levels(bar_pattern, floori(sig.phase_clock / 16.0)) for pixel in Rig.Batten.PIXELS: @@ -960,6 +993,9 @@ func _direct(dt: float) -> void: pixel_color = col2 if not multi else pal[(i + 1) % pal.size()] bt.pixel_colors[pixel] = pixel_color bt.level *= phrasing.gain(2 if bt.group in ["front", "back"] else 1, bt.mount, dropk) + # hats tick along the bars: a quarter of them at a time, walking on with every hat + if passages and posmod(i + int(groove.hat_count), 4) == 0: + bt.level = maxf(bt.level, _lamp(groove.hats * 0.4 * master) * float(tune.groups.get("batten", 1.0))) if safe and not manual.strobe: var glow: float = (0.04 + sig.mid * 0.08) * (0.8 + 0.2 * sin(sig.phase_clock * TAU / 32.0 + bt.u)) bt.level = _lamp(glow * _w.bars * master) * float(tune.groups.get("batten", 1.0)) @@ -981,15 +1017,15 @@ func _direct(dt: float) -> void: rig.truss_rolls[i] = 0.0 if brk else L.truss_roll(_truss_look, i) # the room itself: how much of all this lands on walls, floor and crowd, and in what colour - var room_base := mono_colour(_blk_index + _palette * 137) if multi and _mono_block() else col + var room_base := mono_colour(_colour_block() + _palette * 137) if multi and _mono_block() else col rig.room_color = room_base.lerp(Color.WHITE, maxf(flash, blind * 0.7)) - rig.room_level = (1.5 + sig.energy * 7.0 + sig.kick * 26.0 + sig.build * 6.0 + sig.lift * 14.0 + flash * 100.0 + blind * 50.0) * master + rig.room_level = (1.5 + sig.energy * 7.0 + sig.kick * (14.0 if passages else 26.0) + sig.build * 6.0 + sig.lift * 14.0 + flash * 100.0 + blind * 50.0) * master hall_look = [head_name, truss_name, L.ROW_LOOKS[row_look][0]] status = ("build-up" if sig.build > 0.45 else "break") if brk else SECTION_NAME.get(sec, sec) + " · " + head_name _shown = [col, col2] - colours_in_use = [mono_colour(_blk_index + _palette * 137)] if multi and _mono_block() else pal if multi else _shown + colours_in_use = [mono_colour(_colour_block() + _palette * 137)] if multi and _mono_block() else pal if multi else _shown if single_colour: colours_in_use = [col] - gestures.update(dt, sig.phase_clock, sec, sig.drop, safe, mood.flow, "%s:%s" % [genre, title], bpm, _multi()) + gestures.update(dt, sig.phase_clock, sec, sig.drop, safe, mood.flow, "%s:%s" % [genre, title], bpm, _multi(), not passages or passage.progress < 0.15) if not held: gestures.apply(rig, master, tune.groups, safe) # SV in the browser draws a level of 1 as a full beam and its renderer is exposed for levels around 0.2 to 0.5; @@ -998,9 +1034,44 @@ func _direct(dt: float) -> void: static func _lamp(I: float) -> float: return 1.0 - exp(-maxf(I, 0.0) * 3.5) +# The parts of the track as the show plays them: claps answer from the towers, hats tick along the bars, percussion +# on the delay towers, the bass widens the figure, and a lead coming in opens it. Without sound to analyse (rekordbox +# alone), the beat grid gives a clap on two and four and a hat on every off-beat. +func _groove(dt: float, grid: float, brk: bool) -> void: + var p: ListenParts = _listen.parts if _listen != null and _listen.source != "none" else null + var heard := p != null and (p.present[4] > 0.05 or p.present[5] > 0.05) + for k in ["clap", "hats", "body"]: groove[k] *= exp(-dt * 14.0) + if heard: + groove.clap = maxf(groove.clap, p.hit[4]) + groove.hats = maxf(groove.hats, p.hit[5]) + groove.body = maxf(groove.body, p.hit[2]) + groove.claps = p.hits[4] + groove.hat_count = p.hits[5] + groove.bodies = p.hits[2] + groove.bass = p.level[1] * p.present[1] + if p.came_in[3] or p.came_in[2]: passage.open() + elif not brk and _activity > 0.3: + var step := floori(grid * 2.0) + if step != _grid_step: + _grid_step = step + if step % 2 == 1: + groove.hats = 0.8 + groove.hat_count += 1 + elif posmod(step / 2, 2) == 1: + groove.clap = 1.0 + groove.claps += 1 + groove.bass = 0.5 + if brk: groove.bass = 0.0 + passage.breath = groove.bass + func _mono_block() -> bool: var identity := "%s:%d" % [title, _palette] - return posmod(hash("%s:%d:mono" % [identity, _blk_index]), 5) == 1 and posmod(hash("%s:%d:mono" % [identity, _blk_index - 1]), 5) != 1 + var i := _colour_block() + return posmod(hash("%s:%d:mono" % [identity, i]), 5) == 1 and posmod(hash("%s:%d:mono" % [identity, i - 1]), 5) != 1 + +# A single-colour block lasts a whole passage, so colour does not change on its own clock. +func _colour_block() -> int: + return _blk_index if mood.flow else passage.index static func mono_colour(block: int) -> Color: var colours := [Color(0.03, 0.12, 1.0), Color(1.0, 0.02, 0.01), Color(0.03, 1.0, 0.08), Color.WHITE, Color(1.0, 0.58, 0.24)] @@ -1010,4 +1081,4 @@ static func mono_colour(block: int) -> Color: const GROUP_LEVEL := {"hall": 1.0, "front": 1.0, "back": 0.9, "rear": 0.85, "side": 0.85, "delay": 0.85, "roof": 0.8} func scene_text() -> String: - return "%s · %s · %s · %s · trusses %s · row %s%s%s" % [status, PROGRAMS[_program].name, mood.get("family", ""), ShowPhrasing.NAMES[phrasing.cue], truss_name, row_look, (" · " + seq_text) if _multi() else "", (" · " + gestures.name) if gestures.mix > 0.01 else ""] + return "%s · %s · %s · %s · trusses %s · row %s%s%s" % [status, PROGRAMS[_program].name, mood.get("family", ""), ShowPhrasing.NAMES[phrasing.cue], truss_name, row_look, (" · " + seq_text) if _multi() else "", (" · " + gestures.name) if gestures.mix > 0.01 else ""] + (" · " + passage.describe() if not mood.get("flow", false) else "") diff --git a/scripts/show/gestures.gd b/scripts/show/gestures.gd index ccee3b2..421f314 100644 --- a/scripts/show/gestures.gd +++ b/scripts/show/gestures.gd @@ -46,7 +46,8 @@ func start(effect: String, grid: float, seed_value: int, gentle := false) -> voi point.x = clampf(point.x, -18.0, 18.0) point.z = clampf(point.z, -34.0, 33.0) -func update(dt: float, grid: float, section: String, drop: float, gentle: bool, flowing: bool, identity: String, _tempo := 120.0, sequences := false) -> void: +# opening: whether a passage is just beginning; an episode only enters there, so it never cuts into a figure. +func update(dt: float, grid: float, section: String, drop: float, gentle: bool, flowing: bool, identity: String, _tempo := 120.0, sequences := false, opening := true) -> void: if identity != _identity or grid < _last_grid - 4.0: _identity = identity _rng.seed = hash(identity + ":gestures") @@ -60,10 +61,12 @@ func update(dt: float, grid: float, section: String, drop: float, gentle: bool, var allowed := section in ["groove", "peak"] and drop < 0.1 and not flowing if boundary != _boundary: _boundary = boundary - if allowed and grid >= next_at and mix < 0.01: + if allowed and opening and grid >= next_at and mix < 0.01: var choices := ["lightning"] if sequences and not gentle: choices.append("spatial_sequence") if grid >= 256.0: choices.append("depth_chase") + # hypnotic techno travels: the depth chase is part of its base, not a rare guest + if sequences and not gentle and grid >= 64.0: choices = ["depth_chase", "depth_chase", "lightning"] + (["spatial_sequence"] if grid >= 256.0 else []) if gentle: choices = ["depth_chase"] if grid >= 256.0 else [] if choices.is_empty(): next_at = grid + 128.0 @@ -71,7 +74,9 @@ func update(dt: float, grid: float, section: String, drop: float, gentle: bool, var effect: String = choices[_rng.randi_range(0, choices.size() - 1)] start(effect, grid, _rng.randi(), gentle) # Every episode buys at least four times its duration of ordinary choreography. - next_at = grid + duration + maxf(128.0, duration * 4.0) + [0.0, 32.0, 64.0][_rng.randi_range(0, 2)] + var rest := maxf(128.0, duration * 4.0) + if sequences and not gentle and name == "depth_chase": rest = duration * 3.0 # passages of its own figures, then it travels again + next_at = grid + duration + rest + [0.0, 32.0, 64.0][_rng.randi_range(0, 2)] _gentle = gentle if gentle and name == "lightning": name = "" if not allowed: duration = minf(duration, elapsed) @@ -106,12 +111,19 @@ func weight(position: Vector3) -> float: var fork := maxf(0.0, 1.0 - here.distance_to(branch) / 8.0) * 0.45 return maxf(core, fork) * (1.0 - smoothstep(0.45, 0.95, fposmod(step, 1.0))) if name == "depth_chase": - var phrase := floori(elapsed / 16.0) - var current := variants[posmod(phrase, variants.size())] - var previous := variants[posmod(phrase - 1, variants.size())] if phrase > 0 else current + # One chase that develops: it starts as an aligned wave, grows into its own variant for the middle of the + # passage (staggered columns, an echo or breathing columns, one after the other), and returns to the wave. + var part := floori(elapsed / 16.0) + var last := maxi(0, ceili(duration / 16.0) - 1) + var current := _variant(part, last) + var previous := _variant(part - 1, last) if part > 0 else current return lerpf(_chase(position, previous), _chase(position, current), smoothstep(0.0, 2.0, fposmod(elapsed, 16.0))) return 0.0 +func _variant(part: int, last: int) -> int: + if part <= 0 or part >= last: return 0 + return variants[1] if part * 2 <= last else variants[2] # the passage's own development: one variant, then a second + func apply(rig: Rig, master: float, groups: Dictionary, _gentle_mode: bool) -> void: if mix <= 0.0 or name == "": return for f in rig.fixtures: diff --git a/scripts/show/looks.gd b/scripts/show/looks.gd index 9f27528..5e29e1b 100644 --- a/scripts/show/looks.gd +++ b/scripts/show/looks.gd @@ -383,5 +383,6 @@ static func row_aim(name: String, h: Head, p: Vector3, k: Dictionary) -> Vector3 "pairs": return Vector3(p.x + (6.0 if h.slot % 2 else -6.0), ROW_UP, p.z + 5.0) "chase": return Vector3(p.x, ROW_UP, p.z + 3.0) "sunburst": return Vector3(p.x * 3.5, ROW_UP * 0.8, p.z + 9.0) - "crowd": return Vector3(p.x * 1.4 + sin(k.m * 0.4) * 4.0, 4.6, p.z + 24.0) + # a wide fan rising over the heads: aimed low and long, the row piles up on the horizon from the DJ's eyes + "crowd": return Vector3(p.x * 2.2 + sin(k.m * 0.4) * 3.0, 9.0, p.z + 16.0) return Vector3(p.x, ROW_UP, p.z + 2.5) diff --git a/scripts/show/passage.gd b/scripts/show/passage.gd new file mode 100644 index 0000000..daf0bd2 --- /dev/null +++ b/scripts/show/passage.gd @@ -0,0 +1,214 @@ +class_name ShowPassage +extends RefCounted +# A passage is one spatial idea held for a musical stretch of 16 or 32 bars. One group leads it, others support it, +# the rest hold a quiet figure or make room. It begins, develops and ends; the director changes scenes, room +# compositions and bar figures only when a passage changes, so the layers move together instead of on their own clocks. +# +# Stage figures (the towers behind the DJ, the stage roof's spots, the delay towers) are designed to read on their +# own, with the hall beams and the stage row switched off and the washes low. + +# name: [lead group, support groups] +const FIGURES := { + "crossing_planes": ["back", ["roof"]], # two fans from the towers cross slowly over the crowd + "held_rake": ["back", ["delay"]], # an asymmetric rake, held, drifting a little + "opening_columns": ["back", ["roof"]], # upright columns that slowly open in width, then lean down into the hall + "roof_cross": ["roof", ["back", "bars"]], # the roof spots weave: neighbours lean opposite ways, reaching out into the hall + "roof_fan": ["roof", ["back"]], # a mirrored fan from the roof that widens and reaches further + "overhead": ["hall", ["back"]], # the hall's own looks lead; the stage holds a quiet figure +} +const LEVEL := {"lead": 1.0, "support": 0.6, "hold": 0.3} +# How much of the kick a role may show: the lead breathes with it, the rest barely. +const PULSE := {"lead": 0.75, "support": 0.3, "hold": 0.08} + +var index := -1 # counts passages; the director keys its scene changes on it +var figure := "overhead" +var mirror := 1.0 # which way an asymmetric figure leans +var started := 0.0 # in beats +var length := 128.0 # in beats +var progress := 0.0 +var _identity := "" +var _rng := RandomNumberGenerator.new() +var _recent: Array = [] # figure, lean and roles: a colour change is not a new passage +var _section := "" +var _change_at := -1.0 +var _hall_on := true +var breath := 0.0 # the bass: the figure widens with it +var stepped := false # techno: heads stand still and change position only in steps (four per passage), in the dark +var route := "across" # the passage's rhythm: how the lead's light travels from fixture to fixture on the beat +const ROUTES := ["across", "centre", "ladder", "alternate", "pingpong"] +var _open := 0.0 +var _grid := 0.0 # a new layer (a lead) came in: the figure opens up, then settles + +func open() -> void: + _open = 1.0 + +# Called every frame on the beat grid. Returns true when a new passage starts. +func update(grid: float, section: String, dropped: bool, faders: Dictionary, identity: String) -> bool: + _hall_on = float(faders.get("hall", 1.0)) > 0.3 + if identity != _identity or grid < started - 8.0: + _identity = identity + _rng.seed = hash(identity + ":passage") + _recent.clear() + index = -1 + _section = section + _change_at = -1.0 + _start(floorf(grid / 16.0) * 16.0, section) + return true + if section == "break": + _section = section + return false + var due := grid >= started + length + # a new part of the track moves on at the next four-bar line, not mid-bar; a drop moves on at once + if section != _section: + _section = section + _change_at = floorf(grid / 16.0) * 16.0 + 16.0 if grid - started >= 32.0 else -1.0 + if _change_at >= 0.0 and grid >= _change_at: due = true + if dropped: due = true + if due: + _start(floorf(grid / 4.0) * 4.0, section) + return true + progress = clampf((grid - started) / length, 0.0, 1.0) + _open *= exp(-maxf(0.0, grid - _grid) / 16.0) # settles over about four bars + _grid = grid + return false + +func _start(at: float, section: String) -> void: + index += 1 + started = at + progress = 0.0 + _change_at = -1.0 + var quiet := section in ["intro", "low", "outro"] + length = 128.0 if quiet else [128.0, 128.0, 64.0][_rng.randi_range(0, 2)] + var pool: Array = FIGURES.keys() + if not _hall_on: pool.erase("overhead") # the hall is off on the desk: the stage carries every passage + var options := [] + for name in pool: + for lean in [-1.0, 1.0]: + var key := "%s:%d" % [name, lean] + if not key in _recent and not (not _recent.is_empty() and _recent[0].begins_with(name + ":")): options.append(key) + if options.is_empty(): options = ["%s:1" % pool[0]] + var pick: String = options[_rng.randi_range(0, options.size() - 1)] + figure = pick.get_slice(":", 0) + mirror = float(pick.get_slice(":", 1)) + route = ROUTES[_rng.randi_range(0, ROUTES.size() - 1)] + _recent.push_front(pick) + if _recent.size() > 4: _recent.resize(4) + +# lead, support or hold, for a fixture group ("bars" for the battens) +func role(group: String) -> String: + var def: Array = FIGURES[figure] + if group == def[0]: return "lead" + if group in def[1]: return "support" + if figure == "overhead" and group in ["rear", "side"]: return "support" + return "hold" + +func stage_leads() -> bool: + return figure != "overhead" + +# begin, development and end of the passage as weights 0..1 +func begin() -> float: return smoothstep(0.0, 0.18, _at()) +func develop() -> float: + if stepped: return smoothstep(0.15, 0.85, _at()) + return clampf(smoothstep(0.15, 0.85, progress) + _open * 0.35 + breath * 0.12, 0.0, 1.0) +func ending() -> float: return smoothstep(0.88, 1.0, _at()) + +# Where the passage is for aiming: in steps when the heads stand still, so a position holds for a quarter of it. +func _at() -> float: + return (floorf(progress * 4.0) + 0.5) / 4.0 if stepped else progress + +# The rhythm of the lead: its light travels between fixtures on the beat (quarters, eighths late in a peak). +# The passage opens with everything on, plays the route, and closes with everything on again. +func chase(group: String, row: int, u: float, grid: float, section: String) -> float: + if progress < 0.125 or progress >= 0.875: return 1.0 + var rate := 2.0 if section == "peak" and progress >= 0.5 else 1.0 + var step := floori(grid * rate) + if mirror < 0.0: step = -step + var station := clampi(floori(u * 8.0), 0, 7) + var on := false + match route: + "across": on = posmod(station - step, 4) == 0 # two lights travel across, half a rig apart + "centre": on = mini(3, floori(absf(u - 0.5) * 8.0)) == posmod(step, 4) # rings from the middle out + "ladder": on = (row if group == "back" else mini(3, floori(u * 4.0))) == posmod(step, 4) # bottom to top + "alternate": on = posmod(station + step, 2) == 0 # odd and even trade places + "pingpong": + var at := posmod(step, 14) + on = station == (at if at < 8 else 14 - at) + if not on: return 0.04 + return 1.0 - smoothstep(0.6, 0.98, fposmod(grid * rate, 1.0)) * 0.7 + +func describe() -> String: + var def: Array = FIGURES[figure] + return "%s (%s leads, %s, %d%%)" % [figure.replace("_", " "), def[0], route, roundi(progress * 100.0)] + +# Where a stage head aims in this passage, as a point in the hall. m is the show's slow motion clock. +# Returns null-like Vector3.INF when the figure leaves the head to the director's own program. +func stage_target(group: String, row: int, mount: Vector3, xn: float, u: float, m: float) -> Vector3: + var d := develop() + var e := ending() + var b := begin() + var side := -1.0 if mount.x < 0.0 else 1.0 + var drift := sin(m * 0.18 + u * 0.9) * 0.8 + match group: + "back": + if row == 3: return _back_spot(mount, u, d, m) + var height := 6.0 + row * 3.0 # above the crowd: from the DJ's eyes low beams pile up on the horizon + match figure: + "crossing_planes": + # each side's fan swings slowly across the centre line; the two planes meet and pass + var swing := sin(_at() * TAU * 1.0 + (0.0 if side < 0 else PI)) * lerpf(3.0, 9.0, d * b) * (1.0 - e * 0.6) + # every tower keeps its own lane (at least the towers' spacing), so the planes cross without bundling + return Vector3(_lane(mount.x * 1.6 + swing), height + d * 1.5, 26.0) + "held_rake": + # all lean one way, rising across the rig: an asymmetric figure that holds + var lean := mirror * lerpf(4.0, 13.0, d) * (1.0 - e * 0.5) + return Vector3(_lane(mount.x * 1.4 + lean + drift), 5.0 + u * 5.0 + row * 2.0, 22.0 - u * 6.0) + "opening_columns": + # upright, then wider, then leaning down into the hall + var wide := lerpf(1.1, 2.0, smoothstep(0.1, 0.55, _at())) + var reach := smoothstep(0.45, 0.95, _at()) * (1.0 - e * 0.5) + return Vector3(_lane(mount.x * wide), lerpf(14.0, 7.0 + row * 2.5, reach), lerpf(mount.z + 9.0 + row * 2.0, 24.0, reach)) + _: + # support/hold: a calm, slightly opening fan towards the back of the hall, above the crowd + return Vector3(_lane(mount.x * 1.6 + drift), 7.0 + row * 2.5 + sin(m * 0.12 + u * 2.0) * 0.8, 30.0) + "roof": + match figure: + "roof_cross": + # a weave: neighbours lean opposite ways, so the crossings spread over the whole width instead of one knot; + # the weave reaches from the stage out into the hall and back + var lean := (1.0 if roundi(u * 11.0) % 2 == 0 else -1.0) * mirror * lerpf(2.5, 6.0, d) + var z := lerpf(-24.0, 4.0, d) * (1.0 - e) + -24.0 * e + return Vector3(mount.x * 1.15 + lean, 0.0, z) + "roof_fan": + var width := lerpf(10.0, 22.0, d) * (1.0 - e * 0.4) + return Vector3(xn * width + mirror * d * 3.0, 0.0, lerpf(-24.0, 14.0, d * (1.0 - e * 0.6)) - absf(xn) * 4.0) + _: + # support/hold: on the deck and the front of the crowd, a slow mirrored breath + return Vector3(xn * (13.0 + 2.0 * sin(m * 0.15)), 0.0, -24.0 + absf(xn) * 6.0 + 2.0 * sin(m * 0.1 + xn)) + "delay": + # the four beams of a tower fan out, each to its own place + var fan := (u - 0.5) * 10.0 + if figure == "held_rake": + return Vector3(mount.x * 0.8 + fan + mirror * 5.0 * d, 14.0, mount.z - 14.0) # leaning towards the stage with the rake + return Vector3(mount.x + fan, 14.5, mount.z - 6.0 + (u - 0.5) * 8.0) # nearly upright, quiet + "rear": + # from the back of the hall towards the stage, every beam in its own lane, above the crowd + return Vector3(_lane(mount.x * 1.3 + mirror * d * 4.0), 6.0 + 2.0 * sin(m * 0.1 + u * 3.0), -18.0) + "side": + # straight across, slightly rising, staggered by height: parallel lines, no knot + return Vector3(-side * 6.0, mount.y + 1.5, mount.z + (u - 0.5) * 4.0 + mirror * d * 3.0) + return Vector3.INF + +# Across the hall without piling up at a wall: a soft limit keeps every head's order and some of its spacing. +static func _lane(x: float) -> float: + return 23.0 * tanh(x / 23.0) + +# The spot on each back tower: a slow, high answer to the beams, never into the booth. +func _back_spot(mount: Vector3, u: float, d: float, m: float) -> Vector3: + var lean := mirror * (u - 0.5) * 2.0 + return Vector3(_lane(mount.x + lean * 8.0 * d), 12.0 + sin(m * 0.1 + u * PI) * 1.0, 18.0) + +# Whether a stage head is part of the figure: making room for a sightline is a choice, not a flash. +func stage_on(group: String, row: int) -> bool: + if figure == "roof_fan" and group == "back": return row >= 2 # only the upper heads support; the booth stays clear + if figure == "roof_cross" and group == "back": return row != 0 + return true diff --git a/scripts/show/passage.gd.uid b/scripts/show/passage.gd.uid new file mode 100644 index 0000000..6afc80c --- /dev/null +++ b/scripts/show/passage.gd.uid @@ -0,0 +1 @@ +uid://bu0ywegpegpvu diff --git a/scripts/show/phrasing.gd b/scripts/show/phrasing.gd index b3186cc..83fa02e 100644 --- a/scripts/show/phrasing.gd +++ b/scripts/show/phrasing.gd @@ -16,8 +16,11 @@ var _recent: Array = [] var _period := 16.0 var _offset := 0.0 var _direction := 1.0 +var _follow := -1 -func update(dt: float, clock: float, section: String, build: float, drop: float, flowing: bool, gentle: bool, identity := "show") -> void: +# follow: the director's passage; when given, the composition changes with it instead of on its own clock. +# travel: whether travelling waves may play (only while the hall leads). +func update(dt: float, clock: float, section: String, build: float, drop: float, flowing: bool, gentle: bool, identity := "show", follow := -1, travel := true) -> void: if identity != _identity or clock < grid - 4.0: _identity = identity _rng.seed = hash(identity) @@ -25,7 +28,9 @@ func update(dt: float, clock: float, section: String, build: float, drop: float, _recent.clear() grid = clock var quiet := section in ["intro", "low", "break", "outro"] - if clock >= next_at: + var due := clock >= next_at if follow < 0 else follow != _follow + _follow = follow + if due: var pool: Array = [0, 1, 5, 7] if quiet or flowing else range(NAMES.size()) var fresh := pool.filter(func(i): return i != cue and not i in _recent) if fresh.is_empty(): fresh = pool.filter(func(i): return i != cue) @@ -41,7 +46,7 @@ func update(dt: float, clock: float, section: String, build: float, drop: float, if quiet: target = target.lerp(Vector3(0.4, 0.35, 1.0), clampf(build, 0.0, 1.0)) var opening := clampf(drop * 2.0, 0.0, 1.0) banks = banks.lerp(target, 1.0 - exp(-dt / 2.0)).lerp(Vector3.ONE, opening) - var active := not quiet and not flowing and not gentle + var active := not quiet and not flowing and not gentle and travel wave = lerpf(wave, 1.0 if active and cue in [2, 3, 6, 8] else 0.0, 1.0 - exp(-dt / 2.0)) stereo = lerpf(stereo, 1.0 if active and cue in [3, 5, 8] else 0.0, 1.0 - exp(-dt / 2.0)) diff --git a/tools/measure_show.gd b/tools/measure_show.gd new file mode 100644 index 0000000..c8d36d1 --- /dev/null +++ b/tools/measure_show.gd @@ -0,0 +1,155 @@ +extends SceneTree +# Measure a long show without rendering: a set of three generated tracks (about sixteen minutes), played with given +# light-desk levels. Reports how often looks change, how much changes at once, how often the total light jumps with +# the strobes off, how lit the stage's own heads are, and how soon a figure returns. +# Works on any version with the same Show API, so two versions can be compared on the same music and settings. +# godot --headless --path . --script tools/measure_show.gd -- --genre=techno-raw-deep-hypnotic --desk=low --output=res://local/m.json + +var options := {} + +func _initialize() -> void: + for arg in OS.get_cmdline_user_args(): + var pair := arg.trim_prefix("--").split("=", true, 1) + options[pair[0]] = pair[1] if pair.size() > 1 else "1" + call_deferred("_run") + +func _section(bar: float) -> String: + if bar < 16.0: return "intro" + if bar < 64.0: return "groove" + if bar < 80.0: return "break" + if bar < 144.0: return "peak" + return "groove" if bar < 160.0 else "outro" + +func _run() -> void: + for name in ["sv_beat", "sv_energy", "sv_hands", "sv_haze", "sv_phones"]: + RenderingServer.global_shader_parameter_add(name, RenderingServer.GLOBAL_VAR_TYPE_FLOAT, 0.0) + var rig := Rig.new() + root.add_child(rig) + rig.build() + var hall := Hall.new() + hall.stage_screen = Node3D.new() + hall.add_child(hall.stage_screen) + var listen := Listen.new() + var rb := Rekordbox.new() + var genre: String = options.get("genre", "techno-raw-deep-hypnotic") + seed(37) + var show := Show.new(rig, hall, {"genre": genre, "seed": 37}) + if options.get("desk", "low") == "low": + show.tune.groups = {"front": 0.0, "hall": 0.0, "wash": 0.15, "back": 1.0, "roof": 1.0, "side": 1.0, "delay": 1.0, "batten": 1.0, "rear": 1.0} + if options.get("strobe", "off") == "off": show.tune.strobe = 0.0 + var dt := 0.05 + var bpm := 128.0 + var track_bars := 176.0 + var frames := int(3.0 * track_bars * 4.0 * 60.0 / bpm / dt) + var previous := "" + var keys := {"look": "", "stack": "", "program": "", "room": ""} + var changes := {"look": 0, "stack": 0, "program": 0, "room": 0} + var together := 0 + var held_since := 0.0 + var holds: Array = [] + var looks_seen: Array = [] + var returns := 0 + var jumps := 0 + var jump_frames := 0 + var last_total := -1.0 + var stage_sum := 0.0 + var stage_n := 0 + var lit_sum := 0.0 + var played := 0.0 + for n in frames: + var t := n * dt + var beat_all := t * bpm / 60.0 + var track := floori(beat_all / (track_bars * 4.0)) + var b := fposmod(beat_all, track_bars * 4.0) + var bar := b / 4.0 + var sec := _section(bar) + var breaking := sec == "break" + listen.beat = b + listen.bpm = bpm + listen.latency = 0.0 + listen.source = "fixture" + listen.title = "Measured track %d" % track + listen.section = sec + listen.phase = "break" if breaking else "groove" + listen.build = clampf((bar - 72.0) / 8.0, 0.0, 1.0) if breaking else 0.0 + listen.ev_drop = previous == "break" and sec == "peak" + listen.track_changed = n == 0 or floori((beat_all - dt * bpm / 60.0) / (track_bars * 4.0)) != track + listen.energy = 0.3 if breaking else 0.55 if sec in ["intro", "outro"] else 0.85 + listen.since_kick = fposmod(b, 1.0) if not breaking else 10.0 + listen.kick = not breaking and int(floorf(b)) != int(floorf(b - dt * bpm / 60.0)) + previous = sec + if "parts" in listen and not breaking: + # a groove: claps on two and four, hats on the off-beats, a lead that comes in at bar 32 + var parts = listen.parts + parts.clear_events() + for i in 6: parts.hit[i] *= exp(-dt * 14.0) + var step := int(floorf(b * 2.0)) + if step != int(floorf((b - dt * bpm / 60.0) * 2.0)): + if step % 2 == 1: + parts.hit[5] = 0.8 + parts.hits[5] += 1 + elif (step / 2) % 2 == 1: + parts.hit[4] = 1.0 + parts.hits[4] += 1 + parts.present[4] = 0.8 + parts.present[5] = 0.8 + parts.present[1] = 0.8 + parts.level[1] = 0.6 + 0.3 * exp(-fposmod(b, 1.0) * 6.0) + if absf(bar - 32.0) < dt * bpm / 240.0: parts.came_in[3] = true + show.update(dt, listen, rb) + if t < 20.0: continue + var now := {"look": show.head_name, "stack": show.stack_name, "program": show.PROGRAMS[show._program].name, "room": str(show.phrasing.cue)} + var changed := 0 + for k in keys: + if keys[k] != "" and keys[k] != now[k]: + changes[k] += 1 + changed += 1 + keys[k] = now[k] + if changed >= 3: together += 1 + if changed > 0 and now.look != "": + if not breaking: holds.append(t - held_since) + held_since = t + if looks_seen.is_empty() or looks_seen[0] != now.look: + if now.look in looks_seen.slice(1, 4): returns += 1 + looks_seen.push_front(now.look) + # the light that reaches the eye, without the strobe channels + var total := 0.0 + var stage := 0.0 + var stage_count := 0 + for f in rig.fixtures: + total += f.level + if f.kind != Rig.WASH and f.group in ["back", "roof", "delay"]: + stage += f.level + stage_count += 1 + for bt in rig.battens: total += bt.level + if sec in ["groove", "peak"] and show._drop_beat < 0.0 or (show._drop_beat >= 0.0 and b - show._drop_beat > 16.0 and sec in ["groove", "peak"]): + if last_total >= 0.0: + jump_frames += 1 + if absf(total - last_total) / maxf(maxf(total, last_total), 4.0) > 0.2: jumps += 1 + stage_sum += stage / maxf(1.0, stage_count) + stage_n += 1 + lit_sum += total + played += dt + last_total = total + holds.sort() + var minutes := (frames * dt - 20.0) / 60.0 + var result := { + "genre": genre, "desk": options.get("desk", "low"), "minutes": snappedf(minutes, 0.1), + "look_changes_per_min": snappedf(changes.look / minutes, 0.01), "stack_changes_per_min": snappedf(changes.stack / minutes, 0.01), + "program_changes_per_min": snappedf(changes.program / minutes, 0.01), "room_changes_per_min": snappedf(changes.room / minutes, 0.01), + "three_or_more_at_once": together, "median_hold_s": snappedf(holds[holds.size() / 2] if not holds.is_empty() else 0.0, 0.1), + "quick_returns": returns, "distinct_looks": looks_seen.reduce(func(acc, x): return acc if x in acc else acc + [x], []).size(), + "light_jumps_per_min": snappedf(jumps / maxf(0.01, played / 60.0), 0.1), "stage_head_mean": snappedf(stage_sum / maxf(1.0, stage_n), 0.001), + "total_light_mean": snappedf(lit_sum / maxf(1.0, stage_n), 0.01), + } + print(JSON.stringify(result)) + var path: String = options.get("output", "") + if path != "": + var file := FileAccess.open(path, FileAccess.WRITE) + file.store_string(JSON.stringify(result, " ")) + file.close() + rig.free() + hall.free() + listen.free() + rb.free() + quit() diff --git a/tools/measure_show.gd.uid b/tools/measure_show.gd.uid new file mode 100644 index 0000000..71e6717 --- /dev/null +++ b/tools/measure_show.gd.uid @@ -0,0 +1 @@ +uid://b7sgsf761f4kh diff --git a/tools/parts_probe.gd b/tools/parts_probe.gd new file mode 100644 index 0000000..572aa57 --- /dev/null +++ b/tools/parts_probe.gd @@ -0,0 +1,37 @@ +extends SceneTree +# What the six parts hear in a WAV: hits per minute, how present each is, and when layers come in. +# godot --headless --path . --script tools/parts_probe.gd -- file.wav +const ListenNode = preload("res://scripts/listen.gd") +const Wav = preload("res://scripts/listen_wav.gd") + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var path: String = OS.get_cmdline_user_args()[0] if not OS.get_cmdline_user_args().is_empty() else "" + var wav = Wav.new() + if path == "" or not wav.load_file(path): + printerr("Usage: parts_probe.gd -- file.wav") + quit(1) + return + var listen: Node = ListenNode.new() + root.add_child(listen) + listen.start_samples(wav.rate) + var chunk: int = int(wav.rate / 60.0) + var present := PackedFloat32Array([0, 0, 0, 0, 0, 0]) + var layers := [] + var frames := 0 + for first in range(0, wav.frame_count(), chunk): + listen.begin_frame() + listen.push_samples(wav.samples(first, chunk)) + frames += 1 + for i in 6: + present[i] += listen.parts.present[i] + if listen.parts.came_in[i]: layers.append("%s@%ds" % [ListenParts.NAMES[i], roundi(first / wav.rate)]) + var minutes: float = wav.frame_count() / wav.rate / 60.0 + var out := {} + for i in 6: + out[ListenParts.NAMES[i]] = {"hits_per_min": roundi(listen.parts.hits[i] / minutes), "present": snappedf(present[i] / frames, 0.01)} + print(JSON.stringify({"parts": out, "layers": layers, "bpm": listen.bpm})) + listen.free() + quit() diff --git a/tools/parts_probe.gd.uid b/tools/parts_probe.gd.uid new file mode 100644 index 0000000..a11a14b --- /dev/null +++ b/tools/parts_probe.gd.uid @@ -0,0 +1 @@ +uid://ch71sesntu6rc diff --git a/tools/test_choreography.gd b/tools/test_choreography.gd index 0233670..8caf1f5 100644 --- a/tools/test_choreography.gd +++ b/tools/test_choreography.gd @@ -111,12 +111,12 @@ func _run() -> void: if result.mean_lit < 0.3 or result.looks < 3 or result.longest_rest_beats > 4.0: printerr("Presence/variation contract failed: ", genre) failed = true - # Raw / Deep / Hypnotic is the one genre asked to vary more: many head looks, three or more colours at once. - if genre == raw_genre and (result.head_looks < 8 or result.most_hues < 3): + # Raw / Deep / Hypnotic varies by passage (16 or 32 bars): several head looks in four minutes, three or more colours at once. + if genre == raw_genre and (result.head_looks < 5 or result.most_hues < 3): printerr("Raw / Deep / Hypnotic variation contract failed: ", JSON.stringify(result)) failed = true # ...and its looks are combined with other movements, gates, colour spreads and beams, with washes that are not a room lamp. - if genre == raw_genre and (result.combos < 14 or result.wash_mean > 0.75 * results["techno-peak-time-driving"].wash_mean): + if genre == raw_genre and (result.combos < result.head_looks or result.wash_mean > 0.75 * results["techno-peak-time-driving"].wash_mean): printerr("Raw / Deep / Hypnotic stack or wash contract failed: ", JSON.stringify(result)) failed = true # ...played by the light sequencer: blocks of several lengths, and the lanes pulse on the grid.