Commit1f34dfdbRecorded24 Sep 2026Repositoryharkfell

A2: the sound of the place, and the phone measurement

Message

(harkfell sound), the director: Reedfen's bed (wind, water, reeds, and the drone's H2 and H3, whose notes are dropped until carried: a muted channel still synthesises), room levels ramped on entry, the fixed moment of Reed Bridge opened ahead and played on the first visit the hush allows with the bed ducked 6 dB on its SOURCE gain, bed crossfades that keep the outgoing bed's sink and factors, listening (the bed dips and the H2 finding tone comes forward after a second of stillness, the ears lean toward it; the tone's distance, pan and brightness in its own pull), spot sounds (the room's bellfrogs, reed birds, plops) rendered a few blocks a frame at boot, and the soft return's low tone. A room entry waits up to 3 s for its tunes, which the web page fetches (and retries).

(harkfell audio) builds the director's place from the running world in absolute world pixels; (harkfell tunes), (harkfell ears); both shells call it each frame; ?rate (default 32000), ?carry, ?tone doors.

(harkfell bench) behind ?bench / --bench: A2's bed, A2's bed with its moment, S0's full bed, with a moment, a border's three players, and six tune opens, in 30 s phases; the page sends each line to the host (bench-report?LINE), which serve-web-tls.mjs --log now records.

Tests: gate 4 through the real player (a room change during a moment leaves the bed 6.02 dB under the same room without one), a carry change during a moment, every named tune exists and reads, and listening on the real world (the tone audible, the ears east of the placeholder H2 room).

Changed
 assets/tunes/bed-hollow.cts       |  18 +++++
 assets/tunes/bed-reedfen.cts      |  18 +++++
 assets/tunes/first-open-water.cts |  53 ++++++++++++++
 assets/tunes/moment-reedfen.cts   |  53 ++++++++++++++
 assets/tunes/reedfen-bed.cts      |  15 ++++
 assets/tunes/return-tone.cts      |  10 +++
 assets/tunes/spot-bird.cts        |  14 ++++
 assets/tunes/spot-frog.cts        |  10 +++
 assets/tunes/spot-plop.cts        |   9 +++
 assets/tunes/tone-h2.cts          |  11 +++
 index.template.html               |  93 ++++++++++++++++++++++++
 scripts/host-web                  |   2 +-
 scripts/serve-web-tls.mjs         |   7 +-
 src/harkfell/audio.sgl            | 141 ++++++++++++++++++++++++++++++++++++
 src/harkfell/bench.sgl            | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/harkfell/ears.sgl             |  46 ++++++++++++
 src/harkfell/shell.sgl            |  18 ++++-
 src/harkfell/shell/native.sgl     |  19 ++++-
 src/harkfell/shell/web.sgl        |  35 ++++++++-
 src/harkfell/sound.sgl            | 620 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/harkfell/tunes.sgl            |  88 +++++++++++++++++++++++
 test/test-place.sgl               |  83 ++++++++++++++++++++++
 test/test-sound.sgl               | 122 +++++++++++++++++++++++++++++++
 test/test-tunes.sgl               |  76 ++++++++++++++++++++
 24 files changed, 1774 insertions(+), 11 deletions(-)
Diff
assets/tunes/bed-hollow.ctsadded
@@ -0,0 +1,18 @@
+1
(tune version: 1 name: "bed-hollow" tempo: 60 speed: 15 channels: 8
+2
(bus reverb: zitarev size: 0.85 damp: 0.5 mix: 0.22)
+3
(instruments
+4
(instrument id: 1 name: "wind in the ring" patch: (noise-bed color: pink level: 1.0 centre: 520 width: 1.3 wander: 0.35 rate: 0.05 gust: 0.4 gust-rate: 0.08 hp: 240 lp: 2600 excite: 0.15 excite-freq: 1800 attack: 3 release: 4 seed: 41) volume: 36 gain: 2 send: 0.2)
+5
(instrument id: 2 name: "far water" patch: (noise-bed color: brown level: 1.0 centre: 600 width: 1.2 wander: 0.2 rate: 0.04 gust: 0.5 gust-rate: 0.15 hp: 300 lp: 1400 attack: 3 release: 4 seed: 42) volume: 18 gain: 2 send: 0.2)
+6
(instrument id: 3 name: "the fundamental" patch: (drone-saw level: 0.3 fm-index: 6 fm-level: 0.12 bright: 14 detune: 3 excite: 0.5 excite-freq: 350 excite-drive: 4 breath-rate: 0.03 breath-lo: 0.35 attack: 6 release: 8 seed: 1) volume: 26 gain: 2 send: 0.4)
+7
(instrument id: 4 name: "H2 D3" patch: (drone-saw level: 0.3 fm-index: 2.4 fm-level: 0.1 bright: 14 excite: 0.5 excite-freq: 500 breath-rate: 0.0213 breath-lo: 0.15 seed: 2) volume: 30 gain: 2 send: 0.35)
+8
(instrument id: 5 name: "H3 A3" patch: (drone-saw level: 0.12 fm-index: 1.4 fm-level: 0.12 bright: 12 excite: 0.4 excite-freq: 600 cents: 1.955 breath-rate: 0.0323 breath-lo: 0.15 seed: 3) volume: 28 gain: 2 send: 0.35)
+9
(instrument id: 6 name: "H4 D4" patch: (drone-saw level: 0.3 fm-index: 1.8 fm-level: 0.45 bright: 8 excite: 0.3 excite-freq: 900 breath-rate: 0.0189 breath-lo: 0.15 seed: 4) volume: 26 gain: 2 send: 0.35)
+10
(instrument id: 7 name: "H5 F#4" patch: (drone-saw level: 0.3 fm-index: 1.1 fm-level: 0.3 bright: 8 excite: 0.3 excite-freq: 900 cents: -13.686 breath-rate: 0.027 breath-lo: 0.15 seed: 5) volume: 24 gain: 2 send: 0.35)
+11
(instrument id: 8 name: "H7 C5" patch: (drone-saw level: 0.3 fm-index: 1.0 fm-level: 0.2 bright: 8 excite: 0.3 excite-freq: 900 cents: -31.174 breath-rate: 0.0244 breath-lo: 0.3 seed: 7) volume: 22 gain: 2 send: 0.4))
+12
(patterns
+13
(pattern id: 0 rows: 16
+14
(row 0 (1 "D-4" 1 36 "000") (2 "D-4" 2 18 "000") (3 "D-2" 3 26 "000") (4 "D-3" 4 30 "000") (5 "A-3" 5 28 "000") (6 "D-4" 6 26 "000") (7 "F#4" 7 24 "000") (8 "C-5" 8 22 "000")))
+15
(pattern id: 1 rows: 128
+16
(row 127 (1 "..." 0 0 "B01"))))
+17
(order 0 1)
+18
(history ("claude" "2026-09-23" "S0 region bed: the Hollow")))
assets/tunes/bed-reedfen.ctsadded
@@ -0,0 +1,18 @@
+1
(tune version: 1 name: "bed-reedfen" tempo: 60 speed: 15 channels: 8
+2
(bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.15)
+3
(instruments
+4
(instrument id: 1 name: "fell wind" patch: (noise-bed color: pink level: 1.0 centre: 700 width: 1.6 wander: 0.45 rate: 0.07 gust: 0.55 gust-rate: 0.11 hp: 250 lp: 5000 attack: 3 release: 4 seed: 1) volume: 40 gain: 2 send: 0.2)
+5
(instrument id: 2 name: "tarn" patch: (noise-bed color: brown level: 1.0 centre: 520 width: 1.3 wander: 0.2 rate: 0.05 gust: 0.6 gust-rate: 0.22 hp: 300 lp: 1500 attack: 3 release: 4 seed: 11) volume: 44 gain: 2 send: 0.15)
+6
(instrument id: 3 name: "reeds" patch: (noise-bed color: pink level: 1.0 centre: 3400 width: 0.8 wander: 0.25 rate: 0.12 gust: 0.6 gust-rate: 0.2 trem: 0.35 trem-rate: 7 hp: 1500 lp: 9000 attack: 3 release: 4 seed: 16) volume: 30 gain: 2 send: 0.15)
+7
(instrument id: 4 name: "H2 D3" patch: (drone-saw level: 0.3 fm-index: 2.4 fm-level: 0.1 bright: 14 excite: 0.5 excite-freq: 500 breath-rate: 0.0213 breath-lo: 0.15 seed: 2) volume: 24 gain: 2 send: 0.35)
+8
(instrument id: 5 name: "H3 A3" patch: (drone-saw level: 0.12 fm-index: 1.4 fm-level: 0.12 bright: 12 excite: 0.4 excite-freq: 600 cents: 1.955 breath-rate: 0.0323 breath-lo: 0.15 seed: 3) volume: 22 gain: 2 send: 0.35)
+9
(instrument id: 6 name: "H4 D4" patch: (drone-saw level: 0.3 fm-index: 1.8 fm-level: 0.45 bright: 8 excite: 0.3 excite-freq: 900 breath-rate: 0.0189 breath-lo: 0.15 seed: 4) volume: 20 gain: 2 send: 0.35)
+10
(instrument id: 7 name: "H5 F#4" patch: (drone-saw level: 0.3 fm-index: 1.1 fm-level: 0.3 bright: 8 excite: 0.3 excite-freq: 900 cents: -13.686 breath-rate: 0.027 breath-lo: 0.15 seed: 5) volume: 18 gain: 2 send: 0.35)
+11
(instrument id: 8 name: "H7 C5" patch: (drone-saw level: 0.3 fm-index: 1.0 fm-level: 0.2 bright: 8 excite: 0.3 excite-freq: 900 cents: -31.174 breath-rate: 0.0244 breath-lo: 0.3 seed: 7) volume: 16 gain: 2 send: 0.4))
+12
(patterns
+13
(pattern id: 0 rows: 16
+14
(row 0 (1 "D-4" 1 40 "000") (2 "D-4" 2 44 "000") (3 "D-4" 3 30 "000") (4 "D-3" 4 24 "000") (5 "A-3" 5 22 "000") (6 "D-4" 6 20 "000") (7 "F#4" 7 18 "000") (8 "C-5" 8 16 "000")))
+15
(pattern id: 1 rows: 128
+16
(row 127 (1 "..." 0 0 "B01"))))
+17
(order 0 1)
+18
(history ("claude" "2026-09-23" "S0 region bed: Reedfen")))
assets/tunes/first-open-water.ctsadded
@@ -0,0 +1,53 @@
+1
(tune version: 1 name: "first-open-water" tempo: 84 speed: 6 channels: 6
+2
(bus reverb: zitarev size: 0.65 damp: 0.5 mix: 0.22)
+3
(instruments
+4
(instrument id: 1 name: "reed flute" patch: (flute-wind attack: 0.12 release: 0.6 noise-amp: 0.18) volume: 42 send: 0.35)
+5
(instrument id: 2 name: "frog-bell" patch: (karplus-strong decay: 1.4 excite-gain: 0.6) volume: 26 send: 0.3)
+6
(instrument id: 3 name: "fen pad" patch: (warm-pad cutoff: 1100 attack: 1.2 release: 2.5 sustain: 0.75) volume: 24 send: 0.35)
+7
(instrument id: 4 name: "low line" patch: (drone-fm level: 0.4 index: 2.5 index-wander: 0 breath-lo: 1 attack: 0.4 release: 1.5) volume: 34 send: 0.2))
+8
(patterns
+9
(pattern id: 0 rows: 144
+10
(row 0 (2 "D-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "D-2" 4 34 "000"))
+11
(row 4 (2 "A-5" 2 22 "000") (1 "A-4" 1 40 "000"))
+12
(row 8 (2 "F-5" 2 26 "000"))
+13
(row 12 (2 "A-5" 2 22 "000"))
+14
(row 15 (1 "===" 0 0 "000"))
+15
(row 16 (2 "D-5" 2 30 "000") (4 "G-4" 3 24 "000") (5 "B-4" 3 22 "000") (6 "G-1" 4 34 "000") (1 "B-4" 1 42 "000"))
+16
(row 20 (2 "B-5" 2 22 "000"))
+17
(row 22 (1 "D-5" 1 40 "000"))
+18
(row 24 (2 "G-5" 2 26 "000"))
+19
(row 28 (2 "B-5" 2 22 "000") (1 "E-5" 1 42 "000"))
+20
(row 32 (2 "D-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "D-2" 4 34 "000") (1 "F-5" 1 46 "000"))
+21
(row 36 (2 "A-5" 2 22 "000"))
+22
(row 40 (2 "F-5" 2 26 "000") (1 "E-5" 1 40 "000"))
+23
(row 44 (2 "A-5" 2 22 "000") (1 "D-5" 1 40 "000"))
+24
(row 48 (2 "D-5" 2 30 "000") (4 "G-4" 3 24 "000") (5 "B-4" 3 22 "000") (6 "G-1" 4 34 "000") (1 "B-4" 1 44 "000"))
+25
(row 52 (2 "B-5" 2 22 "000"))
+26
(row 56 (2 "G-5" 2 26 "000") (1 "A-4" 1 40 "000"))
+27
(row 60 (2 "B-5" 2 22 "000") (1 "G-4" 1 38 "000"))
+28
(row 64 (2 "C-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "F-1" 4 34 "000") (1 "A-4" 1 42 "000"))
+29
(row 68 (2 "A-5" 2 22 "000"))
+30
(row 70 (3 "A-4" 2 18 "000") (1 "C-5" 1 42 "000"))
+31
(row 72 (2 "F-5" 2 26 "000"))
+32
(row 76 (2 "A-5" 2 22 "000") (1 "F-5" 1 46 "000"))
+33
(row 78 (3 "F-4" 2 16 "000"))
+34
(row 80 (2 "C-5" 2 30 "000") (4 "E-4" 3 24 "000") (5 "G-4" 3 22 "000") (6 "C-2" 4 34 "000") (1 "E-5" 1 44 "000"))
+35
(row 84 (2 "G-5" 2 22 "000"))
+36
(row 86 (3 "G-4" 2 18 "000"))
+37
(row 88 (2 "E-5" 2 26 "000") (1 "D-5" 1 40 "000"))
+38
(row 92 (2 "G-5" 2 22 "000") (1 "C-5" 1 40 "000"))
+39
(row 94 (3 "E-4" 2 16 "000"))
+40
(row 96 (2 "D-5" 2 30 "000") (4 "G-4" 3 24 "000") (5 "B-4" 3 22 "000") (6 "G-1" 4 34 "000") (1 "B-4" 1 44 "000"))
+41
(row 100 (2 "B-5" 2 22 "000"))
+42
(row 102 (3 "B-4" 2 18 "000"))
+43
(row 104 (2 "G-5" 2 26 "000") (1 "D-5" 1 42 "000"))
+44
(row 108 (2 "B-5" 2 22 "000"))
+45
(row 112 (2 "D-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "D-2" 4 34 "000") (1 "D-5" 1 42 "000"))
+46
(row 116 (2 "A-5" 2 22 "000"))
+47
(row 118 (3 "A-4" 2 16 "000"))
+48
(row 120 (2 "F-5" 2 26 "000"))
+49
(row 124 (2 "A-5" 2 22 "000"))
+50
(row 126 (1 "===" 0 0 "000"))
+51
(row 128 (4 "===" 0 0 "000") (5 "===" 0 0 "000") (6 "===" 0 0 "000"))))
+52
(order 0)
+53
(history ("claude" "2026-09-23" "S0 moment: Reedfen, D Dorian, first open water") ("claude" "2026-09-24" "A2: the fixed moment of Reed Bridge, the S0 sketch renamed")))
assets/tunes/moment-reedfen.ctsadded
@@ -0,0 +1,53 @@
+1
(tune version: 1 name: "moment-reedfen" tempo: 84 speed: 6 channels: 6
+2
(bus reverb: zitarev size: 0.65 damp: 0.5 mix: 0.22)
+3
(instruments
+4
(instrument id: 1 name: "reed flute" patch: (flute-wind attack: 0.12 release: 0.6 noise-amp: 0.18) volume: 42 send: 0.35)
+5
(instrument id: 2 name: "frog-bell" patch: (karplus-strong decay: 1.4 excite-gain: 0.6) volume: 26 send: 0.3)
+6
(instrument id: 3 name: "fen pad" patch: (warm-pad cutoff: 1100 attack: 1.2 release: 2.5 sustain: 0.75) volume: 24 send: 0.35)
+7
(instrument id: 4 name: "low line" patch: (drone-fm level: 0.4 index: 2.5 index-wander: 0 breath-lo: 1 attack: 0.4 release: 1.5) volume: 34 send: 0.2))
+8
(patterns
+9
(pattern id: 0 rows: 144
+10
(row 0 (2 "D-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "D-2" 4 34 "000"))
+11
(row 4 (2 "A-5" 2 22 "000") (1 "A-4" 1 40 "000"))
+12
(row 8 (2 "F-5" 2 26 "000"))
+13
(row 12 (2 "A-5" 2 22 "000"))
+14
(row 15 (1 "===" 0 0 "000"))
+15
(row 16 (2 "D-5" 2 30 "000") (4 "G-4" 3 24 "000") (5 "B-4" 3 22 "000") (6 "G-1" 4 34 "000") (1 "B-4" 1 42 "000"))
+16
(row 20 (2 "B-5" 2 22 "000"))
+17
(row 22 (1 "D-5" 1 40 "000"))
+18
(row 24 (2 "G-5" 2 26 "000"))
+19
(row 28 (2 "B-5" 2 22 "000") (1 "E-5" 1 42 "000"))
+20
(row 32 (2 "D-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "D-2" 4 34 "000") (1 "F-5" 1 46 "000"))
+21
(row 36 (2 "A-5" 2 22 "000"))
+22
(row 40 (2 "F-5" 2 26 "000") (1 "E-5" 1 40 "000"))
+23
(row 44 (2 "A-5" 2 22 "000") (1 "D-5" 1 40 "000"))
+24
(row 48 (2 "D-5" 2 30 "000") (4 "G-4" 3 24 "000") (5 "B-4" 3 22 "000") (6 "G-1" 4 34 "000") (1 "B-4" 1 44 "000"))
+25
(row 52 (2 "B-5" 2 22 "000"))
+26
(row 56 (2 "G-5" 2 26 "000") (1 "A-4" 1 40 "000"))
+27
(row 60 (2 "B-5" 2 22 "000") (1 "G-4" 1 38 "000"))
+28
(row 64 (2 "C-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "F-1" 4 34 "000") (1 "A-4" 1 42 "000"))
+29
(row 68 (2 "A-5" 2 22 "000"))
+30
(row 70 (3 "A-4" 2 18 "000") (1 "C-5" 1 42 "000"))
+31
(row 72 (2 "F-5" 2 26 "000"))
+32
(row 76 (2 "A-5" 2 22 "000") (1 "F-5" 1 46 "000"))
+33
(row 78 (3 "F-4" 2 16 "000"))
+34
(row 80 (2 "C-5" 2 30 "000") (4 "E-4" 3 24 "000") (5 "G-4" 3 22 "000") (6 "C-2" 4 34 "000") (1 "E-5" 1 44 "000"))
+35
(row 84 (2 "G-5" 2 22 "000"))
+36
(row 86 (3 "G-4" 2 18 "000"))
+37
(row 88 (2 "E-5" 2 26 "000") (1 "D-5" 1 40 "000"))
+38
(row 92 (2 "G-5" 2 22 "000") (1 "C-5" 1 40 "000"))
+39
(row 94 (3 "E-4" 2 16 "000"))
+40
(row 96 (2 "D-5" 2 30 "000") (4 "G-4" 3 24 "000") (5 "B-4" 3 22 "000") (6 "G-1" 4 34 "000") (1 "B-4" 1 44 "000"))
+41
(row 100 (2 "B-5" 2 22 "000"))
+42
(row 102 (3 "B-4" 2 18 "000"))
+43
(row 104 (2 "G-5" 2 26 "000") (1 "D-5" 1 42 "000"))
+44
(row 108 (2 "B-5" 2 22 "000"))
+45
(row 112 (2 "D-5" 2 30 "000") (4 "F-4" 3 24 "000") (5 "A-4" 3 22 "000") (6 "D-2" 4 34 "000") (1 "D-5" 1 42 "000"))
+46
(row 116 (2 "A-5" 2 22 "000"))
+47
(row 118 (3 "A-4" 2 16 "000"))
+48
(row 120 (2 "F-5" 2 26 "000"))
+49
(row 124 (2 "A-5" 2 22 "000"))
+50
(row 126 (1 "===" 0 0 "000"))
+51
(row 128 (4 "===" 0 0 "000") (5 "===" 0 0 "000") (6 "===" 0 0 "000"))))
+52
(order 0)
+53
(history ("claude" "2026-09-23" "S0 moment: Reedfen, D Dorian, first open water")))
assets/tunes/reedfen-bed.ctsadded
@@ -0,0 +1,15 @@
+1
(tune version: 1 name: "reedfen-bed" tempo: 60 speed: 15 channels: 8
+2
(bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.15)
+3
(instruments
+4
(instrument id: 1 name: "fell wind" patch: (noise-bed color: pink level: 1.0 centre: 700 width: 1.6 wander: 0.45 rate: 0.07 gust: 0.55 gust-rate: 0.11 hp: 250 lp: 5000 attack: 3 release: 4 seed: 1) volume: 40 gain: 2 send: 0.2)
+5
(instrument id: 2 name: "tarn" patch: (noise-bed color: brown level: 1.0 centre: 520 width: 1.3 wander: 0.2 rate: 0.05 gust: 0.6 gust-rate: 0.22 hp: 300 lp: 1500 attack: 3 release: 4 seed: 11) volume: 44 gain: 2 send: 0.15)
+6
(instrument id: 3 name: "reeds" patch: (noise-bed color: pink level: 1.0 centre: 3400 width: 0.8 wander: 0.25 rate: 0.12 gust: 0.6 gust-rate: 0.2 trem: 0.35 trem-rate: 7 hp: 1500 lp: 9000 attack: 3 release: 4 seed: 16) volume: 30 gain: 2 send: 0.15)
+7
(instrument id: 4 name: "H2 D3" patch: (drone-saw level: 0.3 fm-index: 2.4 fm-level: 0.1 bright: 14 excite: 0.5 excite-freq: 500 breath-rate: 0.0213 breath-lo: 0.15 seed: 2) volume: 24 gain: 2 send: 0.35)
+8
(instrument id: 5 name: "H3 A3" patch: (drone-saw level: 0.12 fm-index: 1.4 fm-level: 0.12 bright: 12 excite: 0.4 excite-freq: 600 cents: 1.955 breath-rate: 0.0323 breath-lo: 0.15 seed: 3) volume: 22 gain: 2 send: 0.35))
+9
(patterns
+10
(pattern id: 0 rows: 16
+11
(row 0 (1 "D-4" 1 40 "000") (2 "D-4" 2 44 "000") (3 "D-4" 3 30 "000") (4 "D-3" 4 24 "000") (5 "A-3" 5 22 "000")))
+12
(pattern id: 1 rows: 128
+13
(row 127 (1 "..." 0 0 "B01"))))
+14
(order 0 1)
+15
(history ("claude" "2026-09-24" "A2: Reedfen's bed from S0's, with two drone channels, H2 (4) and H3 (5), each muted until carried")))
assets/tunes/return-tone.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "return-tone" tempo: 120 speed: 3 channels: 1
+2
(bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.25)
+3
(instruments
+4
(instrument id: 1 name: "low tone" patch: (drone-fm level: 0.4 index: 2.5 index-wander: 0 breath-lo: 1 attack: 0.05 release: 0.8) volume: 40 send: 0.3))
+5
(patterns
+6
(pattern id: 0 rows: 8
+7
(row 0 (1 "D-3" 1 40 "000"))
+8
(row 6 (1 "===" 0 0 "000"))))
+9
(order 0)
+10
(history ("claude" "2026-09-24" "A2: the soft return's short low tone (section 3, Harm)")))
assets/tunes/spot-bird.ctsadded
@@ -0,0 +1,14 @@
+1
(tune version: 1 name: "spot-bird" tempo: 120 speed: 2 channels: 1
+2
(bus reverb: zitarev size: 0.7 damp: 0.4 mix: 0.3)
+3
(instruments
+4
(instrument id: 1 name: "reed bird" patch: (flute-wind attack: 0.02 release: 0.12 noise-amp: 0.1) volume: 36 send: 0.4))
+5
(patterns
+6
(pattern id: 0 rows: 16
+7
(row 0 (1 "E-6" 1 30 "000"))
+8
(row 2 (1 "===" 0 0 "000"))
+9
(row 3 (1 "A-6" 1 36 "000"))
+10
(row 5 (1 "===" 0 0 "000"))
+11
(row 7 (1 "E-6" 1 26 "000"))
+12
(row 8 (1 "===" 0 0 "000"))))
+13
(order 0)
+14
(history ("claude" "2026-09-24" "A2 spot sound: a reed bird's three-note call")))
assets/tunes/spot-frog.ctsadded
@@ -0,0 +1,10 @@
+1
(tune version: 1 name: "spot-frog" 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" "A2 spot sound: a bellfrog's two-note croak, falling a fourth")))
assets/tunes/spot-plop.ctsadded
@@ -0,0 +1,9 @@
+1
(tune version: 1 name: "spot-plop" tempo: 120 speed: 3 channels: 1
+2
(bus reverb: zitarev size: 0.5 damp: 0.6 mix: 0.15)
+3
(instruments
+4
(instrument id: 1 name: "plop" patch: (drip level: 0.6 rise: 0.6 sweep: 0.05 decay: 0.09 echo: 0.2 feedback: 0.25 echo-mix: 0.25 hp: 250 seed: 21) volume: 40 gain: 2 send: 0.2))
+5
(patterns
+6
(pattern id: 0 rows: 8
+7
(row 0 (1 "D-4" 1 40 "000"))))
+8
(order 0)
+9
(history ("claude" "2026-09-24" "A2 spot sound: something small going into the water")))
assets/tunes/tone-h2.ctsadded
@@ -0,0 +1,11 @@
+1
(tune version: 1 name: "tone-h2" tempo: 60 speed: 15 channels: 1
+2
(bus reverb: zitarev size: 0.8 damp: 0.5 mix: 0.3)
+3
(instruments
+4
(instrument id: 1 name: "H2, unfound" patch: (finding-tone level: 0.4 index: 1.1 pulse: 0.7 pulse-rate: 0.35 cutoff: 5000 attack: 1.5 release: 2 seed: 31) volume: 52 send: 0.4))
+5
(patterns
+6
(pattern id: 0 rows: 16
+7
(row 0 (1 "D-3" 1 52 "000")))
+8
(pattern id: 1 rows: 32
+9
(row 31 (1 "..." 0 0 "B01"))))
+10
(order 0 1)
+11
(history ("claude" "2026-09-23" "S0 sketch: the finding tone for H2, here")))
index.template.htmlmodified
@@ -8,6 +8,24 @@
8
<meta name="apple-mobile-web-app-capable" content="yes">
9
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
<title>Harkfell</title>
+11
<script>
+12
// A2: the sound renders at ?rate=N Hz (default 32000, the rate Crash's
+13
// phone measurements chose; ?rate=device for the device's own) and the
+14
// browser resamples to the device. The WebAudio bridge opens
+15
// `new AudioContext()` with no options, so the page supplies the rate by
+16
// wrapping the constructor before the loader runs (the S0 sketchbook's way).
+17
(function () {
+18
var p = new URLSearchParams(location.search).get("rate");
+19
var want = p === "device" ? 0 : Number(p || 32000);
+20
var Base = window.AudioContext || window.webkitAudioContext;
+21
if (want > 0 && Base) {
+22
var Wrapped = function (opts) { return new Base(Object.assign({ sampleRate: want }, opts || {})); };
+23
Wrapped.prototype = Base.prototype;
+24
window.AudioContext = Wrapped;
+25
if (window.webkitAudioContext) window.webkitAudioContext = Wrapped;
+26
}
+27
})();
+28
</script>
29
<!-- version: "__VERSION__" -->
30
<style>
31
/* The page is the room: the canvas fills the viewport and the game
@@ -42,6 +60,12 @@
60
#dead { position: fixed; inset: 0; z-index: 3; display: flex; align-items: center;
61
justify-content: center; background: rgba(0, 0, 0, 0.8); color: #ddd; font: 48px sans-serif; }
62
#dead[hidden] { display: none; }
+63
+64
/* ?bench: the measurement's lines, and the tap that starts it */
+65
#bench { position: fixed; left: 0; right: 0; top: 0; z-index: 4; max-height: 70vh; overflow: auto;
+66
margin: 0; padding: 8px; background: rgba(0, 0, 0, 0.78); color: #cfe; font: 11px/1.35 ui-monospace, monospace;
+67
white-space: pre-wrap; word-break: break-all; }
+68
#bench[hidden] { display: none; }
69
</style>
70
</head>
71
<body>
@@ -52,6 +76,7 @@
76
<div id="jumpbtn"></div>
77
</div>
78
<div id="dead" hidden>&#x21bb;</div>
+79
<pre id="bench" hidden></pre>
80
<div id="status"></div>
81
82
<script>
@@ -90,6 +115,15 @@
115
// region sheet); a tap enters a room.
116
// ?still the world does not step (screenshots).
117
// ?test-room A0's grey test room instead of the world.
+118
// ?rate=N|device the sound's render rate (A2; default 32000).
+119
// ?carry=2,3 play as if carrying those harmonics: their drone
+120
// voices sound in the bed (A3's pickups do this).
+121
// ?tone=X,Y the room H2's finding tone sounds from (default
+122
// 11,3: a placeholder east of Reedfen's six rooms).
+123
// ?bench the phone measurement (A2, (harkfell bench)): a tap
+124
// starts it; about 3.3 minutes; its lines show at the
+125
// top and are sent to the host (bench-report?LINE), so
+126
// the numbers need no transcribing.
127
//
128
// The guard follows Crash The Stack's page guard (master e737d21) in
129
// spirit, much reduced: a FATAL line, a trap out of a dispatch or a lost
@@ -107,6 +141,63 @@
141
deadCard.hidden = false;
142
}
143
deadCard.addEventListener("click", function () { location.reload(); });
+144
+145
// ---- A2: the sound's lines -------------------------------------------
+146
// harkfell: fetch NAME the wasm wants assets/tunes/NAME.cts: fetched and
+147
// handed over in 8 KB chunks, then tune-done
+148
// harkfell: bench ... ?bench's lines: shown, and sent to the host
+149
var benchEl = document.getElementById("bench");
+150
var fetched = {}, tries = {};
+151
function fetchTune(name) {
+152
if (!/^[a-z0-9-]+$/.test(name) || fetched[name]) return;
+153
fetched[name] = true;
+154
fetch("assets/tunes/" + name + ".cts").then(function (r) {
+155
if (!r.ok) throw new Error("tune " + name + " " + r.status);
+156
return r.text();
+157
}).then(function (text) {
+158
for (var i = 0; i < text.length; i += 8192) send("tune-chunk", text.slice(i, i + 8192));
+159
send("tune-done", name);
+160
}, function (err) {
+161
// a failed fetch is tried again, three times in all, 2 s apart (the
+162
// wasm asks for a name once; review M4)
+163
fetched[name] = false;
+164
tries[name] = (tries[name] || 0) + 1;
+165
if (tries[name] < 3) setTimeout(function () { fetchTune(name); }, 2000);
+166
else console.error("harkfell: " + err.message);
+167
});
+168
}
+169
function report(line) {
+170
benchEl.textContent += line + "\n";
+171
benchEl.scrollTop = benchEl.scrollHeight;
+172
try { fetch("bench-report?" + encodeURIComponent(line), { cache: "no-store" }).catch(function () {}); } catch (err) { /* offline */ }
+173
}
+174
var log = console.log;
+175
console.log = function () {
+176
var line = String(arguments[0] || "");
+177
if (line.indexOf("harkfell: fetch ") === 0) fetchTune(line.slice(16).trim());
+178
else if (line.indexOf("harkfell: bench ") === 0 || (benchOn && line.indexOf("harkfell: audio ") === 0)) report(line);
+179
return log.apply(console, arguments);
+180
};
+181
// Every gesture may resume the audio context (the autoplay policy); the
+182
// capture phase sees the touch controls' events before they stop them.
+183
["pointerdown", "pointerup", "touchend", "click", "keydown"].forEach(function (n) {
+184
document.addEventListener(n, function () { send("gesture", n); }, true);
+185
});
+186
var benchOn = params.has("bench");
+187
if (benchOn) {
+188
benchEl.hidden = false;
+189
benchEl.textContent = "Harkfell sound measurement. Tap anywhere to start; it takes about 3.3 minutes. Leave the phone on this page with the screen on.\n";
+190
var begun = false;
+191
document.addEventListener("pointerup", function () {
+192
if (begun) return;
+193
begun = true;
+194
report("harkfell: bench env version " + (document.documentElement.innerHTML.match(/version: "([^"]*)"/) || [0, "?"])[1] +
+195
" isolated " + (self.crossOriginIsolated ? 1 : 0) + " rate-param " + (params.get("rate") || "32000") +
+196
" cores " + (navigator.hardwareConcurrency || "?") + " dpr " + (window.devicePixelRatio || 1) +
+197
" ua " + navigator.userAgent.replace(/\s+/g, "_"));
+198
withApp(function () { send("gesture", "bench"); send("bench", ""); });
+199
}, true);
+200
}
201
var warn = console.warn;
202
console.warn = function () {
203
var s = String(arguments[0] || "");
@@ -150,6 +241,8 @@
241
if (window.HARKFELL_WORLD_LIVE) console.log("harkfell: page world live " + files.length + " files");
242
files.forEach(function (f) { send("world-file", f[0] + "\n" + f[1]); });
243
if (files.length) send("world-done", "");
+244
if (params.get("carry")) send("carry", params.get("carry"));
+245
if (params.get("tone")) send("tone", params.get("tone"));
246
if (params.has("test-room")) send("test-room", "");
247
var room = params.get("room");
248
if (room) send("goto", params.get("at") ? room + "@" + params.get("at") : room);
scripts/host-webmodified
@@ -91,7 +91,7 @@ stop_port "$PORT_HTTP" sigil "serve --dir"
91
rm -rf "$DST"
92
cp -r "$SRC" "$DST"
93
−94
setsid node scripts/serve-web-tls.mjs "$DST" "$PORT_TLS" --isolate > "build/host-$PORT_TLS.log" 2>&1 &
+94
setsid node scripts/serve-web-tls.mjs "$DST" "$PORT_TLS" --isolate --log > "build/host-$PORT_TLS.log" 2>&1 &
95
echo $! > "build/host-$PORT_TLS.pid"
96
setsid sigil serve --dir "$DST" --host "$HOST" --port "$PORT_HTTP" > "build/host-$PORT_HTTP.log" 2>&1 &
97
echo $! > "build/host-$PORT_HTTP.pid"
scripts/serve-web-tls.mjsmodified
@@ -1,6 +1,8 @@
1
#!/usr/bin/env node
2
// COPIED from Crash The Stack master e737d21 (scripts/serve-web-tls.mjs),
−3
// unchanged but for this header. Harkfell hosts on wg0 8797 (https) with
+3
// changed only by this header and by A2's --log (one line per request on
+4
// stdout, and GET /bench-report?LINE answered 204: the page's ?bench sends
+5
// its measurement lines there, so they land in the host log). Harkfell hosts on wg0 8797 (https) with
6
// --isolate; the S0 sketchbook runs its own instance on 8795.
7
// Host a build directory over HTTPS on the WireGuard address, for the phone.
8
//
@@ -27,6 +29,7 @@ const [dir, portArg, ...flags] = process.argv.slice(2);
29
if (!dir || !portArg) { console.error("usage: serve-web-tls.mjs DIR PORT [--isolate]"); process.exit(2); }
30
const port = Number(portArg);
31
const isolate = flags.includes("--isolate");
+32
const logging = flags.includes("--log");
33
const root = path.resolve(dir);
34
if (!fs.existsSync(path.join(root, "index.html"))) { console.error(`serve-web-tls: ${root}/index.html missing`); process.exit(1); }
35
@@ -38,7 +41,9 @@ const key = fs.readFileSync("build/tls/key.pem"), cert = fs.readFileSync("build/
41
const MIME = { html: "text/html; charset=utf-8", js: "text/javascript", mjs: "text/javascript", wasm: "application/wasm", json: "application/json",
42
css: "text/css", cts: "text/plain; charset=utf-8", png: "image/png", svg: "image/svg+xml", webmanifest: "application/manifest+json", txt: "text/plain; charset=utf-8", md: "text/plain; charset=utf-8" };
43
const server = https.createServer({ key, cert }, (req, res) => {
+44
if (logging) console.log(new Date().toISOString() + " " + req.socket.remoteAddress + " " + req.method + " " + req.url);
45
const urlPath = decodeURIComponent(new URL(req.url, "https://x").pathname);
+46
if (urlPath === "/bench-report") { res.writeHead(204, { "cache-control": "no-store" }); res.end(); return; }
47
let fp = path.normalize(path.join(root, urlPath));
48
if (!fp.startsWith(root)) { res.writeHead(403); res.end(); return; }
49
try { if (fs.statSync(fp).isDirectory()) fp = path.join(fp, "index.html"); } catch { /* falls to the read */ }
src/harkfell/audio.sgladded
@@ -0,0 +1,141 @@
+1
;;; (harkfell audio) - What both shells call for the sound, once a frame, and
+2
;;; the adapter from the world to the sound director's PLACE.
+3
;;;
+4
;;; (audio-boot! say) open the mixer (suspended on the web until a gesture)
+5
;;; (audio-world! content) the world loaded: the director learns its moments
+6
;;; (audio-gesture!) a user gesture: resume the context (the web's autoplay policy)
+7
;;; (audio-bench!) ?bench / --bench: the phone measurement ((harkfell bench));
+8
;;; the director is silent while it runs
+9
;;; (audio-carry! "2,3") ?carry= / --carry: the harmonics carried (A3's pickups do this in play)
+10
;;; (audio-tone-room! "X,Y") ?tone= / --tone: where H2's finding tone is
+11
;;; (audio-frame! frame-ms work-ms) every frame, after the world's ticks
+12
;;; (audio-lines) the readout's sound lines
+13
+14
(define-library (harkfell audio)
+15
(import (sigil core)
+16
(sigil time)
+17
(sigil math)
+18
(sigil list)
+19
(sigil string)
+20
(engine mixer)
+21
(engine body)
+22
(engine worldmap)
+23
(harkfell world)
+24
(harkfell content)
+25
(harkfell shell)
+26
(harkfell tunes)
+27
(harkfell sound)
+28
(harkfell bench))
+29
+30
(export audio-boot! audio-world! audio-gesture! audio-bench! audio-frame! audio-now
+31
audio-carry! audio-tone-room! audio-lines world-place room-moment-name)
+32
+33
(begin
+34
(define *say* (lambda parts #f))
+35
(define *last* #f)
+36
+37
(define (audio-now) (/ (current-jiffy) (* 1.0 (jiffies-per-second))))
+38
+39
(define (audio-boot! say)
+40
(set! *say* say)
+41
(mixer-open!)
+42
(shell-set-extra-lines! audio-lines)
+43
(*say* "audio" (number->string (mixer-rate)) (symbol->string (mixer-state))))
+44
+45
(define (audio-gesture!) (mixer-resume!))
+46
+47
(define (audio-bench!) (sound-reset!) (bench-start! *say*))
+48
+49
;; "2,3" -> (2 3)
+50
(define (numbers s) (filter number? (map string->number (string-split s ","))))
+51
+52
(define (audio-carry! s) (sound-set-carry! (numbers s)))
+53
+54
(define (audio-tone-room! s)
+55
(let ((n (numbers s)))
+56
(when (= (length n) 2) (sound-set-tone-room! (car n) (cadr n)))))
+57
+58
;; --- the adapter -------------------------------------------------------------------
+59
(define (kw-ref l name)
+60
(let find ((l l))
+61
(cond ((or (not (pair? l)) (null? (cdr l))) #f)
+62
((and (keyword? (car l)) (string=? (keyword->string (car l)) name)) (cadr l))
+63
(else (find (cdr l))))))
+64
+65
;; A room's fixed moment as a tune name (a string), or #f.
+66
(define (room-moment-name rm)
+67
(let* ((m (room-moment rm))
+68
(t (and (list? m) (kw-ref m "tune"))))
+69
(and (symbol? t) (symbol->string t))))
+70
+71
(define (region-of ct sym)
+72
(find (lambda (r) (eq? (region-name r) sym)) (content-regions ct)))
+73
+74
;; bed: (wind 0.4 water 0.8) -> ((wind . 0.4) (water . 0.8))
+75
(define (plist->alist l)
+76
(if (and (pair? l) (pair? (cdr l)))
+77
(cons (cons (car l) (cadr l)) (plist->alist (cddr l)))
+78
'()))
+79
+80
(define TILE 16)
+81
+82
;; The place the director needs, from the running world; #f outside the
+83
;; written world (A0's test room, the atlas, the sheet).
+84
(define (world-place w ct)
+85
(let* ((p (and w (world-room w)))
+86
(rm (and p (placed-data p))))
+87
(and rm ct
+88
(let* ((reg (region-of ct (room-region rm)))
+89
(b (world-body w))
+90
(wm (world-map w))
+91
(bed (region-field reg 'bed))
+92
(frogs (filter-map (lambda (t)
+93
(and (pair? t) (eq? (car t) 'bellfrog)
+94
(let ((at (kw-ref (cdr t) "at")))
+95
(and (pair? at) (+ (* TILE (car at)) (/ TILE 2.0))))))
+96
(let ((ts (room-things rm))) (if (list? ts) ts '())))))
+97
(place key: (room-label rm)
+98
region: (room-region rm)
+99
bed-tune: (cond ((string? bed) bed) ((symbol? bed) (symbol->string bed)) (else #f))
+100
groups: (or (region-field reg 'bed-channels) '())
+101
levels: (plist->alist (room-bed rm))
+102
moment: (room-moment-name rm)
+103
frogs: frogs
+104
;; ABSOLUTE world pixels: the map's pixels start at its
+105
;; westmost and northmost room (x0 y0), not at room (0 0)
+106
px: (+ (body-x b) 4.0 (* (worldmap-cols wm) (worldmap-size wm) (worldmap-x0 wm)))
+107
py: (+ (body-y b) 6.0 (* (worldmap-rows wm) (worldmap-size wm) (worldmap-y0 wm)))
+108
rx: (room-x rm) ry: (room-y rm)
+109
still: (and (eq? (world-phase w) 'play)
+110
(memq (body-mode b) '(ground))
+111
(< (abs (body-vx b)) 0.01)
+112
(< (abs (body-vy b)) 0.01))
+113
returns: (world-returns w))))))
+114
+115
(define (filter-map f l)
+116
(let loop ((l l) (acc '()))
+117
(if (null? l) (reverse acc)
+118
(let ((v (f (car l)))) (loop (cdr l) (if v (cons v acc) acc))))))
+119
+120
(define (audio-world! ct)
+121
(when ct
+122
(let ((ms (filter-map (lambda (p) (room-moment-name (placed-data p))) (content-rooms ct))))
+123
(sound-boot! ms *say*))))
+124
+125
(define (audio-frame! frame-ms work-ms)
+126
(let ((now (audio-now)))
+127
(if (or (bench-running?) (bench-done?))
+128
(when (bench-running?) (bench-frame! now frame-ms work-ms))
+129
(let ((dt (if *last* (min 0.25 (- now *last*)) 0.0)))
+130
(sound-frame! now dt (world-place (shell-world) (shell-content)))))
+131
(set! *last* now)
+132
(mixer-pump! now)))
+133
+134
(define (r1 x) (number->string (/ (round (* 10.0 x)) 10.0)))
+135
+136
(define (audio-lines)
+137
(let ((st (mixer-stats)))
+138
(append (sound-lines)
+139
(list (string-append "music " (r1 (stats-ref st 'frame-ms)) " ms/frame pump "
+140
(r1 (car (stats-ref st 'pump))) " dry " (number->string (stats-ref st 'dry-sounding))
+141
" rate " (number->string (mixer-rate)))))))))
src/harkfell/bench.sgladded
@@ -0,0 +1,224 @@
+1
;;; (harkfell bench) - The phone measurement A2 starts with (?bench).
+2
;;;
+3
;;; harkfell-design section 5 ("Budget, to measure, not assume") and section
+4
;;; 10 A2: the bed alone, the bed plus a moment player, a region-border
+5
;;; crossfade (three players), and the cost of opening a tune, on David's
+6
;;; phone at 32000, against a 4 ms music budget and the frame budget. The
+7
;;; game's room runs underneath as it would in play; nothing is asked of the
+8
;;; player but to leave the phone on the page.
+9
;;;
+10
;;; The other tunes are S0's (copied into assets/tunes): bed-reedfen (8 channels:
+11
;;; wind, water, reeds and the drone H2 H3 H4 H5 H7, EVERY channel unmuted:
+12
;;; the heaviest a bed gets, the end of the game with H7), moment-reedfen,
+13
;;; and bed-hollow as the second region's bed.
+14
;;;
+15
;;; The run, in 30 s phases, each measured over its last 25 s (from 5 s in):
+16
;;; a2bed A2's own bed as it ships (reedfen-bed: wind, water, reeds; the
+17
;;; drone's H2 and H3 notes dropped since nothing is carried)
+18
;;; a2moment the same, plus the fixed moment (first-open-water, looped)
+19
;;; and the duck on the bed's source gain
+20
;;; bed S0's full Reedfen bed, all 8 channels sounding (the heaviest
+21
;;; a bed gets: the end of the game, with H7)
+22
;;; moment the full bed plus the moment
+23
;;; border the full bed, the moment and a second region's bed
+24
;;; (bed-hollow), the beds at half each: three players
+25
;;; opens the full bed alone; every 3 s a parse and open (then a close)
+26
;;; of the moment, then of bed-hollow, three times each: one
+27
;;; open line each, with the frame's work and the dry frames of
+28
;;; the next 2.5 s
+29
;;; About 2.9 minutes in all. A2's two phases also run the finding tone
+30
;;; (tone-h2, at a distance's gain: it synthesises whatever its gain), as
+31
;;; play does. "music" is ms per 60 fps frame of audio (Crash's unit, the
+32
;;; budget's); a phone at 30 fps pays twice that per frame it draws, which
+33
;;; "per-second" (ms of synthesis per second of audio) states without a
+34
;;; frame rate.
+35
;;;
+36
;;; Lines (through say, "harkfell: bench ..."; the page beacons them):
+37
;;; bench start rate R state S
+38
;;; bench open NAME parse P open O work W dry D (ms, ms, the frame's work ms, starved frames in the next 2.5 s)
+39
;;; bench PHASE music M budget 4 per SRC/M/P95 ... pump P50/P95/MAX frame P50/P95/MAX work P50/P95/MAX over33 N frames N dry-sounding N dry-tail N
+40
;;; bench done
+41
+42
(define-library (harkfell bench)
+43
(import (sigil core)
+44
(sigil math)
+45
(sigil string)
+46
(sigil list)
+47
(sigil time)
+48
(motif tune)
+49
(motif tune player)
+50
(engine mixer)
+51
(harkfell tunes)
+52
(only (harkfell sound) strip-uncarried))
+53
+54
(export bench-start! bench-frame! bench-running? bench-done?)
+55
+56
(begin
+57
(define A2-BED "reedfen-bed")
+58
(define A2-MOMENT "first-open-water")
+59
(define TONE "tone-h2")
+60
(define BED-A "bed-reedfen")
+61
(define BED-B "bed-hollow")
+62
(define MOMENT "moment-reedfen")
+63
(define BUDGET-MS 4.0)
+64
+65
(define *on* #f)
+66
(define *done* #f)
+67
(define *say* #f)
+68
(define *t0* #f) ; the clock at the run's start (s)
+69
(define *step* 0) ; the next scheduled step
+70
(define *frames* '()) ; frame intervals (ms) in the window
+71
(define *works* '()) ; frame work (ms) in the window
+72
(define *opens* '()) ; ((name parse open work) ...) of the open series
+73
(define *pending-open* #f) ; (name parse open work dry-before t) awaiting its dry figure
+74
(define *players* '()) ; ((name . player) ...) open
+75
+76
(define (bench-running?) *on*)
+77
(define (bench-done?) *done*)
+78
+79
(define (now-ms) (/ (* 1000.0 (current-jiffy)) (jiffies-per-second)))
+80
(define (r1 x) (number->string (/ (round (* 10.0 x)) 10.0)))
+81
(define (n x) (number->string (exact (round x))))
+82
+83
(define (bench-start! say)
+84
(set! *on* #t)
+85
(set! *say* say)
+86
;; ask for the texts now (on the web the page fetches them)
+87
(for-each tune-text (list A2-BED A2-MOMENT TONE BED-A BED-B MOMENT)))
+88
+89
(define (ready?) (and (tune-text A2-BED) (tune-text A2-MOMENT) (tune-text TONE) (tune-text BED-A) (tune-text BED-B) (tune-text MOMENT)))
+90
+91
;; parse + open, timed separately. Answers (player parse-ms open-ms).
+92
(define (open-timed name loop?)
+93
(let* ((t0 (now-ms))
+94
(t (read-tune-string (tune-text name)))
+95
(t1 (now-ms))
+96
(p (open-tune-player t (mixer-rate) loop: loop?))
+97
(t2 (now-ms)))
+98
(list p (- t1 t0) (- t2 t1))))
+99
+100
(define (start-source! id name loop? work-at)
+101
(let* ((o (open-timed name loop?))
+102
(p (car o)))
+103
(set! *players* (cons (cons id p) *players*))
+104
(*say* "bench" "open" name "parse" (r1 (cadr o)) "open" (r1 (caddr o)) "live")
+105
(let ((s (mixer-add! id (lambda (out off k) (player-pull! p out off k)) #t)))
+106
s)))
+107
+108
(define (stop-source! id)
+109
(mixer-remove! id)
+110
(let ((e (assoc id *players*)))
+111
(when e (player-close! (cdr e)))
+112
(set! *players* (filter (lambda (x) (not (equal? (car x) id))) *players*))))
+113
+114
(define (reset-window!)
+115
(set! *frames* '())
+116
(set! *works* '())
+117
(mixer-stats-reset!))
+118
+119
(define (trip l) (list (median-of l) (percentile-of l 0.95) (if (null? l) 0.0 (apply max l))))
+120
(define (first3 l) (list (car l) (cadr l) (caddr l)))
+121
(define (slash l) (string-join (map r1 l) "/"))
+122
+123
(define (report! phase)
+124
(let* ((st (mixer-stats))
+125
(per (stats-ref st 'sources)))
+126
(apply *say*
+127
(append (list "bench" phase "music" (r1 (stats-ref st 'frame-ms)) "budget" (r1 BUDGET-MS))
+128
(apply append (map (lambda (p) (list "per" (string-append (car p) "/" (r1 (cadr p)) "/" (r1 (caddr p))))) per))
+129
;; the same cost as ms of synthesis per second of audio
+130
;; (under 1000 keeps up; independent of the frame rate)
+131
(list "per-second" (r1 (* 60.0 (stats-ref st 'frame-ms)))
+132
"pump" (slash (first3 (stats-ref st 'pump)))
+133
"frame" (slash (trip *frames*))
+134
"work" (slash (trip *works*))
+135
"over33" (n (length (filter (lambda (x) (> x 33.4)) *frames*)))
+136
"frames" (n (length *frames*))
+137
"dry-sounding" (n (stats-ref st 'dry-sounding))
+138
"dry-tail" (n (stats-ref st 'dry-tail)))))))
+139
+140
(define (dry-now) (stats-ref (mixer-stats) 'dry-sounding))
+141
+142
;; one open of the series: parse + open + close, with bed A sounding
+143
(define (series-open! name work)
+144
(let* ((o (open-timed name #t)))
+145
(player-close! (car o))
+146
(set! *pending-open* (list name (cadr o) (caddr o) (dry-now) (+ (mixer-now) 2.5)))))
+147
+148
;; (t . action), seconds from the start
+149
(define (duck! id) (source-factor! (mixer-find id) 'duck 0.5 2.0 (mixer-now)))
+150
(define STEPS
+151
(list (cons 0.0 (lambda () (mixer-stats-reset!) (start-a2-bed!)
+152
(source-factor! (start-source! "tone" TONE #t 0) 'dist 0.1 0.0 (mixer-now))))
+153
(cons 5.0 reset-window!)
+154
(cons 30.0 (lambda () (report! "a2bed") (start-source! "moment" A2-MOMENT #t 0) (duck! "a2")))
+155
(cons 35.0 reset-window!)
+156
(cons 60.0 (lambda () (report! "a2moment") (stop-source! "moment") (stop-source! "a2") (stop-source! "tone")
+157
(mixer-stats-reset!) (start-source! "bed-a" BED-A #t 0)))
+158
(cons 65.0 reset-window!)
+159
(cons 90.0 (lambda () (report! "bed") (start-source! "moment" MOMENT #t 0) (duck! "bed-a")))
+160
(cons 95.0 reset-window!)
+161
(cons 120.0 (lambda () (report! "moment")
+162
(start-source! "bed-b" BED-B #t 0)
+163
(source-factor! (mixer-find "bed-a") 'fade 0.5 4.0 (mixer-now))
+164
(source-factor! (mixer-find "bed-b") 'fade 0.0 0.0 (mixer-now))
+165
(source-factor! (mixer-find "bed-b") 'fade 0.5 4.0 (mixer-now))))
+166
(cons 125.0 reset-window!)
+167
(cons 150.0 (lambda () (report! "border")
+168
(stop-source! "moment")
+169
(stop-source! "bed-b")
+170
(source-factor! (mixer-find "bed-a") 'fade 1.0 1.0 (mixer-now))
+171
(source-factor! (mixer-find "bed-a") 'duck 1.0 1.0 (mixer-now))
+172
(reset-window!)))
+173
(cons 152.0 (lambda () (series-open! MOMENT 0)))
+174
(cons 155.0 (lambda () (series-open! BED-B 0)))
+175
(cons 158.0 (lambda () (series-open! MOMENT 0)))
+176
(cons 161.0 (lambda () (series-open! BED-B 0)))
+177
(cons 164.0 (lambda () (series-open! MOMENT 0)))
+178
(cons 167.0 (lambda () (series-open! BED-B 0)))
+179
(cons 170.0 (lambda () (report! "opens")
+180
(stop-source! "bed-a")
+181
(*say* "bench" "done")
+182
(set! *done* #t)
+183
(set! *on* #f)))))
+184
+185
;; A2's bed as the director opens it: nothing carried, so the drone's
+186
;; notes are dropped (a muted channel still synthesises).
+187
(define (start-a2-bed!)
+188
(let* ((t0 (now-ms))
+189
(t (strip-uncarried (read-tune-string (tune-text A2-BED)) '()))
+190
(t1 (now-ms))
+191
(p (open-tune-player t (mixer-rate) loop: #t))
+192
(t2 (now-ms)))
+193
(set! *players* (cons (cons "a2" p) *players*))
+194
(*say* "bench" "open" A2-BED "parse" (r1 (- t1 t0)) "open" (r1 (- t2 t1)) "live")
+195
(mixer-add! "a2" (lambda (out off k) (player-pull! p out off k)) #t)))
+196
+197
;; Every frame while running: now (s, the shell's clock), the frame's
+198
;; interval and its work (ms, the frame before this one: the work of the
+199
;; frame in progress is not known yet).
+200
(define (bench-frame! now frame-ms work-ms)
+201
(when *on*
+202
(cond
+203
((not *t0*)
+204
(when (ready?)
+205
(set! *t0* now)
+206
(*say* "bench" "start" "rate" (n (mixer-rate)) "state" (symbol->string (mixer-state)))))
+207
(else
+208
(set! *frames* (cons frame-ms *frames*))
+209
(set! *works* (cons work-ms *works*))
+210
;; the open series' dry figure, and the work of the frame that opened
+211
(when (and *pending-open* (= (length *pending-open*) 5))
+212
(set! *pending-open* (append *pending-open* (list work-ms))))
+213
(when (and *pending-open* (>= now (list-ref *pending-open* 4)) (= (length *pending-open* ) 6))
+214
(let ((p *pending-open*))
+215
(*say* "bench" "open" (list-ref p 0) "parse" (r1 (list-ref p 1)) "open" (r1 (list-ref p 2))
+216
"work" (r1 (list-ref p 5)) "dry" (n (- (dry-now) (list-ref p 3))))
+217
(set! *pending-open* #f)))
+218
(let loop ()
+219
(when (< *step* (length STEPS))
+220
(let ((s (list-ref STEPS *step*)))
+221
(when (>= (- now *t0*) (car s))
+222
(set! *step* (+ *step* 1))
+223
((cdr s))
+224
(loop)))))))))))
src/harkfell/ears.sgladded
@@ -0,0 +1,46 @@
+1
;;; (harkfell ears) - The listening ears (harkfell-design section 3, "The
+2
;;; loop" 2): standing still, the creature's ears rise and point toward the
+3
;;; strongest finding tone, the cue for a mono speaker and for a player who
+4
;;; cannot hear.
+5
;;;
+6
;;; Drawn over the body (A0's pale block with ears, (harkfell draw)'s
+7
;;; draw-body-at) in the room's target, after the room: the resting ears
+8
;;; are two 1x3 strokes at the box's top; listening they grow two pixels and
+9
;;; lean toward the tone (left, right), stand straight (up), or lie out flat
+10
;;; (below). In the colour of the world palette's fur-1 (A1's placeholder
+11
;;; #e8e0cf), so they read as the same ears grown, not a marker.
+12
;;;
+13
;;; (draw-ears b ox oy dir) b: the body; ox oy: the room's origin in world
+14
;;; pixels; dir: up | left | right | below | #f
+15
+16
(define-library (harkfell ears)
+17
(import (sigil core)
+18
(sigil math)
+19
(sigil graphics)
+20
(engine body))
+21
+22
(export draw-ears)
+23
+24
(begin
+25
(define (px x y) (draw-filled-rect x y 1 1))
+26
+27
(define (draw-ears b ox oy dir)
+28
(when dir
+29
(let ((x (- (exact (floor (- (body-x b) ox))) 1))
+30
(y (exact (floor (- (body-y b) oy)))))
+31
(set-color 0.910 0.878 0.812 1.0)
+32
(case dir
+33
((up)
+34
(draw-filled-rect (+ x 2) (- y 2) 1 5)
+35
(draw-filled-rect (+ x 7) (- y 2) 1 5))
+36
((left right)
+37
(let ((s (if (eq? dir 'left) -1 1)))
+38
(for-each (lambda (ex)
+39
(draw-filled-rect ex y 1 3)
+40
(px (+ ex s) (- y 1))
+41
(px (+ ex s s) (- y 2)))
+42
(list (+ x 2) (+ x 7)))))
+43
((below)
+44
(draw-filled-rect (- x 1) (+ y 3) 3 1)
+45
(draw-filled-rect (+ x 8) (+ y 3) 3 1))
+46
(else #f)))))))
src/harkfell/shell.sglmodified
@@ -56,7 +56,9 @@
56
(engine roomfile)
57
(harkfell content)
58
(harkfell check)
−59
(harkfell legend))
+59
(harkfell legend)
+60
(harkfell sound)
+61
(harkfell ears))
62
63
(export shell-boot! shell-frame! shell-present! shell-reset-world!
64
shell-key-down! shell-key-up! shell-keys-clear!
@@ -67,6 +69,7 @@
69
shell-load-content! shell-content shell-goto! shell-goto-start!
70
shell-test-room! shell-set-mode! shell-mode shell-set-still!
71
shell-tap! shell-replace-file! shell-replace-files! shell-room-label shell-view-size
+72
shell-set-extra-lines!
73
VIEW-W VIEW-H)
74
75
(begin
@@ -111,6 +114,10 @@
114
115
(define (now-ms) (/ (* 1000.0 (current-jiffy)) (jiffies-per-second)))
116
+117
;; A2: more readout lines (the sound's), from whoever sets them
+118
(define *extra-lines* (lambda () '()))
+119
(define (shell-set-extra-lines! proc) (set! *extra-lines* proc))
+120
121
(define (shell-world) *world*)
122
(define (shell-clock) *clock*)
123
@@ -455,7 +462,11 @@
462
(begin-frame)
463
(set-blend-mode 'normal)
464
(with-render-target *room-rt*
−458
(draw-play *world*))
+465
(draw-play *world*)
+466
;; A2: the listening ears, over the body while you stand still
+467
(when (and (world-room *world*) (sound-ears))
+468
(let ((o (world-origin *world*)))
+469
(draw-ears (world-body *world*) (car o) (cdr o) (sound-ears)))))
470
(if (eq? (fit-mode f) 'integer)
471
(draw-render-target *room-rt* (fit-x f) (fit-y f) (fit-w f) (fit-h f))
472
(begin
@@ -471,7 +482,8 @@
482
(number->string (fit-scale f))
483
(tenths (fit-scale f)))
484
" stick " (symbol->string (shell-stick-form))))
−474
(shell-latency-lines))
+485
(shell-latency-lines)
+486
(*extra-lines*))
487
(fit-x f) (fit-y f) (fit-w f)
488
(max 1 (exact (floor (/ (fit-scale f) 2))))))
489
(end-frame)
src/harkfell/shell/native.sglmodified
@@ -50,7 +50,8 @@
50
(harkfell feel)
51
(harkfell source)
52
(harkfell cli)
−53
(harkfell fixtures))
+53
(harkfell fixtures)
+54
(harkfell audio))
55
56
(export native-main)
57
@@ -136,10 +137,15 @@
137
(guard (x (#t (say "harkfell: live reload skipped a look: "
138
(if (error-object? x) (error-object-message x) "an error"))))
139
(check-reload!)))
−139
(let ((ticks (shell-frame! dt)))
−140
(shell-present! (frame-width) (frame-height) ticks))
+140
(let* ((w0 (now-ms))
+141
(ticks (shell-frame! dt)))
+142
(audio-frame! (* 1000.0 dt) *work-ms*)
+143
(shell-present! (frame-width) (frame-height) ticks)
+144
(set! *work-ms* (- (now-ms) w0)))
145
(unless (quit-requested?) (frame (+ n 1)))))))
146
+147
(define *work-ms* 0.0) ; A2: the last frame's work (the bench reads it)
+148
149
;; Loads the world; enters --room's door or the start. #f (after saying
150
;; why) when there is nothing to play.
151
(define (enter-world! args)
@@ -151,6 +157,7 @@
157
(key (arg-value args "--room")))
158
(for-each (lambda (p) (say "harkfell: " p)) ps)
159
(set! *mtimes* (source-mtimes dir))
+160
(audio-world! (shell-content))
161
(cond ((and key (let* ((a (arg-value args "--at"))
162
(cr (and a (map string->number (string-split a ","))))
163
(at (and cr (= (length cr) 2) (car cr) (cadr cr) (cons (car cr) (cadr cr)))))
@@ -173,6 +180,12 @@
180
(else
181
(shell-boot!)
182
(shell-set-ms! (and (member "--ms" args) #t))
+183
;; A2: the sound; --bench runs the phone measurement natively
+184
(audio-boot! (lambda parts (say (apply string-append "harkfell:" (map (lambda (p) (string-append " " p)) parts)))))
+185
(when (member "--bench" args) (audio-bench!))
+186
(let ((c (arg-value args "--carry")) (t (arg-value args "--tone")))
+187
(when c (audio-carry! c))
+188
(when t (audio-tone-room! t)))
189
(if (or (member "--test-room" args) (enter-world! args))
190
(begin
191
(run-game "Harkfell" WINDOW-W WINDOW-H (loop))
src/harkfell/shell/web.sglmodified
@@ -33,6 +33,12 @@
33
;;; ("tap", "X,Y") a click or tap in canvas pixels (the atlas and the
34
;;; sheet enter the room under it)
35
;;; ("test-room", "") ?test-room: A0's grey room
+36
;;; ("gesture", NAME) a user gesture: resume the audio context (A2)
+37
;;; ("bench", "") ?bench: the phone measurement ((harkfell bench))
+38
;;; ("carry", "2,3") ?carry=: the harmonics carried (their drone voices sound)
+39
;;; ("tone", "X,Y") ?tone=: the room H2's finding tone sounds from
+40
;;; ("tune-chunk", TEXT) part of a tune's text, then
+41
;;; ("tune-done", NAME) the text is complete (the answer to a fetch line)
42
;;;
43
;;; Trace lines (with ?trace, on the console):
44
;;; harkfell: boot stick FORM the stick form in force
@@ -47,6 +53,11 @@
53
;;; harkfell: problem FILE:LINE: ... one per problem (printed always)
54
;;; harkfell: room REGION:X,Y the body entered a room (with ?trace,
55
;;; and always after a goto or a tap)
+56
;;; harkfell: audio RATE STATE the mixer opened (always)
+57
;;; harkfell: fetch NAME a tune is wanted: the page fetches
+58
;;; assets/tunes/NAME.cts (always)
+59
;;; harkfell: bench ... the measurement's lines (always; see
+60
;;; (harkfell bench))
61
;;; The replay and envelope lines print whether or not ?trace is on:
62
;;; they are asked for.
63
@@ -61,7 +72,9 @@
72
(harkfell shell)
73
(harkfell replay)
74
(harkfell envelope)
−64
(harkfell feel))
+75
(harkfell feel)
+76
(harkfell tunes)
+77
(harkfell audio))
78
79
(export web-main web-dispatch)
80
@@ -71,6 +84,7 @@
84
(define *returns* 0)
85
(define *world-files* '()) ; the page's files, until world-done
86
(define *room* #f) ; the last room label traced
+87
(define *work-ms* 0.0) ; A2: the last frame's work (the bench reads it)
88
89
(define (say . parts)
90
(println "~a" (apply string-append "harkfell:" (map (lambda (p) (string-append " " p)) parts))))
@@ -98,9 +112,11 @@
112
113
(define (web-tick payload)
114
(let* ((dt (frame-dt payload))
−101
(ms (string->number payload)))
+115
(ms (string->number payload))
+116
(w0 (now-ms)))
117
(shell-set-frame-stamp! ms)
118
(let ((ticks (shell-frame! dt)))
+119
(audio-frame! (* 1000.0 dt) *work-ms*)
120
(when (and *trace* (not (= (world-returns (shell-world)) *returns*)))
121
(set! *returns* (world-returns (shell-world)))
122
(say "returns" (number->string *returns*)))
@@ -108,7 +124,8 @@
124
(set! *room* (shell-room-label))
125
(say "room" *room*))
126
(gles3-resize-to-display)
−111
(shell-present! (gles3-canvas-width) (gles3-canvas-height) ticks))))
+127
(shell-present! (gles3-canvas-width) (gles3-canvas-height) ticks)
+128
(set! *work-ms* (- (now-ms) w0)))))
129
130
;; "A,B,C" -> a list of numbers (#f for a field that is not one).
131
(define (fields payload)
@@ -122,6 +139,10 @@
139
(set-viewport #f)
140
(set-letterbox-color 0.0 0.0 0.0)
141
(shell-boot!)
+142
;; A2: the sound. A tune the wasm lacks is asked of the page
+143
;; ("harkfell: fetch NAME"; it answers with tune-chunk and tune-done).
+144
(tunes-set-want! (lambda (name) (say "fetch" name)))
+145
(audio-boot! say)
146
(gles3-start-loop "harkfell-web-tick")
147
0)
148
@@ -201,6 +222,7 @@
222
(for-each (lambda (p) (say "problem" p)) ps)
223
(set! *world-files* '())
224
(shell-goto-start!)
+225
(audio-world! (shell-content))
226
(if (null? ps) 1 0)))
227
((string=? type "goto")
228
(if (let* ((parts (string-split payload "@"))
@@ -227,4 +249,11 @@
249
(when r (set! *room* r) (say "room" r))
250
(if r 1 0)))
251
((string=? type "test-room") (shell-test-room!) 0)
+252
;; A2
+253
((string=? type "gesture") (audio-gesture!) 0)
+254
((string=? type "bench") (audio-bench!) 0)
+255
((string=? type "carry") (audio-carry! payload) 0)
+256
((string=? type "tone") (audio-tone-room! payload) 0)
+257
((string=? type "tune-chunk") (tunes-chunk! payload) 0)
+258
((string=? type "tune-done") (tunes-done! payload) 0)
259
(else 0)))))
src/harkfell/sound.sgladded
@@ -0,0 +1,620 @@
+1
;;; (harkfell sound) - The sound of the place: the director (A2).
+2
;;;
+3
;;; harkfell-design section 5, as built into the game:
+4
;;;
+5
;;; THE BED. One motif player per region plays the region's bed tune (the
+6
;;; region's bed: field in world/world.sgl). Its channels are grouped the way
+7
;;; the room files name them: the region's bed-channels: list, whose first
+8
;;; three names are channels 1, 2 and 3 (Reedfen: wind water reeds) and whose
+9
;;; drone is DRONE-CHANNELS (4 H2, 5 H3, 6 H4, 7 H5, 8 H7; motif tunes have
+10
;;; at most 8 channels, so the fundamental, under the Hollow, is not a bed
+11
;;; channel). A2's Reedfen bed has two drone voices, H2 and H3.
+12
;;; On entering a room every channel RAMPS to the room's level over
+13
;;; ROOM-RAMP-S with player-ramp! (channel-volume . ch); a group the room does
+14
;;; not name goes to DEFAULT-LEVELS. A drone channel for a harmonic not
+15
;;; carried is muted (A3's pickups unmute them; ?carry= does it for
+16
;;; listening). Entering another region's room crossfades the beds over
+17
;;; BORDER-S.
+18
;;;
+19
;;; THE MOMENT. A room's fixed moment (moment: (tune: NAME ...)) is opened
+20
;;; ahead (sound-preopen!, at boot: an open is a frame-thread stall) and
+21
;;; started on the first visit the hush allows ((harkfell hush)). The bed
+22
;;; ducks DUCK-GAIN (6 dB) over DUCK-IN-S on its SOURCE GAIN, the mixer
+23
;;; factor 'duck, never on its channel volumes: player-ramp! keeps one ramp
+24
;;; per target, so a duck made of channel ramps would be wiped out by the next
+25
;;; room's levels (the gate in test/test-sound.sgl holds this). The moment
+26
;;; plays to its end and motif's release tail; the bed comes back over
+27
;;; DUCK-OUT-S from when its end is heard. Leaving the room does not stop
+28
;;; it; walking into a room whose own fixed moment is still armed fades it
+29
;;; out over FADE-S (and that room's moment stays armed: the hush counts
+30
;;; from the fade).
+31
;;;
+32
;;; LISTENING. Each unfound harmonic is a finding tone at its place (A2 has
+33
;;; H2's, at a placeholder room until A3 places the pickup): louder as you
+34
;;; get closer (TONE-DB-PER-ROOM), silent past TONE-ROOMS rooms (D22),
+35
;;; panned toward it (at most 60%, the speakers-first rule), brighter above
+36
;;; you and darker below. Standing still STILL-S seconds dips the bed 6 dB
+37
;;; (factor 'still), brings the tone forward 4 dB, and raises the ears toward
+38
;;; the strongest tone (sound-ears); moving brings the bed back.
+39
;;;
+40
;;; SPOT SOUNDS. One-shot cues rendered at boot from short tunes, scheduled
+41
;;; with a rate range each: the bellfrogs of the room croak from where they
+42
;;; sit (panned by x, quieter with distance), reed birds call in rooms with
+43
;;; reeds, water plops in rooms with water. The soft return has its low tone.
+44
;;;
+45
;;; (sound-boot! content say) the world is loaded: beds known, cues queued
+46
;;; (sound-frame! now dt place) every frame in play (place: the place struct, below)
+47
;;; (sound-set-carry! hs) harmonics carried: (2 3 ...)
+48
;;; (sound-set-tone-room! x y) where H2's finding tone is (a door for listening)
+49
;;; (sound-ears) #f, or up | left | right | below: the ears' point
+50
;;; (sound-lines) the readout's lines
+51
;;; (sound-hush) (sound-bed-levels) (sound-moment-playing) for the gates
+52
+53
(define-library (harkfell sound)
+54
(import (sigil core)
+55
(sigil math)
+56
(sigil struct)
+57
(sigil list)
+58
(sigil string)
+59
(motif tune)
+60
(motif tune player)
+61
(engine mixer)
+62
(harkfell tunes)
+63
(harkfell hush))
+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
+68
place place? place-key place-region place-px place-py place-rx place-ry place-moment place-frogs place-levels
+69
bed-channels-of group-channels
+70
DUCK-GAIN DUCK-IN-S DUCK-OUT-S ROOM-RAMP-S BORDER-S FADE-S STILL-S
+71
DRONE-CHANNELS DEFAULT-LEVELS BED-ID MOMENT-ID TONE-ID)
+72
+73
(begin
+74
(define DUCK-GAIN 0.5) ; -6 dB
+75
(define DUCK-IN-S 2.0)
+76
(define DUCK-OUT-S 4.0)
+77
(define ROOM-RAMP-S 2.0)
+78
(define BORDER-S 4.0)
+79
(define FADE-S 2.0)
+80
(define STILL-S 1.0) ; standing still this long sharpens listening
+81
(define STILL-IN-S 1.0)
+82
(define STILL-OUT-S 1.5)
+83
(define TONE-GAIN 0.63) ; the tone in its own room, at rest
+84
(define TONE-FORWARD 1.585) ; +4 dB while still (the product is held under 1)
+85
(define TONE-ROOMS 3.0) ; D22: audible from up to three rooms away
+86
(define TONE-DB-PER-ROOM 4.0)
+87
(define ROOM-W 400.0) ; a room in pixels (25 x 11 tiles of 16)
+88
(define ROOM-H 176.0)
+89
(define MAX-PAN 0.6)
+90
(define DRONE-CHANNELS '(4 5 6 7 8))
+91
(define HARMONIC-CHANNEL '((2 . 4) (3 . 5) (4 . 6) (5 . 7) (7 . 8)))
+92
(define DEFAULT-LEVELS '((wind . 0.5) (water . 0.4) (reeds . 0.4) (texture . 0.4) (drone . 0.6)))
+93
(define BED-ID "bed")
+94
(define MOMENT-ID "moment")
+95
(define TONE-ID "tone-h2")
+96
(define TONE-TUNE "tone-h2")
+97
+98
;; What the director is told each frame about where the player is.
+99
;; key the room's key (any equal?-comparable value), or #f outside the world
+100
;; region the region's symbol; bed-tune its bed's tune name; groups its bed-channels
+101
;; levels the room's bed: as an alist ((wind . 0.4) ...)
+102
;; moment the room's fixed moment's tune name, or #f
+103
;; frogs the room's bellfrogs' x positions in room pixels
+104
;; px py the body's centre in world pixels; rx ry the room's coordinate
+105
;; still the body is at rest this frame
+106
;; returns soft returns so far
+107
(define-struct place
+108
(key default: #f) (region default: #f) (bed-tune default: #f) (groups default: '())
+109
(levels default: '()) (moment default: #f) (frogs default: '()) (water default: #f)
+110
(px default: 0.0) (py default: 0.0) (rx default: 0) (ry default: 0)
+111
(still default: #f) (returns default: 0))
+112
+113
;; --- state ---------------------------------------------------------------------
+114
(define *say* (lambda parts #f))
+115
(define *hush* (hush-new))
+116
(define *key* #f)
+117
(define *region* #f)
+118
(define *bed-tune* #f)
+119
(define *room-levels* '())
+120
(define *groups* '())
+121
(define *bed-player* #f)
+122
(define *bed-src* #f)
+123
(define *levels* '()) ; ((ch . #(from to t0 dur)) ...): what the channels were asked for
+124
(define *carry* '()) ; harmonics carried
+125
(define *moments* '()) ; ((name . player) ...) opened ahead
+126
(define *moment* #f) ; (name . player) playing
+127
(define *moment-key* #f)
+128
(define *tone-player* #f)
+129
(define *tone-room* (cons 11 3)) ; PLACEHOLDER: east of A1's six rooms, until A3 places H2
+130
(define *tone-cutoff* #f)
+131
(define *pan* (cons 1.0 1.0)) ; the tone's L R gains
+132
(define *still-for* 0.0)
+133
(define *still* #f)
+134
(define *ears* #f)
+135
(define *returns* 0)
+136
(define *cues* '()) ; ((name . cue) ...)
+137
(define *cue-queue* '()) ; names still to render
+138
(define *next* '()) ; ((spot . t) ...) when each spot sound is next due
+139
(define *now* 0.0)
+140
(define *seed* 20260924)
+141
+142
(define (sound-hush) *hush*)
+143
(define (sound-moment-playing) (and *moment* (car *moment*)))
+144
(define (sound-ears) *ears*)
+145
;; A change of what is carried reopens the bed on the next frame (its
+146
;; drone notes are chosen at open: strip-uncarried).
+147
(define *carry-changed* #f)
+148
(define (sound-set-carry! hs)
+149
(unless (equal? hs *carry*)
+150
(set! *carry* hs)
+151
(set! *carry-changed* #t)))
+152
(define (sound-set-tone-room! x y) (set! *tone-room* (cons x y)))
+153
(define (sound-set-rng! seed) (set! *seed* seed))
+154
+155
;; MINSTD (only modulo is bound here): a number in [0, 1)
+156
(define (rand!)
+157
(set! *seed* (modulo (* *seed* 48271) 2147483647))
+158
(/ *seed* 2147483647.0))
+159
(define (rand-in lo hi) (+ lo (* (- hi lo) (rand!))))
+160
+161
;; Back to nothing playing (tests; a world reload).
+162
(define (sound-reset!)
+163
(for-each (lambda (s) (mixer-remove! (source-id s))) (mixer-sources))
+164
(when *bed-player* (player-close! *bed-player*))
+165
(when *moment* (player-close! (cdr *moment*)))
+166
(for-each (lambda (m) (player-close! (cdr m))) *moments*)
+167
(when *tone-player* (player-close! *tone-player*))
+168
(set! *hush* (hush-new)) (set! *key* #f) (set! *region* #f) (set! *groups* '())
+169
(set! *bed-player* #f) (set! *bed-src* #f) (set! *levels* '()) (set! *moments* '())
+170
(set! *moment* #f) (set! *moment-key* #f) (set! *tone-player* #f) (set! *tone-cutoff* #f)
+171
(set! *still-for* 0.0) (set! *still* #f) (set! *ears* #f) (set! *next* '()) (set! *waiting* #f))
+172
+173
;; --- channels and levels ------------------------------------------------------------
+174
;; groups: the region's bed-channels, e.g. (wind water reeds drone)
+175
(define (bed-channels-of groups)
+176
(let loop ((gs groups) (ch 1) (acc '()))
+177
(cond ((null? gs) (reverse acc))
+178
((eq? (car gs) 'drone) (loop (cdr gs) ch (cons (cons 'drone DRONE-CHANNELS) acc)))
+179
(else (loop (cdr gs) (+ ch 1) (cons (list (car gs) ch) acc))))))
+180
+181
(define (group-channels groups g)
+182
(let ((e (assq g (bed-channels-of groups)))) (if e (cdr e) '())))
+183
+184
(define (level-now ch now)
+185
(let ((e (assv ch *levels*)))
+186
(if (not e)
+187
1.0
+188
(let* ((r (cdr e)) (from (vector-ref r 0)) (to (vector-ref r 1)) (t0 (vector-ref r 2)) (dur (vector-ref r 3)))
+189
(cond ((<= now t0) from)
+190
((or (<= dur 0.0) (>= now (+ t0 dur))) to)
+191
(else (+ from (* (- to from) (/ (- now t0) dur)))))))))
+192
+193
;; Every channel's current target: ((ch . level) ...), for the gates and the readout.
+194
(define (sound-bed-levels) (map (lambda (e) (cons (car e) (vector-ref (cdr e) 1))) *levels*))
+195
+196
(define (room-level levels g)
+197
(let ((e (assq g levels)))
+198
(cond (e (cdr e))
+199
((assq g DEFAULT-LEVELS) (cdr (assq g DEFAULT-LEVELS)))
+200
(else 0.5))))
+201
+202
;; Ramp every channel of the bed to the room's levels.
+203
(define (apply-room-levels! levels now secs)
+204
(when *bed-player*
+205
(for-each
+206
(lambda (gc)
+207
(let ((v (room-level levels (car gc))))
+208
(for-each (lambda (ch)
+209
(let ((from (level-now ch now)))
+210
(player-ramp! *bed-player* (cons 'channel-volume ch) from v (* 1000.0 secs))
+211
(set! *levels* (cons (cons ch (vector from v now secs))
+212
(filter (lambda (e) (not (eqv? (car e) ch))) *levels*)))))
+213
(cdr gc))))
+214
(bed-channels-of *groups*))))
+215
+216
;; The bed as opened: the notes of every drone channel whose harmonic is
+217
;; not carried are dropped. Measured (native dev build, 20 s of reedfen-bed
+218
;; at 32000): as written 6.6 s of pull, channels 4 and 5 MUTED 6.4 s (a
+219
;; muted channel still synthesises), their notes dropped 4.9 s.
+220
(define (strip-uncarried t carry)
+221
(let ((drop (filter-map* (lambda (hc) (and (not (memv (car hc) carry)) (cdr hc))) HARMONIC-CHANNEL)))
+222
(tune-with-patterns t
+223
(map (lambda (p)
+224
(tune-pattern p cells: (filter (lambda (c) (not (and (memv (cell-channel c) drop) (number? (cell-note c)))))
+225
(tune-pattern-cells p))))
+226
(tune-patterns t)))))
+227
+228
(define (filter-map* f l)
+229
(let loop ((l l) (acc '()))
+230
(if (null? l) (reverse acc) (let ((v (f (car l)))) (loop (cdr l) (if v (cons v acc) acc))))))
+231
+232
(define (apply-mutes!)
+233
(when *bed-player*
+234
(for-each (lambda (hc) (player-mute! *bed-player* (cdr hc) (not (memv (car hc) *carry*))))
+235
HARMONIC-CHANNEL)))
+236
+237
;; --- opening -------------------------------------------------------------------------
+238
(define (open name loop?)
+239
(let ((t (tune-parsed name)))
+240
(and t (guard (e (#t (*say* "sound" "error" "open" name) #f))
+241
(open-tune-player t (mixer-rate) loop: loop?)))))
+242
+243
(define (open-bed name)
+244
(let ((t (tune-parsed name)))
+245
(and t (guard (e (#t (*say* "sound" "error" "open" name) #f))
+246
(open-tune-player (strip-uncarried t *carry*) (mixer-rate) loop: #t)))))
+247
+248
(define (pull-of p) (lambda (out off n) (player-pull! p out off n)))
+249
+250
;; A room's fixed moment, opened ahead (the first open of a tune is a
+251
;; frame-thread stall; do it while nothing is at stake).
+252
(define (sound-preopen! name)
+253
(unless (assoc name *moments*)
+254
(let ((p (open name #f)))
+255
(when p (set! *moments* (cons (cons name p) *moments*))))))
+256
+257
(define (take-moment! name)
+258
(let ((e (assoc name *moments*)))
+259
(if e
+260
(begin (set! *moments* (filter (lambda (m) (not (eq? m e))) *moments*)) (cdr e))
+261
(open name #f))))
+262
+263
;; --- the bed ---------------------------------------------------------------------------
+264
;; A new bed for the region (a region entered, or what is carried changed):
+265
;; the old one crossfades out over BORDER-S under an id of its own, keeping
+266
;; its sink and its factors (its duck, its stillness); the new one fades in.
+267
;; A bed that will not open leaves the old one playing as it was (review M2).
+268
(define *outs* 0)
+269
(define (open-region-bed! region groups bed-tune levels now)
+270
(let* ((old-src *bed-src*) (old-player *bed-player*) (first? (not old-src))
+271
(p (and bed-tune (open-bed bed-tune))))
+272
(if (not p)
+273
(*say* "sound" "error" "no-bed" (if bed-tune bed-tune "none"))
+274
(begin
+275
(set! *region* region)
+276
(set! *groups* groups)
+277
(set! *bed-tune* bed-tune)
+278
(set! *levels* '())
+279
(set! *bed-player* p)
+280
(when old-src
+281
(set! *outs* (+ *outs* 1))
+282
(let ((out-id (string-append BED-ID "-out-" (number->string *outs*))))
+283
(mixer-rename! BED-ID out-id)
+284
(source-factor! old-src 'fade 0.0 BORDER-S now)
+285
(mixer-at! (+ now BORDER-S) (lambda () (mixer-remove! out-id) (player-close! old-player)))))
+286
(set! *bed-src* (mixer-add! BED-ID (pull-of p) #t))
+287
(new-bed-levels! levels now first?)))))
+288
+289
(define (enter-region! pl now)
+290
(open-region-bed! (place-region pl) (place-groups pl) (place-bed-tune pl) (place-levels pl) now))
+291
+292
(define (new-bed-levels! levels now first?)
+293
(let ((pl-levels levels))
+294
(when *bed-src*
+295
;; channels start at the room's levels (no ramp from 1), the
+296
;; source fades in (the first bed over a second, a border over BORDER-S)
+297
(apply-room-levels! pl-levels now 0.0)
+298
(apply-mutes!)
+299
(source-factor! *bed-src* 'fade 0.0 0.0 now)
+300
(source-factor! *bed-src* 'fade 1.0 (if first? 1.0 BORDER-S) now)
+301
(when (and *moment* (mixer-find MOMENT-ID) (source-live? (mixer-find MOMENT-ID)))
+302
(source-factor! *bed-src* 'duck DUCK-GAIN 0.0 now))
+303
(when *still* (source-factor! *bed-src* 'still DUCK-GAIN 0.0 now)))))
+304
+305
;; --- moments ---------------------------------------------------------------------------
+306
(define (start-moment! name key now)
+307
(let ((p (take-moment! name)))
+308
(when p
+309
(set! *moment* (cons name p))
+310
(set! *moment-key* key)
+311
(set! *hush* (hush-started *hush* name key))
+312
(*say* "sound" "moment" name "start")
+313
(let ((s (mixer-add! MOMENT-ID (pull-of p) #t)))
+314
(duck-bed! now)
+315
(source-on-end! s (lambda (t)
+316
(when (and *moment* (eq? (cdr *moment*) p))
+317
(moment-over! t)
+318
(*say* "sound" "moment" name "end"))
+319
(player-close! p)))))))
+320
+321
;; The duck: the bed's SOURCE gain, never its channels (see the header).
+322
(define (duck-bed! now)
+323
(when *bed-src* (source-factor! *bed-src* 'duck DUCK-GAIN DUCK-IN-S now)))
+324
+325
(define (unduck-bed! t)
+326
(when *bed-src* (source-factor! *bed-src* 'duck 1.0 DUCK-OUT-S t)))
+327
+328
(define (moment-over! t)
+329
(set! *moment* #f)
+330
(set! *moment-key* #f)
+331
(set! *hush* (hush-ended *hush*))
+332
(unduck-bed! t))
+333
+334
;; Walking into a room whose own fixed moment is still armed: the playing
+335
;; moment fades out and leaves.
+336
(define (fade-moment! now)
+337
(let ((s (mixer-find MOMENT-ID)) (m *moment*))
+338
(when (and s m)
+339
(*say* "sound" "moment" (car m) "fade")
+340
(source-factor! s 'fade 0.0 FADE-S now)
+341
(source-on-end! s #f)
+342
(moment-over! now)
+343
(mixer-at! (+ now FADE-S) (lambda () (mixer-remove! MOMENT-ID) (player-close! (cdr m)))))))
+344
+345
;; --- a room entered ----------------------------------------------------------------------
+346
(define (enter-room! pl now)
+347
(set! *key* (place-key pl))
+348
(set! *hush* (hush-enter *hush* (place-key pl)))
+349
(set! *next* '())
+350
(set! *room-levels* (place-levels pl))
+351
(if (not (eq? (place-region pl) *region*))
+352
(enter-region! pl now)
+353
(apply-room-levels! (place-levels pl) now ROOM-RAMP-S))
+354
(let ((m (place-moment pl)))
+355
(when m
+356
(let ((d (hush-fixed-decision *hush* (place-key pl))))
+357
(cond ((and *moment* (not (eq? d 'spent))) (fade-moment! now))
+358
((eq? d 'play) (start-moment! m (place-key pl) now))
+359
(else #f))
+360
(*say* "sound" "room" "moment" m (symbol->string d))))))
+361
+362
;; --- listening: the tone, stillness, the ears ----------------------------------------------
+363
(define (clamp lo hi x) (max lo (min hi x)))
+364
+365
(define (tone-geometry pl)
+366
;; the tone's place: the centre of its room, in world pixels
+367
(let* ((tx (+ (* ROOM-W (car *tone-room*)) (/ ROOM-W 2.0)))
+368
(ty (+ (* ROOM-H (cdr *tone-room*)) (/ ROOM-H 2.0)))
+369
(dx (/ (- tx (place-px pl)) ROOM-W))
+370
(dy (/ (- ty (place-py pl)) ROOM-H)))
+371
(list dx dy (sqrt (+ (* dx dx) (* dy dy))))))
+372
+373
(define (tone-gain d)
+374
(cond ((> d (+ TONE-ROOMS 0.5)) 0.0)
+375
(else (let ((g (* TONE-GAIN (expt 10.0 (/ (* -1.0 TONE-DB-PER-ROOM (max 0.0 (- d 0.5))) 20.0)))))
+376
;; the last half room fades to nothing
+377
(if (> d TONE-ROOMS) (* g (- 1.0 (* 2.0 (- d TONE-ROOMS)))) g)))))
+378
+379
;; brighter above you, darker below, darker with distance: a low-pass
+380
;; corner in Hz, or #f for none (in the same room). Done by a one-pole
+381
;; low-pass in the tone's own pull, not with player-set-instrument-param!:
+382
;; that swaps a fresh voice graph in mid-note (the note is held from row 0,
+383
;; so the fresh envelope re-attacks) and keeps every old graph in the
+384
;; player's roots (review M6).
+385
(define (tone-cutoff d dy)
+386
(let ((base (cond ((< d 0.75) #f) ((< d 1.75) 2000.0) (else 900.0))))
+387
(and base
+388
(cond ((< dy -0.5) (* base 1.5))
+389
((> dy 0.5) (* base 0.6))
+390
(else base)))))
+391
+392
;; the one-pole's coefficient for a corner (1.0: no filtering), eased
+393
;; toward its target each frame so a change of band is not a step
+394
(define *tone-a* 1.0)
+395
(define *tone-a-target* 1.0)
+396
(define *lp-l* 0.0)
+397
(define *lp-r* 0.0)
+398
(define (corner->a fc)
+399
(if fc (- 1.0 (exp (/ (* -2.0 3.14159265 fc) (mixer-rate)))) 1.0))
+400
+401
(define (tone-pull p)
+402
(lambda (out off n)
+403
(and (player-pull! p out off n)
+404
(let ((gl (car *pan*)) (gr (cdr *pan*)) (a *tone-a*))
+405
(let loop ((i 0) (yl *lp-l*) (yr *lp-r*))
+406
(if (< i n)
+407
(let* ((o (+ off (* 8 i)))
+408
(yl (+ yl (* a (- (bytevector-ieee-single-ref out o 'little) yl))))
+409
(yr (+ yr (* a (- (bytevector-ieee-single-ref out (+ o 4) 'little) yr)))))
+410
(bytevector-ieee-single-set! out o (* gl yl) 'little)
+411
(bytevector-ieee-single-set! out (+ o 4) (* gr yr) 'little)
+412
(loop (+ i 1) yl yr))
+413
(begin (set! *lp-l* yl) (set! *lp-r* yr))))
+414
#t))))
+415
+416
(define (close-tone!)
+417
(mixer-remove! TONE-ID)
+418
(when *tone-player* (player-close! *tone-player*))
+419
(set! *tone-player* #f))
+420
+421
(define (tone-frame! pl now)
+422
(when (and (not (memv 2 *carry*)) (not *tone-player*) (tune-text TONE-TUNE))
+423
(set! *tone-player* (open TONE-TUNE #t))
+424
(when *tone-player*
+425
(let ((s (mixer-add! TONE-ID (tone-pull *tone-player*) #t)))
+426
(source-factor! s 'dist 0.0 0.0 now))))
+427
(let ((s (mixer-find TONE-ID)))
+428
(when s
+429
(if (memv 2 *carry*)
+430
;; carried: the tone fades and its player closes (review M5)
+431
(unless (> (source-factor s 'dist now) 0.99)
+432
(source-factor! s 'dist 0.0 0.5 now)
+433
(let ((p *tone-player*))
+434
(mixer-at! (+ now 0.6) (lambda () (when (eq? p *tone-player*) (close-tone!))))))
+435
(let* ((g (tone-geometry pl)) (dx (car g)) (dy (cadr g)) (d (caddr g))
+436
(pan (clamp (- MAX-PAN) MAX-PAN (* 0.6 dx))))
+437
(source-factor! s 'dist (tone-gain d) 0.25 now)
+438
;; equal power, at most MAX-PAN toward it; neither side above unity
+439
(let ((a (* 0.25 3.14159265 (+ pan 1.0))))
+440
(set! *pan* (cons (min 1.0 (* 1.41421356 (cos a))) (min 1.0 (* 1.41421356 (sin a))))))
+441
(set! *tone-a-target* (corner->a (tone-cutoff d dy)))
+442
(set! *tone-a* (+ *tone-a* (* 0.1 (- *tone-a-target* *tone-a*)))))))))
+443
+444
(define (still-frame! pl dt now)
+445
(set! *still-for* (if (place-still pl) (+ *still-for* dt) 0.0))
+446
(let ((now-still (>= *still-for* STILL-S)))
+447
(unless (eq? now-still *still*)
+448
(set! *still* now-still)
+449
(when *bed-src*
+450
(source-factor! *bed-src* 'still (if now-still DUCK-GAIN 1.0) (if now-still STILL-IN-S STILL-OUT-S) now))
+451
(let ((s (mixer-find TONE-ID)))
+452
(when s (source-factor! s 'still (if now-still TONE-FORWARD 1.0) (if now-still STILL-IN-S STILL-OUT-S) now)))))
+453
(set! *ears*
+454
(and *still*
+455
(let* ((g (tone-geometry pl)) (dx (car g)) (dy (cadr g)) (d (caddr g)))
+456
(cond ((or (memv 2 *carry*) (> d (+ TONE-ROOMS 0.5)) (< d 0.3)) 'up)
+457
((>= (abs dx) (abs dy)) (if (> dx 0) 'right 'left))
+458
((< dy 0) 'up)
+459
(else 'below))))))
+460
+461
;; --- spot sounds ---------------------------------------------------------------------------
+462
;; (name tune lo hi gain): rendered from assets/tunes/TUNE.cts at boot
+463
(define SPOTS
+464
'((frog "spot-frog" 5.0 14.0 0.55)
+465
(bird "spot-bird" 14.0 32.0 0.35)
+466
(plop "spot-plop" 7.0 18.0 0.4)
+467
(return "return-tone" 0.0 0.0 0.5)))
+468
+469
(define (spot-ref name) (assq name SPOTS))
+470
+471
;; Render the queued cues a few blocks a frame (review M7: a whole cue at
+472
;; once, its notes and motif's 2 s release tail, was one long frame each).
+473
(define CUE-BLOCK 1024)
+474
(define CUE-BLOCKS-PER-FRAME 4)
+475
(define *cue-job* #f) ; #(name player chunks frames) being rendered
+476
(define *cue-buf* (make-bytevector (* 8 CUE-BLOCK) 0))
+477
+478
(define (render-next-cue!)
+479
(when (and (not *cue-job*) (pair? *cue-queue*))
+480
(let* ((name (car *cue-queue*))
+481
(spec (spot-ref name))
+482
(p (and spec (open (cadr spec) #f))))
+483
(set! *cue-queue* (cdr *cue-queue*))
+484
(when p (set! *cue-job* (vector name p '() 0)))))
+485
(when *cue-job*
+486
(let* ((j *cue-job*) (p (vector-ref j 1)) (limit (* 6 (mixer-rate))))
+487
(let loop ((k 0))
+488
(when (< k CUE-BLOCKS-PER-FRAME)
+489
(let ((more (and (< (vector-ref j 3) limit) (player-pull! p *cue-buf* 0 CUE-BLOCK))))
+490
(if more
+491
(begin
+492
(vector-set! j 2 (cons (mono-of *cue-buf* CUE-BLOCK) (vector-ref j 2)))
+493
(vector-set! j 3 (+ (vector-ref j 3) CUE-BLOCK))
+494
(loop (+ k 1)))
+495
(finish-cue! j))))))))
+496
+497
(define (finish-cue! j)
+498
(let* ((frames (vector-ref j 3))
+499
(out (make-bytevector (* 4 frames) 0)))

Showing the first 500 of 621 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.

src/harkfell/tunes.sgladded
@@ -0,0 +1,88 @@
+1
;;; (harkfell tunes) - The game's .cts tunes by name: their text and their parse.
+2
;;;
+3
;;; A tune lives at assets/tunes/NAME.cts. Natively its text is read from
+4
;;; there (the game runs from the repository's root, as scripts/dev does);
+5
;;; on the web the page fetches it and hands the text over in chunks
+6
;;; (tunes-chunk!, tunes-done!), and a name the wasm needs and does not have
+7
;;; is asked for through the WANT hook (the web shell prints a line the page
+8
;;; answers with a fetch).
+9
;;;
+10
;;; Parsing is a known stall on a phone (Crash's M1: a tune open is up to
+11
;;; ~260 ms of the frame thread), so the parse is kept apart from the open:
+12
;;; tune-parsed parses once and keeps the value, and each parse's cost is
+13
;;; recorded (tunes-parse-ms).
+14
;;;
+15
;;; (tune-text name) the text, or #f (natively: read on first use)
+16
;;; (tune-parsed name) the tune value, or #f (no text, or it does not read)
+17
;;; (tunes-provide! name text)
+18
;;; (tunes-chunk! s) (tunes-done! name) the page's delivery
+19
;;; (tunes-set-want! proc) (proc name): a text is missing
+20
;;; (tunes-parse-ms name) the last parse's ms, or #f
+21
+22
(define-library (harkfell tunes)
+23
(import (sigil core)
+24
(sigil string)
+25
(sigil time)
+26
(sigil io)
+27
(sigil fs)
+28
(motif tune))
+29
+30
(export tune-text tune-parsed tunes-provide! tunes-chunk! tunes-done!
+31
tunes-set-want! tunes-parse-ms tune-path tune-exists?)
+32
+33
(begin
+34
(define *texts* '()) ; ((name . text) ...)
+35
(define *parsed* '()) ; ((name . tune) ...)
+36
(define *parse-ms* '()) ; ((name . ms) ...)
+37
(define *chunks* '())
+38
(define *want* #f)
+39
(define *wanted* '())
+40
+41
(define (now-ms) (/ (* 1000.0 (current-jiffy)) (jiffies-per-second)))
+42
+43
(define (tune-path name) (string-append "assets/tunes/" name ".cts"))
+44
+45
;; Natively: does the file exist (the content check's question)
+46
(define (tune-exists? name) (file-exists? (tune-path name)))
+47
+48
(define (tunes-set-want! proc) (set! *want* proc))
+49
+50
(define (tunes-provide! name text)
+51
(set! *texts* (cons (cons name text) (filter (lambda (e) (not (string=? (car e) name))) *texts*)))
+52
(set! *parsed* (filter (lambda (e) (not (string=? (car e) name))) *parsed*)))
+53
+54
(define (tunes-chunk! s) (set! *chunks* (cons s *chunks*)))
+55
+56
(define (tunes-done! name)
+57
(tunes-provide! name (apply string-append (reverse *chunks*)))
+58
(set! *chunks* '()))
+59
+60
(define (tune-text name)
+61
(let ((e (assoc name *texts*)))
+62
(cond (e (cdr e))
+63
(*want*
+64
(unless (member name *wanted*)
+65
(set! *wanted* (cons name *wanted*))
+66
(*want* name))
+67
#f)
+68
((tune-exists? name)
+69
(let ((text (read-file-string (tune-path name))))
+70
(tunes-provide! name text)
+71
text))
+72
(else #f))))
+73
+74
(define (tune-parsed name)
+75
(let ((e (assoc name *parsed*)))
+76
(if e
+77
(cdr e)
+78
(let ((text (tune-text name)))
+79
(and text
+80
(let* ((t0 (now-ms))
+81
(t (guard (x (#t #f)) (read-tune-string text))))
+82
(set! *parse-ms* (cons (cons name (- (now-ms) t0)) *parse-ms*))
+83
;; a text that does not read is remembered as #f: not parsed again
+84
(set! *parsed* (cons (cons name t) *parsed*))
+85
t))))))
+86
+87
(define (tunes-parse-ms name)
+88
(let ((e (assoc name *parse-ms*))) (and e (cdr e))))))
test/test-place.sgladded
@@ -0,0 +1,83 @@
+1
;;; The adapter from the running world to the sound director's PLACE
+2
;;; ((harkfell audio) world-place), on the real world (world/): the place's
+3
;;; px py are ABSOLUTE world pixels, room (rx, ry) spanning x 400*rx ..
+4
;;; 400*(rx+1) and y 176*ry .. 176*(ry+1), which is the frame the director's
+5
;;; tone and spot-sound maths use. The world map's own pixels start at its
+6
;;; westmost and northmost room (worldmap-x0/y0: Reedfen's (8 3)), not at
+7
;;; room (0 0); the first build passed those through and put H2's tone eight
+8
;;; rooms away from every room (the adversarial review's H1, 2026-09-24).
+9
;;; Checked in two rooms, so a constant offset cannot pass by luck.
+10
+11
(import (sigil test)
+12
(sigil list)
+13
(engine worldmap)
+14
(harkfell world)
+15
(harkfell content)
+16
(harkfell source)
+17
(harkfell check)
+18
(harkfell feel)
+19
(engine mixer)
+20
(harkfell sound)
+21
(harkfell audio))
+22
+23
(define CT (load-content (read-world-source "world")))
+24
+25
;; The body standing in room (x, y) at its start cell, as a world.
+26
(define (world-in x y)
+27
(let* ((rm (content-room CT x y))
+28
(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
+32
(define (inside? pl)
+33
(and (<= (* 400 (place-rx pl)) (place-px pl) (* 400 (+ 1 (place-rx pl))))
+34
(<= (* 176 (place-ry pl)) (place-py pl) (* 176 (+ 1 (place-ry pl))))))
+35
+36
(test-group "place: absolute world pixels"
+37
(test "the world loads clean (the control)"
+38
(assert-equal '() (content-problems CT)))
+39
(test "Fen Edge (8 3): the body's place is inside room (8 3)'s absolute span"
+40
(let ((pl (world-place (world-in 8 3) CT)))
+41
(assert-equal 8 (place-rx pl))
+42
(assert-true (inside? pl))))
+43
(test "Climb Back (10 4): inside room (10 4)'s absolute span"
+44
(let ((pl (world-place (world-in 10 4) CT)))
+45
(assert-equal 10 (place-rx pl))
+46
(assert-equal 4 (place-ry pl))
+47
(assert-true (inside? pl))))
+48
(test "Reed Bridge carries its moment and its frogs, in room pixels"
+49
(let ((pl (world-place (world-in 9 3) CT)))
+50
(assert-equal "first-open-water" (place-moment pl))
+51
(assert-equal 2 (length (place-frogs pl)))
+52
(assert-true (every (lambda (x) (< 0 x 400)) (place-frogs pl))))))
+53
+54
;; 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.
+58
(define (listen-in x y)
+59
(mixer-open-headless! 32000 (lambda (id buf n gain) #f))
+60
(sound-reset!)
+61
(sound-set-tone-room! 11 3)
+62
(let* ((w (world-in x y))
+63
(base (world-place w CT))
+64
(pl (place base still: #t)))
+65
(let loop ((f 0))
+66
(let ((now (/ f 30.0)))
+67
(when (<= now 3.0)
+68
(sound-frame! now (if (= f 0) 0.0 (/ 1 30.0)) pl)
+69
(mixer-pump! now)
+70
(loop (+ f 1)))))
+71
(let ((s (mixer-find TONE-ID)))
+72
(list (sound-ears) (and s (source-gain s 3.0))))))
+73
+74
(define OLD-STONE (listen-in 10 3))
+75
(define FEN-EDGE (listen-in 8 3))
+76
+77
(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"
+82
(assert-equal 'right (car FEN-EDGE))
+83
(assert-true (< 0.0 (cadr FEN-EDGE) (cadr OLD-STONE)))))
test/test-sound.sgladded
@@ -0,0 +1,122 @@
+1
;;; A2's gate 4 (harkfell-design section 10): a room change during a moment
+2
;;; ramps the bed's channels to the new room's levels while the duck on the
+3
;;; source gain holds. Run through the REAL director, the real motif player
+4
;;; and the real bed tune (assets/tunes/reedfen-bed.cts), in the mixer's
+5
;;; headless mode, measured at the bed's output (its samples times its
+6
;;; source gain), not by reading the director's own bookkeeping: the defect
+7
;;; this guards is motif's one-ramp-per-target rule, so the check has to go
+8
;;; through motif.
+9
;;;
+10
;;; The measurement: two runs of the same bed from t = 0, so the seeded noise
+11
;;; is the same sample for sample and only the levels differ.
+12
;;; scenario Reed Bridge (its fixed moment starts, the bed ducks), then at
+13
;;; 5 s into Old Stone (different levels; the moment plays on)
+14
;;; reference Old Stone from the start: its levels, no moment, no duck
+15
;;; Over 8 s to 10 s (the room's 2 s ramp is done at 7 s) the scenario's bed
+16
;;; must be the reference's times the duck: 0.5, -6 dB. The sabotage in
+17
;;; scripts/gate-sound (the duck made of channel ramps) puts it back at the
+18
;;; reference's level, a ratio near 1.
+19
+20
(import (sigil test)
+21
(sigil list)
+22
(sigil math)
+23
(engine mixer)
+24
(harkfell hush)
+25
(harkfell sound))
+26
+27
(define RATE 32000)
+28
(define GROUPS '(wind water reeds drone))
+29
+30
(define REED-BRIDGE
+31
(place key: "reedfen:9,3" region: 'reedfen bed-tune: "reedfen-bed" groups: GROUPS
+32
levels: '((wind . 0.4) (water . 0.8) (reeds . 0.5)) moment: "first-open-water"
+33
px: 3800.0 py: 620.0 rx: 9 ry: 3))
+34
+35
(define OLD-STONE
+36
(place key: "reedfen:10,3" region: 'reedfen bed-tune: "reedfen-bed" groups: GROUPS
+37
levels: '((wind . 0.6) (reeds . 0.3) (drone . 0.4)) moment: #f
+38
px: 4200.0 py: 620.0 rx: 10 ry: 3))
+39
+40
;; The bed's energy between t-lo and t-hi: the sum of its squared samples
+41
;; times its source gain squared, block by block.
+42
(define *energy* 0.0)
+43
(define *t* 0.0)
+44
(define *lo* 8.0)
+45
(define *hi* 10.0)
+46
+47
(define (tap id buf n gain)
+48
(when (and (string=? id BED-ID) (>= *t* *lo*) (< *t* *hi*))
+49
(let loop ((i 0) (acc 0.0))
+50
(if (< i (* 2 n))
+51
(let ((x (bytevector-ieee-single-ref buf (* 4 i) 'little)))
+52
(loop (+ i 1) (+ acc (* x x))))
+53
(set! *energy* (+ *energy* (* gain gain acc)))))))
+54
+55
;; Run the director from 0 to `until` s at 30 frames a second; `plan` gives
+56
;; the place for a time. Answers the bed's energy in the window.
+57
(define (run plan until)
+58
(mixer-open-headless! RATE tap)
+59
(sound-reset!)
+60
(set! *energy* 0.0)
+61
(let loop ((f 0))
+62
(let ((now (/ f 30.0)))
+63
(when (<= now until)
+64
(set! *t* now)
+65
(sound-frame! now (if (= f 0) 0.0 (/ 1 30.0)) (plan now))
+66
(mixer-pump! now)
+67
(loop (+ f 1)))))
+68
*energy*)
+69
+70
(define (db x) (* 10.0 (/ (log x) (log 10.0))))
+71
+72
(define SCENARIO (run (lambda (t) (if (< t 5.0) REED-BRIDGE OLD-STONE)) 10.2))
+73
(define SCENARIO-MOMENT (sound-moment-playing))
+74
(define SCENARIO-LEVELS (sound-bed-levels))
+75
(define REFERENCE (run (lambda (t) OLD-STONE) 10.2))
+76
(define REFERENCE-MOMENT (sound-moment-playing))
+77
+78
(test-group "sound: gate 4, a room change during a moment"
+79
(test "both runs measured something (the window is not empty)"
+80
(assert-true (> REFERENCE 1.0e-3))
+81
(assert-true (> SCENARIO 1.0e-4)))
+82
(test "the moment was playing through the window (it is what the duck is for)"
+83
(assert-equal "first-open-water" SCENARIO-MOMENT)
+84
(assert-false REFERENCE-MOMENT))
+85
(test "the bed's channels went to Old Stone's levels (wind 0.6, water the default 0.4, reeds 0.3, drone 0.4)"
+86
(assert-equal 0.6 (cdr (assv 1 SCENARIO-LEVELS)))
+87
(assert-equal 0.4 (cdr (assv 2 SCENARIO-LEVELS)))
+88
(assert-equal 0.3 (cdr (assv 3 SCENARIO-LEVELS)))
+89
(assert-equal 0.4 (cdr (assv 4 SCENARIO-LEVELS))))
+90
(test "and the duck held: the bed sits 6 dB under the same room without a moment (within 1.5 dB)"
+91
(let ((d (db (/ SCENARIO REFERENCE))))
+92
(display " scenario / reference: ") (display d) (display " dB") (newline)
+93
(assert-true (< -7.5 d -4.5)))))
+94
+95
;; A change of what is carried reopens the bed with its drone notes (a
+96
;; crossfade over 4 s). During a moment, the bed going out and the bed coming
+97
;; in both keep the duck (review M1: the outgoing bed was a fresh source and
+98
;; jumped 6 dB up for its fade), and it is not a visit (no hush count).
+99
(define CARRY-AT 6.0)
+100
(define *carried* #f)
+101
(define ROOMS-BEFORE #f)
+102
(define (carry-plan t)
+103
(when (and (>= t CARRY-AT) (not *carried*))
+104
(set! *carried* #t)
+105
(set! ROOMS-BEFORE (hush-rooms (sound-hush)))
+106
(sound-set-carry! '(2)))
+107
REED-BRIDGE)
+108
(run carry-plan 7.0)
+109
(define BEDS (filter (lambda (s) (let ((id (source-id s))) (and (>= (string-length id) 3) (string=? (substring id 0 3) "bed"))))
+110
(mixer-sources)))
+111
(define NOW 7.0)
+112
+113
(test-group "sound: a carry change during a moment"
+114
(test "two beds sound during the crossfade (the old one renamed, the new one as bed)"
+115
(assert-equal 2 (length BEDS))
+116
(assert-true (mixer-find BED-ID)))
+117
(test "both hold the duck"
+118
(for-each (lambda (s) (assert-equal 0.5 (source-factor s 'duck NOW))) BEDS))
+119
(test "and the moment still plays"
+120
(assert-equal "first-open-water" (sound-moment-playing)))
+121
(test "it was not a visit: the hush's room count did not move"
+122
(assert-equal ROOMS-BEFORE (hush-rooms (sound-hush)))))
test/test-tunes.sgladded
@@ -0,0 +1,76 @@
+1
;;; The world's sounds exist (A2): every room's fixed moment and every
+2
;;; region's bed names a tune at assets/tunes/NAME.cts that reads as a tune.
+3
;;; A1's content gate checks that a moment's name is one of its region's
+4
;;; moments:; this checks there is a file behind it, so a typo or a missing
+5
;;; file is red here rather than silent in play (the director skips a tune it
+6
;;; cannot open).
+7
;;;
+8
;;; The control and the break: the real world (clean), and a copy whose Reed
+9
;;; Bridge names a moment with no file (red, naming the room and the tune).
+10
+11
(import (sigil test)
+12
(sigil list)
+13
(sigil string)
+14
(motif tune)
+15
(engine worldmap)
+16
(harkfell content)
+17
(harkfell source)
+18
(harkfell tunes)
+19
(harkfell audio))
+20
+21
(define WORLD (read-world-source "world"))
+22
+23
;; Every sound the content names, as (what . tune-name).
+24
(define (named-tunes ct)
+25
(append
+26
(filter-map* (lambda (p)
+27
(let* ((rm (placed-data p)) (m (room-moment-name rm)))
+28
(and m (cons (string-append (room-label rm) " moment") m))))
+29
(content-rooms ct))
+30
(filter-map* (lambda (r)
+31
(let ((b (region-field r 'bed)))
+32
(and b (cons (string-append (symbol->string (region-name r)) " bed")
+33
(if (symbol? b) (symbol->string b) b)))))
+34
(content-regions ct))))
+35
+36
(define (filter-map* f l)
+37
(let loop ((l l) (acc '()))
+38
(if (null? l) (reverse acc) (let ((v (f (car l)))) (loop (cdr l) (if v (cons v acc) acc))))))
+39
+40
;; The problems: a name with no file, or a file that does not read.
+41
(define (tune-problems ct)
+42
(filter-map* (lambda (e)
+43
(cond ((not (tune-exists? (cdr e)))
+44
(string-append (car e) ": no " (tune-path (cdr e))))
+45
((not (guard (x (#t #f)) (read-tune-string (tune-text (cdr e)))))
+46
(string-append (car e) ": " (tune-path (cdr e)) " does not read"))
+47
(else #f)))
+48
(named-tunes ct)))
+49
+50
(define (ends-with? s suf)
+51
(let ((n (string-length s)) (m (string-length suf)))
+52
(and (>= n m) (string=? (substring s (- n m) n) suf))))
+53
+54
(define (swap s a b)
+55
(let ((n (string-length s)) (m (string-length a)))
+56
(let loop ((k 0))
+57
(cond ((> (+ k m) n) (error "swap: not found" a))
+58
((string=? (substring s k (+ k m)) a)
+59
(string-append (substring s 0 k) b (substring s (+ k m) n)))
+60
(else (loop (+ k 1)))))))
+61
+62
(test-group "tunes: every named sound has its file"
+63
(test "the world names at least one moment and one bed (the check has a subject)"
+64
(let ((ns (named-tunes (load-content WORLD))))
+65
(assert-true (find (lambda (e) (string-contains? (car e) "moment")) ns))
+66
(assert-true (find (lambda (e) (string-contains? (car e) "bed")) ns))))
+67
(test "the real world: every one exists and reads (the control)"
+68
(assert-equal '() (tune-problems (load-content WORLD))))
+69
(test "a moment with no file is red, naming the room and the path"
+70
(let* ((src (map (lambda (e)
+71
(if (ends-with? (car e) "x9y3.room")
+72
(cons (car e) (swap (cdr e) "tune: first-open-water" "tune: frog-choir"))
+73
e))
+74
WORLD))
+75
(ps (tune-problems (load-content src))))
+76
(assert-true (member "reedfen:9,3 moment: no assets/tunes/frog-choir.cts" ps)))))