Commit71d32abaRecorded24 Sep 2026Repositoryharkfell
A3: the sound of the gate and the creatures
Message
Finding tones are placed from the world's pickups, one per unfound harmonic; a mute ring-stone hums its harmonic's note; the world's news plays as cues (the pickup's rising partials, the stone's ring, the near miss, the stone sinking, the creatures' calls, silent during a moment). Every region's bed takes the carry through one function, so H2 sounds in every bed once carried. Each voice opening is its own mixer source, and a gate cue not yet rendered plays late, not never.
Changed
assets/tunes/chirp-a.cts | 11 +++++
assets/tunes/chirp-b.cts | 14 ++++++
assets/tunes/croak-a.cts | 10 +++++
assets/tunes/croak-b.cts | 10 +++++
assets/tunes/croak-h2.cts | 10 +++++
assets/tunes/flurry.cts | 15 +++++++
assets/tunes/miss-h2.cts | 10 +++++
assets/tunes/moth-a.cts | 9 ++++
assets/tunes/moth-b.cts | 9 ++++
assets/tunes/moth-h2.cts | 10 +++++
assets/tunes/pickup-h2.cts | 14 ++++++
assets/tunes/ring-h2.cts | 12 +++++
assets/tunes/song-h2.cts | 18 ++++++++
assets/tunes/stone-slide.cts | 10 +++++
src/harkfell/audio.sgl | 45 +++++++++++++++++--
src/harkfell/sound.sgl | 341 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
test/fixtures/worlds/two/regions/testfen.map | 7 +++
test/fixtures/worlds/two/regions/testhollow.map | 7 +++
test/fixtures/worlds/two/rooms/testfen/x0y0.room | 19 ++++++++
test/fixtures/worlds/two/rooms/testhollow/x2y0.room | 18 ++++++++
test/fixtures/worlds/two/world.sgl | 29 ++++++++++++
test/test-place.sgl | 63 ++++++++++++++++++++------
test/test-regions.sgl | 111 ++++++++++++++++++++++++++++++++++++++++++++++
test/test-tunes.sgl | 29 ++++++++++++
24 files changed, 749 insertions(+), 82 deletions(-)Diff
assets/tunes/chirp-a.ctsadded
@@ -0,0 +1,11 @@
+1
(tune version: 1 name: "chirp-a" tempo: 120 speed: 2 channels: 1+2
(bus reverb: zitarev size: 0.6 damp: 0.4 mix: 0.25)+3
(instruments+4
(instrument id: 1 name: "reedling" patch: (flute-wind attack: 0.008 decay: 0.05 sustain: 0.5 release: 0.05 noise-amp: 0.08) volume: 30 send: 0.3))+5
(patterns+6
(pattern id: 0 rows: 8+7
(row 0 (1 "G-6" 1 28 "000"))+8
(row 1 (1 "B-6" 1 30 "000"))+9
(row 2 (1 "===" 0 0 "000"))))+10
(order 0)+11
(history ("claude" "2026-09-24" "A3 call: a reedling's tsip, two quick notes up")))assets/tunes/chirp-b.ctsadded
@@ -0,0 +1,14 @@
+1
(tune version: 1 name: "chirp-b" tempo: 120 speed: 2 channels: 1+2
(bus reverb: zitarev size: 0.6 damp: 0.4 mix: 0.25)+3
(instruments+4
(instrument id: 1 name: "reedling" patch: (flute-wind attack: 0.008 decay: 0.05 sustain: 0.5 release: 0.05 noise-amp: 0.08) volume: 30 send: 0.3))+5
(patterns+6
(pattern id: 0 rows: 8+7
(row 0 (1 "C-7" 1 26 "000"))+8
(row 1 (1 "===" 0 0 "000"))+9
(row 2 (1 "A-6" 1 28 "000"))+10
(row 3 (1 "===" 0 0 "000"))+11
(row 4 (1 "A-6" 1 22 "000"))+12
(row 5 (1 "===" 0 0 "000"))))+13
(order 0)+14
(history ("claude" "2026-09-24" "A3 call: a reedling's three-note twitter, falling")))assets/tunes/croak-a.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "croak-a" tempo: 120 speed: 3 channels: 1+2
(bus reverb: zitarev size: 0.5 damp: 0.5 mix: 0.2)+3
(instruments+4
(instrument id: 1 name: "bellfrog" patch: (karplus-strong decay: 0.9 excite-gain: 0.6) volume: 40 send: 0.3))+5
(patterns+6
(pattern id: 0 rows: 12+7
(row 0 (1 "F-3" 1 40 "000"))+8
(row 3 (1 "C-3" 1 34 "000"))))+9
(order 0)+10
(history ("claude" "2026-09-24" "A3 call: a bellfrog's two-note croak, falling a fourth (off the harmonic)")))assets/tunes/croak-b.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "croak-b" tempo: 120 speed: 3 channels: 1+2
(bus reverb: zitarev size: 0.5 damp: 0.5 mix: 0.2)+3
(instruments+4
(instrument id: 1 name: "bellfrog" patch: (karplus-strong decay: 0.9 excite-gain: 0.6) volume: 40 send: 0.3))+5
(patterns+6
(pattern id: 0 rows: 12+7
(row 0 (1 "E-3" 1 38 "000"))+8
(row 3 (1 "B-2" 1 34 "000"))))+9
(order 0)+10
(history ("claude" "2026-09-24" "A3 call: a second bellfrog voice, a step lower")))assets/tunes/croak-h2.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "croak-h2" tempo: 120 speed: 3 channels: 1+2
(bus reverb: zitarev size: 0.5 damp: 0.5 mix: 0.2)+3
(instruments+4
(instrument id: 1 name: "bellfrog" patch: (karplus-strong decay: 0.9 excite-gain: 0.6) volume: 40 send: 0.3))+5
(patterns+6
(pattern id: 0 rows: 12+7
(row 0 (1 "A-3" 1 40 "000"))+8
(row 3 (1 "D-3" 1 34 "000"))))+9
(order 0)+10
(history ("claude" "2026-09-24" "A3 call: carrying H2, the bellfrogs croak in its key, A3 down to D3 (A2's spot-frog)")))assets/tunes/flurry.ctsadded
@@ -0,0 +1,15 @@
+1
(tune version: 1 name: "flurry" tempo: 120 speed: 2 channels: 2+2
(bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.2)+3
(instruments+4
(instrument id: 1 name: "wings" patch: (noise-bed color: pink level: 0.7 centre: 2600 width: 1.6 wander: 0.2 rate: 2.0 trem: 0.9 trem-rate: 22.0 attack: 0.02 release: 0.25 seed: 11) volume: 34 send: 0.15)+5
(instrument id: 2 name: "reedling" patch: (flute-wind attack: 0.008 decay: 0.05 sustain: 0.5 release: 0.05 noise-amp: 0.08) volume: 26 send: 0.3))+6
(patterns+7
(pattern id: 0 rows: 16+8
(row 0 (1 "D-4" 1 34 "000") (2 "B-6" 2 26 "000"))+9
(row 1 (2 "===" 0 0 "000"))+10
(row 3 (2 "G-6" 2 24 "000"))+11
(row 4 (2 "===" 0 0 "000"))+12
(row 6 (1 "===" 0 0 "000") (2 "C-7" 2 20 "000"))+13
(row 7 (2 "===" 0 0 "000"))))+14
(order 0)+15
(history ("claude" "2026-09-24" "A3 call: a reedling flock lifts off: a burst of wings and alarm chirps")))assets/tunes/miss-h2.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "miss-h2" tempo: 120 speed: 3 channels: 2+2
(bus reverb: zitarev size: 0.6 damp: 0.6 mix: 0.25)+3
(instruments+4
(instrument id: 1 name: "ring-stone, off-key" patch: (bell fm-index: 1.4 fm-mod: 1.41 attack: 0.02 decay: 0.7 sustain: 0.0 release: 0.3) volume: 34 send: 0.3))+5
(patterns+6
(pattern id: 0 rows: 8+7
(row 0 (1 "D-3" 1 34 "000"))+8
(row 1 (2 "D#3" 1 30 "000"))))+9
(order 0)+10
(history ("claude" "2026-09-24" "A3 cue: the near miss: the stone's D3 against a semitone above it, beating, a moment, then silent")))assets/tunes/moth-a.ctsadded
@@ -0,0 +1,9 @@
+1
(tune version: 1 name: "moth-a" tempo: 120 speed: 3 channels: 1+2
(bus reverb: zitarev size: 0.8 damp: 0.3 mix: 0.45)+3
(instruments+4
(instrument id: 1 name: "lantern moth" patch: (bell fm-index: 0.5 fm-mod: 3.0 attack: 0.004 decay: 0.5 sustain: 0.0 release: 0.3) volume: 20 send: 0.6))+5
(patterns+6
(pattern id: 0 rows: 8+7
(row 0 (1 "E-6" 1 20 "000"))))+8
(order 0)+9
(history ("claude" "2026-09-24" "A3 call: a lantern moth's glassy tink, idle")))assets/tunes/moth-b.ctsadded
@@ -0,0 +1,9 @@
+1
(tune version: 1 name: "moth-b" tempo: 120 speed: 3 channels: 1+2
(bus reverb: zitarev size: 0.8 damp: 0.3 mix: 0.45)+3
(instruments+4
(instrument id: 1 name: "lantern moth" patch: (bell fm-index: 0.5 fm-mod: 3.0 attack: 0.004 decay: 0.5 sustain: 0.0 release: 0.3) volume: 20 send: 0.6))+5
(patterns+6
(pattern id: 0 rows: 8+7
(row 0 (1 "B-5" 1 20 "000"))))+8
(order 0)+9
(history ("claude" "2026-09-24" "A3 call: a lantern moth's glassy tink, idle, lower")))assets/tunes/moth-h2.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "moth-h2" tempo: 120 speed: 3 channels: 2+2
(bus reverb: zitarev size: 0.85 damp: 0.3 mix: 0.5)+3
(instruments+4
(instrument id: 1 name: "lantern moth, drawn" patch: (bell fm-index: 0.5 fm-mod: 2.0 attack: 0.004 decay: 0.8 sustain: 0.0 release: 0.4) volume: 22 send: 0.6))+5
(patterns+6
(pattern id: 0 rows: 8+7
(row 0 (1 "D-6" 1 22 "000"))+8
(row 1 (2 "A-6" 1 16 "000"))))+9
(order 0)+10
(history ("claude" "2026-09-24" "A3 call: moths drawn to H2 chime in its partials (D6, A6: 8 and 12 times D2)")))assets/tunes/pickup-h2.ctsadded
@@ -0,0 +1,14 @@
+1
(tune version: 1 name: "pickup-h2" tempo: 120 speed: 4 channels: 6+2
(bus reverb: zitarev size: 0.85 damp: 0.45 mix: 0.35)+3
(instruments+4
(instrument id: 1 name: "the harmonic joins you" patch: (bell fm-index: 1.2 fm-mod: 2.0 attack: 0.01 decay: 2.6 sustain: 0.0 release: 1.2) volume: 44 send: 0.5))+5
(patterns+6
(pattern id: 0 rows: 24+7
(row 0 (1 "D-3" 1 44 "000"))+8
(row 2 (2 "D-4" 1 38 "000"))+9
(row 4 (3 "A-4" 1 34 "000"))+10
(row 6 (4 "D-5" 1 30 "000"))+11
(row 8 (5 "F#5" 1 26 "000"))+12
(row 10 (6 "A-5" 1 22 "000"))))+13
(order 0)+14
(history ("claude" "2026-09-24" "A3 cue: H2 taken; its own partials rise from D3 (2, 4, 6, 8, 10, 12 times the fundamental)")))assets/tunes/ring-h2.ctsadded
@@ -0,0 +1,12 @@
+1
(tune version: 1 name: "ring-h2" tempo: 120 speed: 4 channels: 3+2
(bus reverb: zitarev size: 0.9 damp: 0.5 mix: 0.4)+3
(instruments+4
(instrument id: 1 name: "ring-stone, sounding" patch: (bell fm-index: 0.9 fm-mod: 1.41 attack: 0.25 decay: 4.5 sustain: 0.0 release: 2.0) volume: 50 send: 0.6)+5
(instrument id: 2 name: "its partials" patch: (bell fm-index: 0.6 fm-mod: 2.0 attack: 0.6 decay: 3.5 sustain: 0.0 release: 1.5) volume: 34 send: 0.6))+6
(patterns+7
(pattern id: 0 rows: 16+8
(row 0 (1 "D-3" 1 50 "000"))+9
(row 3 (2 "D-4" 2 34 "000"))+10
(row 6 (3 "A-4" 2 26 "000"))))+11
(order 0)+12
(history ("claude" "2026-09-24" "A3 cue: a ring-stone keyed to H2 rings in sympathy: D3 swelling, its octave and twelfth after")))assets/tunes/song-h2.ctsadded
@@ -0,0 +1,18 @@
+1
(tune version: 1 name: "song-h2" tempo: 120 speed: 2 channels: 1+2
(bus reverb: zitarev size: 0.75 damp: 0.4 mix: 0.3)+3
(instruments+4
(instrument id: 1 name: "reedling, singing back" patch: (flute-wind attack: 0.015 decay: 0.08 sustain: 0.6 release: 0.12 noise-amp: 0.06) volume: 32 send: 0.4))+5
(patterns+6
(pattern id: 0 rows: 32+7
(row 0 (1 "D-5" 1 26 "000"))+8
(row 3 (1 "===" 0 0 "000"))+9
(row 4 (1 "A-5" 1 30 "000"))+10
(row 6 (1 "D-6" 1 32 "000"))+11
(row 9 (1 "===" 0 0 "000"))+12
(row 10 (1 "C-6" 1 26 "000"))+13
(row 12 (1 "A-5" 1 28 "000"))+14
(row 14 (1 "===" 0 0 "000"))+15
(row 16 (1 "D-6" 1 30 "000"))+16
(row 21 (1 "===" 0 0 "000"))))+17
(order 0)+18
(history ("claude" "2026-09-24" "A3 call: reedlings sing back once you carry H2: a phrase on D in Reedfen's Dorian (D A D C A D)")))assets/tunes/stone-slide.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "stone-slide" tempo: 120 speed: 4 channels: 1+2
(bus reverb: zitarev size: 0.6 damp: 0.7 mix: 0.2)+3
(instruments+4
(instrument id: 1 name: "stone sinking" patch: (noise-bed color: brown level: 0.8 centre: 320 width: 1.0 wander: 0.4 rate: 3.0 gust: 0.5 gust-rate: 6.0 attack: 0.08 release: 0.35 seed: 7) volume: 46 send: 0.2))+5
(patterns+6
(pattern id: 0 rows: 12+7
(row 0 (1 "D-3" 1 46 "000"))+8
(row 8 (1 "===" 0 0 "000"))))+9
(order 0)+10
(history ("claude" "2026-09-24" "A3 cue: the ring-stone sinks into the floor: a low stone rumble, under a second")))src/harkfell/audio.sglmodified
@@ -25,6 +25,7 @@
25
(harkfell shell) 26
(harkfell tunes) 27
(harkfell sound)+28
(harkfell gates) 29
(harkfell bench)) 30
31
(export audio-boot! audio-world! audio-gesture! audio-bench! audio-frame! audio-now@@ -49,8 +50,13 @@
50
;; "2,3" -> (2 3) 51
(define (numbers s) (filter number? (map string->number (string-split s ",")))) 52
−52
(define (audio-carry! s) (sound-set-carry! (numbers s)))+53
;; ?carry=: the body carries these (the world's, A3; and the sound's,+54
;; for A0's room, which has no world of gates)+55
(define (audio-carry! s) (shell-set-carry! (numbers s)) (sound-set-carry! (numbers s))) 56
+57
;; ?tone= (A2's placeholder door): the finding tones are placed from the+58
;; world's pickups now; this moves A2's placeholder only, which sounds+59
;; only where the world has no gates. 60
(define (audio-tone-room! s) 61
(let ((n (numbers s))) 62
(when (= (length n) 2) (sound-set-tone-room! (car n) (cadr n)))))@@ -81,7 +87,38 @@
87
88
;; The place the director needs, from the running world; #f outside the 89
;; written world (A0's test room, the atlas, the sheet).−84
(define (world-place w ct)+90
;; A3: the world's pickups, stones and news in absolute world pixels+91
;; (shift: the map's own pixel origin, as for px py below).+92
(define (abs-news news sx sy)+93
(map (lambda (n)+94
(let ((k (length n)))+95
;; every item ends with x y+96
(append (list-head* n (- k 2))+97
(list (+ sx (list-ref n (- k 2))) (+ sy (list-ref n (- k 1)))))))+98
news))+99
+100
(define (list-head* l n) (if (or (= n 0) (null? l)) '() (cons (car l) (list-head* (cdr l) (- n 1)))))+101
+102
(define (world-place w ct . news)+103
(let* ((wm (and w (world-map w)))+104
(sx (if wm (* (worldmap-cols wm) (worldmap-size wm) (worldmap-x0 wm)) 0))+105
(sy (if wm (* (worldmap-rows wm) (worldmap-size wm) (worldmap-y0 wm)) 0))+106
(pl (world-place-a2 w ct)))+107
(and pl+108
(if (world-gates w)+109
(let ((tm (world-tiles w)))+110
(place pl+111
carry: (world-carry w)+112
tones: (map (lambda (p) (let ((c (cell-centre tm (pickup-cell p))))+113
(list (pickup-overtone p) (+ sx (car c)) (+ sy (cdr c)))))+114
(world-pickups w))+115
stones: (map (lambda (st) (let ((c (cell-centre tm (stone-cell st))))+116
(list (stone-overtone st) (stone-state st) (+ sx (car c)) (+ sy (cdr c)))))+117
(world-stones w))+118
news: (abs-news (if (pair? news) (car news) '()) sx sy)))+119
pl))))+120
+121
(define (world-place-a2 w ct) 122
(let* ((p (and w (world-room w))) 123
(rm (and p (placed-data p)))) 124
(and rm ct@@ -125,9 +162,9 @@
162
(define (audio-frame! frame-ms work-ms) 163
(let ((now (audio-now))) 164
(if (or (bench-running?) (bench-done?))−128
(when (bench-running?) (bench-frame! now frame-ms work-ms))+165
(begin (shell-take-news!) (when (bench-running?) (bench-frame! now frame-ms work-ms))) 166
(let ((dt (if *last* (min 0.25 (- now *last*)) 0.0)))−130
(sound-frame! now dt (world-place (shell-world) (shell-content)))))+167
(sound-frame! now dt (world-place (shell-world) (shell-content) (shell-take-news!))))) 168
(set! *last* now) 169
(mixer-pump! now))) 170
src/harkfell/sound.sglmodified
@@ -64,9 +64,10 @@
64
65
(export sound-boot! sound-frame! sound-preopen! sound-set-carry! sound-set-tone-room! 66
sound-ears sound-lines sound-hush sound-bed-levels sound-moment-playing sound-reset!−67
sound-set-rng! strip-uncarried sound-spots-played DRONE-MOVING+67
sound-set-rng! strip-uncarried sound-spots-played DRONE-MOVING sound-bed-notes sound-region sound-cue-tunes sound-voice-source HUM-ID 68
sound-ears-flick ears-flick? 69
place place? place-key place-region place-px place-py place-rx place-ry place-moment place-frogs place-levels+70
place-carry place-tones place-stones place-news 71
bed-channels-of group-channels 72
DUCK-GAIN DUCK-IN-S DUCK-OUT-S ROOM-RAMP-S BORDER-S FADE-S STILL-S 73
DRONE-CHANNELS DEFAULT-LEVELS BED-ID MOMENT-ID TONE-ID)@@ -105,11 +106,21 @@
106
;; px py the body's centre in world pixels; rx ry the room's coordinate 107
;; still the body is at rest this frame 108
;; returns soft returns so far+109
;; A3:+110
;; carry the harmonics carried (the world's), or #f: leave the carry+111
;; as sound-set-carry! set it (A2's tests)+112
;; tones ((overtone x y) ...): each unfound harmonic where it lies, in+113
;; absolute world pixels; #f: A2's one placeholder tone (tests)+114
;; stones ((overtone state x y) ...): the ring-stones, absolute pixels+115
;; news what happened this frame ((harkfell gates), (harkfell+116
;; creatures)), positions absolute: (pickup N x y) (ring N x y)+117
;; (sink N x y) (open N x y) (near-miss N x y) (call KIND x y) 118
(define-struct place 119
(key default: #f) (region default: #f) (bed-tune default: #f) (groups default: '()) 120
(levels default: '()) (moment default: #f) (frogs default: '()) (water default: #f) 121
(px default: 0.0) (py default: 0.0) (rx default: 0) (ry default: 0)−112
(still default: #f) (returns default: 0))+122
(still default: #f) (returns default: 0)+123
(carry default: #f) (tones default: #f) (stones default: '()) (news default: '())) 124
125
;; --- state --------------------------------------------------------------------- 126
(define *say* (lambda parts #f))@@ -126,10 +137,8 @@
137
(define *moments* '()) ; ((name . player) ...) opened ahead 138
(define *moment* #f) ; (name . player) playing 139
(define *moment-key* #f)−129
(define *tone-player* #f) 140
(define *tone-room* (cons 11 3)) ; PLACEHOLDER: east of A1's six rooms, until A3 places H2 141
(define *tone-cutoff* #f)−132
(define *pan* (cons 1.0 1.0)) ; the tone's L R gains 142
(define *still-for* 0.0) 143
(define *still* #f) 144
(define *ears* #f)@@ -141,6 +150,7 @@
150
(define *seed* 20260924) 151
152
(define (sound-hush) *hush*)+153
(define (sound-region) *region*) 154
(define (sound-moment-playing) (and *moment* (car *moment*))) 155
(define (sound-ears) *ears*) 156
@@ -180,12 +190,13 @@
190
(when *bed-player* (player-close! *bed-player*)) 191
(when *moment* (player-close! (cdr *moment*))) 192
(for-each (lambda (m) (player-close! (cdr m))) *moments*)−183
(when *tone-player* (player-close! *tone-player*))+193
(for-each (lambda (v) (player-close! (vector-ref (cdr v) 0))) *voices*)+194
(set! *voices* '()) (set! *hum-n* #f) (set! *pending-cues* '()) 195
(set! *hush* (hush-new)) (set! *key* #f) (set! *region* #f) (set! *groups* '()) 196
(set! *bed-player* #f) (set! *bed-src* #f) (set! *levels* '()) (set! *moments* '())−186
(set! *moment* #f) (set! *moment-key* #f) (set! *tone-player* #f) (set! *tone-cutoff* #f)+197
(set! *moment* #f) (set! *moment-key* #f) (set! *tone-cutoff* #f) 198
(set! *still-for* 0.0) (set! *still* #f) (set! *ears* #f) (set! *next* '()) (set! *waiting* #f)−188
(set! *spots* 0) (set! *carry-changed* #f) (set! *bed-tune* #f) (set! *room-levels* '()))+199
(set! *spots* 0) (set! *carry-changed* #f) (set! *bed-tune* #f) (set! *room-levels* '()) (set! *bed-opened* #f)) 200
201
;; --- channels and levels ------------------------------------------------------------ 202
;; groups: the region's bed-channels, e.g. (wind water reeds drone)@@ -273,10 +284,26 @@
284
(and t (guard (e (#t (*say* "sound" "error" "open" name) #f)) 285
(open-tune-player t (mixer-rate) loop: loop?))))) 286
−276
(define (open-bed name)+287
;; The harmonics a region's bed sounds: what you carry, the same in+288
;; EVERY region (section 5: "the drone is present in every region's+289
;; bed, so you hear your progress everywhere"; A3's gate).+290
(define (bed-carry region) *carry*)+291
+292
;; The bed as last opened (after strip-uncarried): for the gates.+293
(define *bed-opened* #f)+294
(define (sound-bed-notes ch)+295
(if *bed-opened*+296
(apply + (map (lambda (p) (length (filter (lambda (c) (and (eqv? (cell-channel c) ch) (number? (cell-note c))))+297
(tune-pattern-cells p))))+298
(tune-patterns *bed-opened*)))+299
0))+300
+301
(define (open-bed name region) 302
(let ((t (tune-parsed name))) 303
(and t (guard (e (#t (*say* "sound" "error" "open" name) #f))−279
(open-tune-player (strip-uncarried t *carry*) (mixer-rate) loop: #t)))))+304
(let ((opened (strip-uncarried t (bed-carry region))))+305
(set! *bed-opened* opened)+306
(open-tune-player opened (mixer-rate) loop: #t)))))) 307
308
(define (pull-of p) (lambda (out off n) (player-pull! p out off n))) 309
@@ -301,7 +328,7 @@
328
(define *outs* 0) 329
(define (open-region-bed! region groups bed-tune levels now) 330
(let* ((old-src *bed-src*) (old-player *bed-player*) (first? (not old-src))−304
(p (and bed-tune (open-bed bed-tune))))+331
(p (and bed-tune (open-bed bed-tune region)))) 332
(if (not p) 333
(*say* "sound" "error" "no-bed" (if bed-tune bed-tune "none")) 334
(begin@@ -395,14 +422,30 @@
422
;; --- listening: the tone, stillness, the ears ---------------------------------------------- 423
(define (clamp lo hi x) (max lo (min hi x))) 424
−398
(define (tone-geometry pl)−399
;; the tone's place: the centre of its room, in world pixels−400
(let* ((tx (+ (* ROOM-W (car *tone-room*)) (/ ROOM-W 2.0)))−401
(ty (+ (* ROOM-H (cdr *tone-room*)) (/ ROOM-H 2.0)))−402
(dx (/ (- tx (place-px pl)) ROOM-W))−403
(dy (/ (- ty (place-py pl)) ROOM-H)))+425
;; The unfound harmonics' places: ((overtone x y) ...), absolute world+426
;; pixels. A3: the place's (the world's pickups); without them, A2's one+427
;; placeholder, H2 at the centre of *tone-room*.+428
(define (tone-spots pl)+429
(or (place-tones pl)+430
(if (memv 2 *carry*)+431
'()+432
(list (list 2 (+ (* ROOM-W (car *tone-room*)) (/ ROOM-W 2.0))+433
(+ (* ROOM-H (cdr *tone-room*)) (/ ROOM-H 2.0)))))))+434
+435
;; (dx dy d) in rooms from the body to (x, y).+436
(define (geometry-to pl x y)+437
(let ((dx (/ (- x (place-px pl)) ROOM-W))+438
(dy (/ (- y (place-py pl)) ROOM-H))) 439
(list dx dy (sqrt (+ (* dx dx) (* dy dy)))))) 440
+441
;; The loudest unfound harmonic's geometry (the ears point to it), or #f.+442
(define (tone-geometry pl)+443
(let loop ((ts (tone-spots pl)) (best #f))+444
(if (null? ts)+445
best+446
(let ((g (geometry-to pl (cadr (car ts)) (caddr (car ts)))))+447
(loop (cdr ts) (if (or (not best) (< (caddr g) (caddr best))) g best))))))+448
449
(define (tone-gain d) 450
(cond ((> d (+ TONE-ROOMS 0.5)) 0.0) 451
(else (let ((g (* TONE-GAIN (expt 10.0 (/ (* -1.0 TONE-DB-PER-ROOM (max 0.0 (- d 0.5))) 20.0)))))@@ -422,20 +465,31 @@
465
((> dy 0.5) (* base 0.6)) 466
(else base))))) 467
−425
;; the one-pole's coefficient for a corner (1.0: no filtering), eased−426
;; toward its target each frame so a change of band is not a step−427
(define *tone-a* 1.0)−428
(define *tone-a-target* 1.0)−429
(define *lp-l* 0.0)−430
(define *lp-r* 0.0)+468
;; --- voices: the finding tones and the stone's hum (A3) ------------------------------+469
;; A VOICE is a looping tune played as a source with its own pan and+470
;; one-pole low-pass: #(player pan-l pan-r a a-target lp-l lp-r). Each+471
;; unfound harmonic has one ("tone-hN", tune tone-hN), open while you are+472
;; in its range; the nearest mute stone has one ("stone-hum", the same+473
;; tune as its harmonic's finding tone: "the same note you heard when you+474
;; were finding its harmonic", section 3).+475
(define *voices* '()) ; ((id . state) ...)+476
(define HUM-R 88.0) ; px: a mute stone hums inside this (5.5 tiles)+477
(define HUM-GAIN 0.3)+478
(define TONE-OPEN-ROOMS (+ TONE-ROOMS 0.5))+479
(define TONE-CLOSE-ROOMS (+ TONE-ROOMS 1.0))+480
(define HUM-ID "stone-hum")+481
+482
(define (tone-id n) (string-append "tone-h" (number->string n)))+483
(define (tone-id? id) (and (>= (string-length id) 6) (string=? (substring id 0 6) "tone-h")))+484
485
(define (corner->a fc) 486
(if fc (- 1.0 (exp (/ (* -2.0 3.14159265 fc) (mixer-rate)))) 1.0)) 487
−434
(define (tone-pull p)+488
(define (voice-pull st) 489
(lambda (out off n)−436
(and (player-pull! p out off n)−437
(let ((gl (car *pan*)) (gr (cdr *pan*)) (a *tone-a*))−438
(let loop ((i 0) (yl *lp-l*) (yr *lp-r*))+490
(and (player-pull! (vector-ref st 0) out off n)+491
(let ((gl (vector-ref st 1)) (gr (vector-ref st 2)) (a (vector-ref st 3)))+492
(let loop ((i 0) (yl (vector-ref st 5)) (yr (vector-ref st 6))) 493
(if (< i n) 494
(let* ((o (+ off (* 8 i))) 495
(yl (+ yl (* a (- (bytevector-ieee-single-ref out o 'little) yl))))@@ -443,36 +497,100 @@
497
(bytevector-ieee-single-set! out o (* gl yl) 'little) 498
(bytevector-ieee-single-set! out (+ o 4) (* gr yr) 'little) 499
(loop (+ i 1) yl yr))−446
(begin (set! *lp-l* yl) (set! *lp-r* yr))))+500
(begin (vector-set! st 5 yl) (vector-set! st 6 yr)))) 501
#t)))) 502
−449
(define (close-tone!)−450
(mixer-remove! TONE-ID)−451
(when *tone-player* (player-close! *tone-player*))−452
(set! *tone-player* #f))+503
;; Each opening of a voice is its own mixer source, "ID#N" (review, A3:+504
;; with one id per voice, leaving and re-entering the hum's range inside+505
;; a fade's 0.6 s let the fade's pending remove delete the NEW source,+506
;; and the hum stayed silent for good). st slot 7 holds the source id.+507
(define *voice-n* 0)+508
+509
(define (voice-src id)+510
(let ((v (assoc id *voices*)))+511
(and v (mixer-find (vector-ref (cdr v) 7))))) 512
+513
(define (voice-open! id tune now)+514
(let ((p (and (tune-text tune) (open tune #t))))+515
(when p+516
(set! *voice-n* (+ *voice-n* 1))+517
(let* ((sid (string-append id "#" (number->string *voice-n*)))+518
(st (vector p 1.0 1.0 1.0 1.0 0.0 0.0 sid))+519
(s (mixer-add! sid (voice-pull st) #t)))+520
(source-factor! s 'dist 0.0 0.0 now)+521
(when (and *still* (tone-id? id)) (source-factor! s 'still TONE-FORWARD 0.0 now))+522
(set! *voices* (cons (cons id st) (filter (lambda (v) (not (string=? (car v) id))) *voices*)))))+523
p))+524
+525
;; Fade a voice out and close it (once).+526
(define (voice-fade! id now)+527
(let ((s (voice-src id)) (v (assoc id *voices*)))+528
(when (and s v)+529
(let* ((st (cdr v)) (sid (vector-ref st 7)))+530
(source-factor! s 'dist 0.0 0.5 now)+531
(set! *voices* (filter (lambda (x) (not (eq? x v))) *voices*))+532
(mixer-at! (+ now 0.6) (lambda () (mixer-remove! sid) (player-close! (vector-ref st 0))))))))+533
+534
;; A voice's mixer source (the gates read the tone's gain), or #f.+535
(define (sound-voice-source id) (voice-src id))+536
+537
;; gain; pan -1..1 (equal power, neither side above unity); fc a low-pass+538
;; corner or #f+539
(define (voice-set! id gain pan fc now)+540
(let ((s (voice-src id)) (v (assoc id *voices*)))+541
(when (and s v)+542
(let ((st (cdr v)) (a (* 0.25 3.14159265 (+ pan 1.0))))+543
(source-factor! s 'dist gain 0.25 now)+544
(vector-set! st 1 (min 1.0 (* 1.41421356 (cos a))))+545
(vector-set! st 2 (min 1.0 (* 1.41421356 (sin a))))+546
(vector-set! st 4 (corner->a fc))+547
(vector-set! st 3 (+ (vector-ref st 3) (* 0.1 (- (vector-ref st 4) (vector-ref st 3)))))))))+548
+549
;; Every unfound harmonic's tone: open in range, placed, faded and closed+550
;; once found (review M5) or out of range. 551
(define (tone-frame! pl now)−455
(when (and (not (memv 2 *carry*)) (not *tone-player*) (tune-text TONE-TUNE))−456
(set! *tone-player* (open TONE-TUNE #t))−457
(when *tone-player*−458
(let ((s (mixer-add! TONE-ID (tone-pull *tone-player*) #t)))−459
(source-factor! s 'dist 0.0 0.0 now))))−460
(let ((s (mixer-find TONE-ID)))−461
(when s−462
(if (memv 2 *carry*)−463
;; carried: the tone fades and its player closes (review M5)−464
(unless (> (source-factor s 'dist now) 0.99)−465
(source-factor! s 'dist 0.0 0.5 now)−466
(let ((p *tone-player*))−467
(mixer-at! (+ now 0.6) (lambda () (when (eq? p *tone-player*) (close-tone!))))))−468
(let* ((g (tone-geometry pl)) (dx (car g)) (dy (cadr g)) (d (caddr g))−469
(pan (clamp (- MAX-PAN) MAX-PAN (* 0.6 dx))))−470
(source-factor! s 'dist (tone-gain d) 0.25 now)−471
;; equal power, at most MAX-PAN toward it; neither side above unity−472
(let ((a (* 0.25 3.14159265 (+ pan 1.0))))−473
(set! *pan* (cons (min 1.0 (* 1.41421356 (cos a))) (min 1.0 (* 1.41421356 (sin a))))))−474
(set! *tone-a-target* (corner->a (tone-cutoff d dy)))−475
(set! *tone-a* (+ *tone-a* (* 0.1 (- *tone-a-target* *tone-a*)))))))))+552
(let ((spots (tone-spots pl)))+553
(for-each+554
(lambda (sp)+555
(let* ((n (car sp)) (id (tone-id n))+556
(g (geometry-to pl (cadr sp) (caddr sp)))+557
(dx (car g)) (dy (cadr g)) (d (caddr g))+558
(open? (assoc id *voices*)))+559
(cond ((and (not open?) (< d TONE-OPEN-ROOMS))+560
(when (voice-open! id (tone-id n) now)+561
(voice-set! id (tone-gain d) (clamp (- MAX-PAN) MAX-PAN (* 0.6 dx)) (tone-cutoff d dy) now)))+562
((and open? (> d TONE-CLOSE-ROOMS)) (voice-fade! id now))+563
(open? (voice-set! id (tone-gain d) (clamp (- MAX-PAN) MAX-PAN (* 0.6 dx)) (tone-cutoff d dy) now))+564
(else #f))))+565
spots)+566
;; a tone whose harmonic is no longer unfound (carried): fades+567
(for-each (lambda (v)+568
(when (and (tone-id? (car v))+569
(not (find (lambda (sp) (string=? (tone-id (car sp)) (car v))) spots)))+570
(voice-fade! (car v) now)))+571
*voices*)))+572
+573
;; The nearest mute stone (shut, its harmonic not carried) within HUM-R+574
;; hums its harmonic's note, quietly, louder as you come closer.+575
(define *hum-n* #f)+576
(define (hum-frame! pl now)+577
(let* ((near (let loop ((ss (place-stones pl)) (best #f) (bd HUM-R))+578
(if (null? ss)+579
best+580
(let* ((st (car ss))+581
(d (sqrt (+ (expt (- (caddr st) (place-px pl)) 2) (expt (- (cadddr st) (place-py pl)) 2)))))+582
(if (and (eq? (cadr st) 'shut) (not (memv (car st) *carry*)) (< d bd))+583
(loop (cdr ss) (cons st d) d)+584
(loop (cdr ss) best bd))))))+585
(n (and near (car (car near)))))+586
(when (and (assoc HUM-ID *voices*) (not (eqv? n *hum-n*)))+587
(voice-fade! HUM-ID now))+588
(when near+589
(unless (assoc HUM-ID *voices*) (voice-open! HUM-ID (tone-id n) now))+590
(let* ((st (car near)) (d (cdr near))+591
(dx (/ (- (caddr st) (place-px pl)) 200.0)))+592
(voice-set! HUM-ID (* HUM-GAIN (- 1.0 (/ d HUM-R))) (clamp (- MAX-PAN) MAX-PAN dx) #f now)))+593
(set! *hum-n* n))) 594
595
(define (still-frame! pl dt now) 596
(set! *still-for* (if (place-still pl) (+ *still-for* dt) 0.0))@@ -481,8 +599,11 @@
599
(set! *still* now-still) 600
(when *bed-src* 601
(source-factor! *bed-src* 'still (if now-still DUCK-GAIN 1.0) (if now-still STILL-IN-S STILL-OUT-S) now))−484
(let ((s (mixer-find TONE-ID)))−485
(when s (source-factor! s 'still (if now-still TONE-FORWARD 1.0) (if now-still STILL-IN-S STILL-OUT-S) now)))+602
(for-each (lambda (v)+603
(when (tone-id? (car v))+604
(let ((s (voice-src (car v))))+605
(when s (source-factor! s 'still (if now-still TONE-FORWARD 1.0) (if now-still STILL-IN-S STILL-OUT-S) now)))))+606
*voices*) 607
(apply-room-levels! *room-levels* now (if now-still DRONE-UP-S DRONE-DOWN-S) 'drone))) 608
(let ((was *ears*)) 609
(set-ears! pl)@@ -491,22 +612,110 @@
612
(define (set-ears! pl) 613
(set! *ears* 614
(and *still*−494
(let* ((g (tone-geometry pl)) (dx (car g)) (dy (cadr g)) (d (caddr g)))−495
(cond ((or (memv 2 *carry*) (> d (+ TONE-ROOMS 0.5)) (< d 0.3)) 'up)+615
(let* ((g (or (tone-geometry pl) (list 0.0 0.0 1000.0))) (dx (car g)) (dy (cadr g)) (d (caddr g)))+616
(cond ((or (> d (+ TONE-ROOMS 0.5)) (< d 0.3)) 'up) 617
((>= (abs dx) (abs dy)) (if (> dx 0) 'right 'left)) 618
((< dy 0) 'up) 619
(else 'below)))))) 620
621
;; --- spot sounds --------------------------------------------------------------------------- 622
;; (name tune lo hi gain): rendered from assets/tunes/TUNE.cts at boot+623
;; A3: the bellfrogs call from (harkfell creatures) now, so frog is not a+624
;; scheduled spot sound; the cues below the first three are played by name+625
;; from the world's news.+626
;; In the order they are rendered at boot (a few blocks a frame, about 7 s+627
;; for all of them at 60 fps): the calls heard in the first rooms first,+628
;; the gate's and the H2 answers after, the ambient spots last. 629
(define SPOTS−503
'((frog "spot-frog" 5.0 14.0 0.55)−504
(bird "spot-bird" 14.0 32.0 0.35)−505
(plop "spot-plop" 7.0 18.0 0.4)−506
(return "return-tone" 0.0 0.0 0.5)))+630
(list (list 'return "return-tone" 0.0 0.0 0.5)+631
;; the creatures' calls (A3)+632
(list 'chirp-a "chirp-a" 0.0 0.0 0.35)+633
(list 'chirp-b "chirp-b" 0.0 0.0 0.35)+634
(list 'flurry "flurry" 0.0 0.0 0.45)+635
(list 'croak-a "croak-a" 0.0 0.0 0.55)+636
(list 'croak-b "croak-b" 0.0 0.0 0.55)+637
(list 'moth-a "moth-a" 0.0 0.0 0.3)+638
(list 'moth-b "moth-b" 0.0 0.0 0.3)+639
;; the gate and the pickup (A3): (name tune 0 0 gain)+640
(list 'miss-h2 "miss-h2" 0.0 0.0 0.6)+641
(list 'pickup-h2 "pickup-h2" 0.0 0.0 0.8)+642
(list 'ring-h2 "ring-h2" 0.0 0.0 0.75)+643
(list 'stone-slide "stone-slide" 0.0 0.0 0.55)+644
;; what H2 changes+645
(list 'croak-h2 "croak-h2" 0.0 0.0 0.55)+646
(list 'song-h2 "song-h2" 0.0 0.0 0.5)+647
(list 'moth-h2 "moth-h2" 0.0 0.0 0.35)+648
;; A2's spot sounds+649
(list 'bird "spot-bird" 14.0 32.0 0.35)+650
(list 'plop "spot-plop" 7.0 18.0 0.4)))+651
+652
;; The world's news as cues, panned by x from the body and quieter with+653
;; distance. A creature's call, like a spot sound, never starts during a+654
;; moment (David, 2026-09-24); the gate's and the pickup's always do:+655
;; they answer what you did.+656
(define (cue-at! name gain x pl)+657
(let* ((dx (- x (place-px pl)))+658
(g (* gain (- 1.0 (* 0.5 (min 1.0 (/ (abs dx) ROOM-W)))))))+659
(cue! name g (clamp (- MAX-PAN) MAX-PAN (/ dx 200.0)))))+660
+661
(define (spot-gain name) (let ((e (spot-ref name))) (if e (list-ref e 4) 0.5)))+662
+663
(define (harmonic-cue base n) (string->symbol (string-append base (number->string n))))+664
+665
;; The gate's cues are rare and are what A3's web probe listens for, so+666
;; each says a line (harkfell: sound cue NAME, or cue-missing: not rendered+667
;; yet or no tune).+668
;;+669
;; A gate cue not rendered yet (a door that drops you onto a harmonic+670
;; takes it on the first frame, before the boot renders have reached it:+671
;; seen by A3's web probe) jumps the render queue and plays as soon as it+672
;; is ready ("cue-late"), if that is within PENDING-S; never silently.+673
(define PENDING-S 3.0)+674
(define *pending-cues* '()) ; ((name gain pan since) ...)+675
+676
(define (gate-cue! name gain x pl)+677
(cond ((assq name *cues*)+678
(*say* "sound" "cue" (symbol->string name))+679
(cue-at! name gain x pl))+680
((spot-ref name)+681
(*say* "sound" "cue-waiting" (symbol->string name))+682
(let* ((dx (- x (place-px pl)))+683
(g (* gain (- 1.0 (* 0.5 (min 1.0 (/ (abs dx) ROOM-W)))))))+684
(set! *pending-cues* (cons (list name g (clamp (- MAX-PAN) MAX-PAN (/ dx 200.0)) *now*) *pending-cues*)))+685
(unless (and *cue-job* (eq? (vector-ref *cue-job* 0) name))+686
(set! *cue-queue* (cons name (filter (lambda (n) (not (eq? n name))) *cue-queue*)))))+687
(else (*say* "sound" "cue-missing" (symbol->string name)))))+688
+689
(define (pending-cues-frame!)+690
(set! *pending-cues*+691
(filter (lambda (p)+692
(cond ((assq (car p) *cues*)+693
(*say* "sound" "cue-late" (symbol->string (car p)))+694
(cue! (car p) (cadr p) (caddr p))+695
#f)+696
((> (- *now* (cadddr p)) PENDING-S)+697
(*say* "sound" "cue-dropped" (symbol->string (car p)))+698
#f)+699
(else #t)))+700
*pending-cues*)))+701
+702
(define (news-frame! pl)+703
(for-each+704
(lambda (item)+705
(case (car item)+706
((pickup) (let ((c (harmonic-cue "pickup-h" (cadr item)))) (gate-cue! c (spot-gain c) (caddr item) pl)))+707
((ring) (let ((c (harmonic-cue "ring-h" (cadr item)))) (gate-cue! c (spot-gain c) (caddr item) pl)))+708
((near-miss) (let ((c (harmonic-cue "miss-h" (cadr item)))) (gate-cue! c (spot-gain c) (caddr item) pl)))+709
((sink) (gate-cue! 'stone-slide (spot-gain 'stone-slide) (caddr item) pl))+710
((call) (unless *moment* (cue-at! (cadr item) (spot-gain (cadr item)) (caddr item) pl)))+711
(else #f)))+712
(place-news pl))) 713
714
(define (spot-ref name) (assq name SPOTS)) 715
+716
;; Every tune the director renders as a cue (the gates check each exists).+717
(define (sound-cue-tunes) (map cadr SPOTS))+718
719
;; Render the queued cues a few blocks a frame (review M7: a whole cue at 720
;; once, its notes and motif's 2 s release tail, was one long frame each). 721
(define CUE-BLOCK 1024)@@ -584,10 +793,6 @@
793
(reeds (room-level (place-levels pl) 'reeds)) 794
(water (room-level (place-levels pl) 'water)) 795
(bx (- (place-px pl) (* ROOM-W (place-rx pl)))))−587
(when (and (pair? frogs) (due? 'frog now 5.0 14.0))−588
(let* ((fx (list-ref frogs (min (- (length frogs) 1) (exact (floor (* (rand!) (length frogs)))))))−589
(dist (abs (- fx bx))))−590
(cue! 'frog (* 0.55 (- 1.0 (* 0.5 (min 1.0 (/ dist ROOM-W))))) (clamp (- MAX-PAN) MAX-PAN (/ (- fx bx) 200.0))))) 796
(when (and (> reeds 0.2) (due? 'bird now 14.0 32.0)) 797
(cue! 'bird (* 0.35 reeds 1.5) (rand-in (- MAX-PAN) MAX-PAN))) 798
(when (and (> water 0.3) (due? 'plop now 7.0 18.0))@@ -601,6 +806,10 @@
806
(define (sound-boot! moments say) 807
(set! *say* say) 808
(set! *to-open* moments)+809
;; a boot renders every cue afresh (a second boot, a world reloaded, starts+810
;; from nothing: the late-cue gate relies on it)+811
(when *cue-job* (player-close! (vector-ref *cue-job* 1)))+812
(set! *cue-job* #f) (set! *cues* (quote ())) (set! *pending-cues* (quote ())) 813
(set! *cue-queue* (map car SPOTS)) 814
(for-each tune-text (append moments (list TONE-TUNE) (map cadr SPOTS)))) 815
@@ -619,6 +828,7 @@
828
(define (sound-frame! now dt pl) 829
(set! *now* now) 830
(unless pl (set! *ears* #f))+831
(when (and pl (list? (place-carry pl))) (sound-set-carry! (place-carry pl))) 832
(when pl 833
(set! *hush* (hush-tick *hush* dt)) 834
(unless (moment-busy?) (boot-work!))@@ -646,6 +856,9 @@
856
(cue! 'return 0.5 0.0)) 857
(still-frame! pl dt now) 858
(tone-frame! pl now)+859
(hum-frame! pl now)+860
(news-frame! pl)+861
(pending-cues-frame!) 862
(spots-frame! pl now))) 863
864
(define (moment-busy?) (and *moment* #t))test/fixtures/worlds/two/regions/testfen.mapadded
@@ -0,0 +1,7 @@
+1
(map+2
region: testfen+3
origin: (0 0)+4
layout:+5
("A")+6
rooms:+7
((A "Fen" "the harmonic on the floor")))test/fixtures/worlds/two/regions/testhollow.mapadded
@@ -0,0 +1,7 @@
+1
(map+2
region: testhollow+3
origin: (2 0)+4
layout:+5
("A")+6
rooms:+7
((A "Hollow" "another region's bed")))test/fixtures/worlds/two/rooms/testfen/x0y0.roomadded
@@ -0,0 +1,19 @@
+1
(room+2
region: testfen+3
at: (0 0)+4
title: "Fen"+5
intent: "the harmonic on the floor"+6
bed: (wind 0.5 drone 0.6)+7
grid:+8
("#########################"+9
"# #"+10
"# #"+11
"# #"+12
"# #"+13
"# #"+14
"# #"+15
"# #"+16
"# #"+17
"# #"+18
"#########################")+19
things: ((harmonic at: (12 9) overtone: 2)))test/fixtures/worlds/two/rooms/testhollow/x2y0.roomadded
@@ -0,0 +1,18 @@
+1
(room+2
region: testhollow+3
at: (2 0)+4
title: "Hollow"+5
intent: "another region's bed"+6
bed: (wind 0.5 drone 0.6)+7
grid:+8
("#########################"+9
"# #"+10
"# #"+11
"# #"+12
"# #"+13
"# #"+14
"# #"+15
"# #"+16
"# #"+17
"# #"+18
"#########################"))test/fixtures/worlds/two/world.sgladded
@@ -0,0 +1,29 @@
+1
;;; A two-region fixture world for A3's every-region gate: a pickup of H2+2
;;; in one region (testfen, Reedfen's bed) and a room in another (testhollow,+3
;;; the Hollow's bed). The rooms are not neighbours: A1's joins do not cross+4
;;; regions yet.+5
(start region: testfen room: (0 0) cell: (2 9))+6
+7
(palette+8
(sky-1 "#8fa3b3" "sky") (stone-1 "#5d5a55" "rock") (stone-2 "#7a766f" "rock lit"))+9
+10
(region testfen+11
name: "Test fen"+12
bounds: ((0 0) (0 0))+13
bed: "reedfen-bed"+14
bed-channels: (wind water reeds drone)+15
palette: (sky-1 stone-1 stone-2)+16
roster: (frog)+17
moments: ()+18
gives: ((overtone 2)))+19
+20
(region testhollow+21
name: "Test hollow"+22
bounds: ((2 0) (2 0))+23
bed: "bed-hollow"+24
bed-channels: (wind water texture drone)+25
palette: (sky-1 stone-1 stone-2)+26
roster: (frog)+27
moments: ())+28
+29
(creature frog behaviour: hop)test/test-place.sglmodified
@@ -18,16 +18,20 @@
18
(harkfell feel) 19
(engine mixer) 20
(harkfell sound)+21
(harkfell gates) 22
(harkfell audio)) 23
24
(define CT (load-content (read-world-source "world"))) 25
−25
;; The body standing in room (x, y) at its start cell, as a world.+26
;; The body standing in room (x, y) at its start cell, as a world with its+27
;; gates (the pickups, the stones), as the shell makes it. 28
(define (world-in x y) 29
(let* ((rm (content-room CT x y)) 30
(cell (room-start-cell CT rm)))−29
(world-at (content-map CT)−30
(cell-body (worldmap-tiles (content-map CT)) HARKFELL-FEEL (car cell) (cdr cell)))))+31
(world-populate+32
(world-at (content-map CT)+33
(cell-body (worldmap-tiles (content-map CT)) HARKFELL-FEEL (car cell) (cdr cell)))+34
(content-stones CT) (content-pickups CT) '()))) 35
36
(define (inside? pl) 37
(and (<= (* 400 (place-rx pl)) (place-px pl) (* 400 (+ 1 (place-rx pl))))@@ -52,13 +56,14 @@
56
(assert-true (every (lambda (x) (< 0 x 400)) (place-frogs pl)))))) 57
58
;; Listening, end to end on the real world: the director given the adapter's−55
;; place, standing still for 3 s. H2's tone is at its placeholder room (11 3),−56
;; east of Old Stone (10 3); Fen Edge (8 3) is three rooms from it. The tone−57
;; must sound in both, louder in Old Stone, and the ears must lean east.+59
;; place, standing still for 3 s. H2's tone is where the world's pickup lies+60
;; (A3): the Drowned Channel (9 4), cell (18 7). From the Climb Back (10 4)+61
;; it is west, next door; from the Fen Edge (8 3) it is east and below, about+62
;; two rooms off. The tone must sound in both, louder in the Climb Back, and+63
;; the ears must lean west in one and east in the other. 64
(define (listen-in x y) 65
(mixer-open-headless! 32000 (lambda (id buf n gain) #f)) 66
(sound-reset!)−61
(sound-set-tone-room! 11 3) 67
(let* ((w (world-in x y)) 68
(base (world-place w CT)) 69
(pl (place base still: #t)))@@ -68,19 +73,19 @@
73
(sound-frame! now (if (= f 0) 0.0 (/ 1 30.0)) pl) 74
(mixer-pump! now) 75
(loop (+ f 1)))))−71
(let ((s (mixer-find TONE-ID)))+76
(let ((s (sound-voice-source TONE-ID))) 77
(list (sound-ears) (and s (source-gain s 3.0)))))) 78
−74
(define OLD-STONE (listen-in 10 3))+79
(define CLIMB-BACK (listen-in 10 4)) 80
(define FEN-EDGE (listen-in 8 3)) 81
82
(test-group "place: listening on the real world"−78
(test "in Old Stone, still: the ears lean east and the tone sounds"−79
(assert-equal 'right (car OLD-STONE))−80
(assert-true (> (cadr OLD-STONE) 0.2)))−81
(test "in Fen Edge, three rooms away: fainter, but there"+83
(test "in the Climb Back, still: the ears lean west and the tone sounds"+84
(assert-equal 'left (car CLIMB-BACK))+85
(assert-true (> (cadr CLIMB-BACK) 0.2)))+86
(test "in the Fen Edge, further: the ears lean east, fainter, but there" 87
(assert-equal 'right (car FEN-EDGE))−83
(assert-true (< 0.0 (cadr FEN-EDGE) (cadr OLD-STONE)))))+88
(assert-true (< 0.0 (cadr FEN-EDGE) (cadr CLIMB-BACK))))) 89
90
;; The ears' flick (David, 2026-09-24): down and back twice in the first 91
;; 0.36 s after they rise (0.09 s steps), again every 4 s, and up otherwise.@@ -95,3 +100,33 @@
100
(assert-false (ears-flick? 2.0)) 101
(assert-false (ears-flick? 3.95)) 102
(assert-true (ears-flick? 4.05))))+103
+104
;; The mute stone's hum survives leaving its range and coming straight back+105
;; (the review's finding 2: the first fade's pending remove deleted the+106
;; reopened hum's source, and it stayed silent for good).+107
(define (hum-after steps)+108
(mixer-open-headless! 32000 (lambda (id buf n gain) #f))+109
(sound-reset!)+110
(let* ((w (world-in 10 3))+111
(base (world-place w CT))+112
(st (car (place-stones base)))+113
(near (place base px: (- (caddr st) 50.0) py: (cadddr st)))+114
(far (place base px: (- (caddr st) 150.0) py: (cadddr st))))+115
(let loop ((ps steps) (f 0))+116
(if (null? ps)+117
(let ((s (sound-voice-source HUM-ID)))+118
(and s (source-gain s (/ f 30.0))))+119
(begin+120
(sound-frame! (/ f 30.0) (/ 1 30.0) (if (eq? (car ps) 'near) near far))+121
(mixer-pump! (/ f 30.0))+122
(loop (cdr ps) (+ f 1)))))))+123
+124
(define (times n x) (if (= n 0) '() (cons x (times (- n 1) x))))+125
+126
(test-group "place: the stone's hum"+127
(test "near a mute stone, it hums"+128
(assert-true (> (or (hum-after (times 60 'near)) 0.0) 0.05)))+129
(test "out of range for one frame and straight back: it still hums 3 s later"+130
(assert-true (> (or (hum-after (append (times 30 'near) (list 'far) (times 90 'near))) 0.0) 0.05)))+131
(test "far from it: silent"+132
(assert-false (let ((g (hum-after (times 60 'far)))) (and g (> g 0.01))))))test/test-regions.sgladded
@@ -0,0 +1,111 @@
+1
;;; A3's every-region gate (harkfell-design section 10, A3): carrying H2+2
;;; unmutes drone channel 4 in EVERY region's bed, not only the bed of the+3
;;; region where it was found.+4
;;;+5
;;; "Unmutes" is measured as A2's handoff asks: the notes of channel 4 are+6
;;; PRESENT in the bed as it is opened (a muted channel still synthesises, so+7
;;; the director drops the notes of an uncarried harmonic instead of muting+8
;;; them: topics/motif-muted-channel-still-synthesises).+9
;;;+10
;;; On a fixture world of two regions (test/fixtures/worlds/two): testfen,+11
;;; whose bed is Reedfen's and where H2 lies on the floor, and testhollow,+12
;;; whose bed is the Hollow's. Driven end to end: the real world ticks, the+13
;;; body takes H2 by touch, the real adapter ((harkfell audio) world-place)+14
;;; hands the director its place, and the director opens each region's bed.+15
;;; Every region is visited before the pickup (the control: no channel-4+16
;;; notes anywhere) and after it.+17
+18
(import (sigil test)+19
(sigil list)+20
(engine worldmap)+21
(engine body)+22
(engine reach)+23
(engine mixer)+24
(harkfell world)+25
(harkfell content)+26
(harkfell source)+27
(harkfell check)+28
(harkfell feel)+29
(harkfell gates)+30
(harkfell sound)+31
(harkfell audio))+32
+33
(define CT (load-content (read-world-source "test/fixtures/worlds/two")))+34
(define TM (worldmap-tiles (content-map CT)))+35
+36
(define (standing x y c r from)+37
(let* ((rm (content-room CT x y))+38
(g (local->global CT rm c r))+39
(w (world-at (content-map CT) (cell-body TM HARKFELL-FEEL (car g) (cdr g)))))+40
(if from+41
(world-populate w (world-stones from) (world-pickups from) (world-carry from))+42
(world-populate w (content-stones CT) (content-pickups CT) '()))))+43
+44
;; The director in the world w for secs seconds; the notes of channel 4 in+45
;; the bed it has open at the end, and the region it thinks it is in.+46
(define *t* 0.0)+47
(define (hear w secs)+48
(let loop ((f 0) (w w))+49
(if (> f (* 30 secs))+50
(list (sound-bed-notes 4) (sound-region) w)+51
(let* ((w (world-tick w NOTHING))+52
(p (world-take-news w)))+53
(set! *t* (+ *t* (/ 1 30.0)))+54
(sound-frame! *t* (/ 1 30.0) (world-place (cdr p) CT (car p)))+55
(mixer-pump! *t*)+56
(loop (+ f 1) (cdr p))))))+57
+58
(mixer-open-headless! 32000 (lambda (id buf n gain) #f))+59
(sound-reset!)+60
;; the director's cues, rendered at boot as in the game; what it says is kept+61
(define *said* '())+62
(sound-boot! '() (lambda parts (set! *said* (cons parts *said*))))+63
(define (said? . parts) (and (member parts *said*) #t))+64
+65
(define W0 (standing 0 0 3 9 #f)) ; testfen, away from H2+66
(define FEN-BEFORE (hear W0 20)) ; long enough to render every cue+67
(define SAID-BEFORE *said*)+68
(define HOLLOW-BEFORE (hear (standing 2 0 5 9 (caddr FEN-BEFORE)) 1))+69
(define TAKE (hear (standing 0 0 12 9 (caddr HOLLOW-BEFORE)) 1)) ; on H2: taken+70
(define SAID-TAKE *said*)+71
(define FEN-AFTER (hear (caddr TAKE) 5))+72
(define HOLLOW-AFTER (hear (standing 2 0 5 9 (caddr FEN-AFTER)) 5))+73
+74
(test-group "every region: before H2"+75
(test "the fixture loads clean, with two regions"+76
(assert-equal '() (content-problems CT))+77
(assert-equal 2 (length (content-regions CT))))+78
(test "testfen's bed: no channel-4 notes"+79
(assert-equal 'testfen (cadr FEN-BEFORE))+80
(assert-equal 0 (car FEN-BEFORE)))+81
(test "testhollow's bed: no channel-4 notes"+82
(assert-equal 'testhollow (cadr HOLLOW-BEFORE))+83
(assert-equal 0 (car HOLLOW-BEFORE))))+84
+85
(test-group "every region: after H2"+86
(test "the body took H2 in testfen"+87
(assert-equal '(2) (world-carry (caddr TAKE))))+88
(test "and the director played its pickup cue (and had not before)"+89
(assert-false (member (list "sound" "cue" "pickup-h2") SAID-BEFORE))+90
(assert-true (member (list "sound" "cue" "pickup-h2") SAID-TAKE)))+91
(test "testfen's bed, where it was found: channel 4 sounds"+92
(assert-equal 'testfen (cadr FEN-AFTER))+93
(assert-true (> (car FEN-AFTER) 0)))+94
(test "testhollow's bed, another region: channel 4 sounds too"+95
(assert-equal 'testhollow (cadr HOLLOW-AFTER))+96
(assert-true (> (car HOLLOW-AFTER) 0))))+97
+98
;; A door onto the harmonic takes it on the first frame, before the boot has+99
;; rendered its cue (seen by A3's web probe: the pickup was silent). The cue+100
;; waits, jumps the render queue, and plays late rather than never.+101
(mixer-open-headless! 32000 (lambda (id buf n gain) #f))+102
(sound-reset!)+103
(set! *said* '())+104
(sound-boot! '() (lambda parts (set! *said* (cons parts *said*))))+105
(define ON-IT (hear (standing 0 0 12 9 #f) 3))+106
+107
(test-group "every region: the pickup's cue on the first frame"+108
(test "it was not ready, so it waited, and then it played"+109
(assert-true (said? "sound" "cue-waiting" "pickup-h2"))+110
(assert-true (said? "sound" "cue-late" "pickup-h2"))+111
(assert-false (said? "sound" "cue-dropped" "pickup-h2"))))test/test-tunes.sglmodified
@@ -16,6 +16,8 @@
16
(harkfell content) 17
(harkfell source) 18
(harkfell tunes)+19
(harkfell sound)+20
(harkfell gates) 21
(harkfell audio)) 22
23
(define WORLD (read-world-source "world"))@@ -74,3 +76,30 @@
76
WORLD)) 77
(ps (tune-problems (load-content src)))) 78
(assert-true (member "reedfen:9,3 moment: no assets/tunes/frog-choir.cts" ps)))))+79
+80
;; A3: every cue the director renders, and for every harmonic placed in the+81
;; world (a pickup or a ring-stone) its finding tone, its pickup cue, its+82
;; ring and its near miss: each has a file that reads. The director plays+83
;; nothing for a harmonic whose tune is missing, so a missing file would be+84
;; silent in play.+85
(define (a3-tunes ct)+86
(append (sound-cue-tunes)+87
(apply append+88
(map (lambda (n)+89
(map (lambda (base) (string-append base (number->string n)))+90
(list "tone-h" "pickup-h" "ring-h" "miss-h")))+91
(append (map pickup-overtone (content-pickups ct))+92
(map stone-overtone (content-stones ct)))))))+93
+94
(define (unreadable names)+95
(filter (lambda (n) (not (and (tune-exists? n) (guard (x (#t #f)) (read-tune-string (tune-text n))))))+96
names))+97
+98
(test-group "tunes: the A3 cues and each placed harmonic's sounds"+99
(test "the world places H2 (the check has a subject), and every cue and H2 tune reads"+100
(let ((ct (load-content WORLD)))+101
(assert-true (member "ring-h2" (a3-tunes ct)))+102
(assert-true (member "croak-h2" (a3-tunes ct)))+103
(assert-equal '() (unreadable (a3-tunes ct)))))+104
(test "a harmonic with no tunes is red (H3 has none yet)"+105
(assert-true (member "ring-h3" (unreadable (list "ring-h2" "ring-h3"))))))