A2: the sound of the place, and the phone measurement
(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).
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(-)assets/tunes/bed-hollow.ctsadded
(tune version: 1 name: "bed-hollow" tempo: 60 speed: 15 channels: 8 (bus reverb: zitarev size: 0.85 damp: 0.5 mix: 0.22) (instruments (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) (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) (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) (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) (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) (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) (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) (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)) (patterns (pattern id: 0 rows: 16 (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"))) (pattern id: 1 rows: 128 (row 127 (1 "..." 0 0 "B01")))) (order 0 1) (history ("claude" "2026-09-23" "S0 region bed: the Hollow")))assets/tunes/bed-reedfen.ctsadded
(tune version: 1 name: "bed-reedfen" tempo: 60 speed: 15 channels: 8 (bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.15) (instruments (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) (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) (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) (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) (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) (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) (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) (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)) (patterns (pattern id: 0 rows: 16 (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"))) (pattern id: 1 rows: 128 (row 127 (1 "..." 0 0 "B01")))) (order 0 1) (history ("claude" "2026-09-23" "S0 region bed: Reedfen")))assets/tunes/first-open-water.ctsadded
(tune version: 1 name: "first-open-water" tempo: 84 speed: 6 channels: 6 (bus reverb: zitarev size: 0.65 damp: 0.5 mix: 0.22) (instruments (instrument id: 1 name: "reed flute" patch: (flute-wind attack: 0.12 release: 0.6 noise-amp: 0.18) volume: 42 send: 0.35) (instrument id: 2 name: "frog-bell" patch: (karplus-strong decay: 1.4 excite-gain: 0.6) volume: 26 send: 0.3) (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) (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)) (patterns (pattern id: 0 rows: 144 (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")) (row 4 (2 "A-5" 2 22 "000") (1 "A-4" 1 40 "000")) (row 8 (2 "F-5" 2 26 "000")) (row 12 (2 "A-5" 2 22 "000")) (row 15 (1 "===" 0 0 "000")) (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")) (row 20 (2 "B-5" 2 22 "000")) (row 22 (1 "D-5" 1 40 "000")) (row 24 (2 "G-5" 2 26 "000")) (row 28 (2 "B-5" 2 22 "000") (1 "E-5" 1 42 "000")) (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")) (row 36 (2 "A-5" 2 22 "000")) (row 40 (2 "F-5" 2 26 "000") (1 "E-5" 1 40 "000")) (row 44 (2 "A-5" 2 22 "000") (1 "D-5" 1 40 "000")) (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")) (row 52 (2 "B-5" 2 22 "000")) (row 56 (2 "G-5" 2 26 "000") (1 "A-4" 1 40 "000")) (row 60 (2 "B-5" 2 22 "000") (1 "G-4" 1 38 "000")) (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")) (row 68 (2 "A-5" 2 22 "000")) (row 70 (3 "A-4" 2 18 "000") (1 "C-5" 1 42 "000")) (row 72 (2 "F-5" 2 26 "000")) (row 76 (2 "A-5" 2 22 "000") (1 "F-5" 1 46 "000")) (row 78 (3 "F-4" 2 16 "000")) (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")) (row 84 (2 "G-5" 2 22 "000")) (row 86 (3 "G-4" 2 18 "000")) (row 88 (2 "E-5" 2 26 "000") (1 "D-5" 1 40 "000")) (row 92 (2 "G-5" 2 22 "000") (1 "C-5" 1 40 "000")) (row 94 (3 "E-4" 2 16 "000")) (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")) (row 100 (2 "B-5" 2 22 "000")) (row 102 (3 "B-4" 2 18 "000")) (row 104 (2 "G-5" 2 26 "000") (1 "D-5" 1 42 "000")) (row 108 (2 "B-5" 2 22 "000")) (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")) (row 116 (2 "A-5" 2 22 "000")) (row 118 (3 "A-4" 2 16 "000")) (row 120 (2 "F-5" 2 26 "000")) (row 124 (2 "A-5" 2 22 "000")) (row 126 (1 "===" 0 0 "000")) (row 128 (4 "===" 0 0 "000") (5 "===" 0 0 "000") (6 "===" 0 0 "000")))) (order 0) (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
(tune version: 1 name: "moment-reedfen" tempo: 84 speed: 6 channels: 6 (bus reverb: zitarev size: 0.65 damp: 0.5 mix: 0.22) (instruments (instrument id: 1 name: "reed flute" patch: (flute-wind attack: 0.12 release: 0.6 noise-amp: 0.18) volume: 42 send: 0.35) (instrument id: 2 name: "frog-bell" patch: (karplus-strong decay: 1.4 excite-gain: 0.6) volume: 26 send: 0.3) (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) (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)) (patterns (pattern id: 0 rows: 144 (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")) (row 4 (2 "A-5" 2 22 "000") (1 "A-4" 1 40 "000")) (row 8 (2 "F-5" 2 26 "000")) (row 12 (2 "A-5" 2 22 "000")) (row 15 (1 "===" 0 0 "000")) (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")) (row 20 (2 "B-5" 2 22 "000")) (row 22 (1 "D-5" 1 40 "000")) (row 24 (2 "G-5" 2 26 "000")) (row 28 (2 "B-5" 2 22 "000") (1 "E-5" 1 42 "000")) (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")) (row 36 (2 "A-5" 2 22 "000")) (row 40 (2 "F-5" 2 26 "000") (1 "E-5" 1 40 "000")) (row 44 (2 "A-5" 2 22 "000") (1 "D-5" 1 40 "000")) (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")) (row 52 (2 "B-5" 2 22 "000")) (row 56 (2 "G-5" 2 26 "000") (1 "A-4" 1 40 "000")) (row 60 (2 "B-5" 2 22 "000") (1 "G-4" 1 38 "000")) (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")) (row 68 (2 "A-5" 2 22 "000")) (row 70 (3 "A-4" 2 18 "000") (1 "C-5" 1 42 "000")) (row 72 (2 "F-5" 2 26 "000")) (row 76 (2 "A-5" 2 22 "000") (1 "F-5" 1 46 "000")) (row 78 (3 "F-4" 2 16 "000")) (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")) (row 84 (2 "G-5" 2 22 "000")) (row 86 (3 "G-4" 2 18 "000")) (row 88 (2 "E-5" 2 26 "000") (1 "D-5" 1 40 "000")) (row 92 (2 "G-5" 2 22 "000") (1 "C-5" 1 40 "000")) (row 94 (3 "E-4" 2 16 "000")) (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")) (row 100 (2 "B-5" 2 22 "000")) (row 102 (3 "B-4" 2 18 "000")) (row 104 (2 "G-5" 2 26 "000") (1 "D-5" 1 42 "000")) (row 108 (2 "B-5" 2 22 "000")) (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")) (row 116 (2 "A-5" 2 22 "000")) (row 118 (3 "A-4" 2 16 "000")) (row 120 (2 "F-5" 2 26 "000")) (row 124 (2 "A-5" 2 22 "000")) (row 126 (1 "===" 0 0 "000")) (row 128 (4 "===" 0 0 "000") (5 "===" 0 0 "000") (6 "===" 0 0 "000")))) (order 0) (history ("claude" "2026-09-23" "S0 moment: Reedfen, D Dorian, first open water")))assets/tunes/reedfen-bed.ctsadded
(tune version: 1 name: "reedfen-bed" tempo: 60 speed: 15 channels: 8 (bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.15) (instruments (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) (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) (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) (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) (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)) (patterns (pattern id: 0 rows: 16 (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"))) (pattern id: 1 rows: 128 (row 127 (1 "..." 0 0 "B01")))) (order 0 1) (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
(tune version: 1 name: "return-tone" tempo: 120 speed: 3 channels: 1 (bus reverb: zitarev size: 0.6 damp: 0.5 mix: 0.25) (instruments (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)) (patterns (pattern id: 0 rows: 8 (row 0 (1 "D-3" 1 40 "000")) (row 6 (1 "===" 0 0 "000")))) (order 0) (history ("claude" "2026-09-24" "A2: the soft return's short low tone (section 3, Harm)")))assets/tunes/spot-bird.ctsadded
(tune version: 1 name: "spot-bird" tempo: 120 speed: 2 channels: 1 (bus reverb: zitarev size: 0.7 damp: 0.4 mix: 0.3) (instruments (instrument id: 1 name: "reed bird" patch: (flute-wind attack: 0.02 release: 0.12 noise-amp: 0.1) volume: 36 send: 0.4)) (patterns (pattern id: 0 rows: 16 (row 0 (1 "E-6" 1 30 "000")) (row 2 (1 "===" 0 0 "000")) (row 3 (1 "A-6" 1 36 "000")) (row 5 (1 "===" 0 0 "000")) (row 7 (1 "E-6" 1 26 "000")) (row 8 (1 "===" 0 0 "000")))) (order 0) (history ("claude" "2026-09-24" "A2 spot sound: a reed bird's three-note call")))assets/tunes/spot-frog.ctsadded
(tune version: 1 name: "spot-frog" tempo: 120 speed: 3 channels: 1 (bus reverb: zitarev size: 0.5 damp: 0.5 mix: 0.2) (instruments (instrument id: 1 name: "bellfrog" patch: (karplus-strong decay: 0.9 excite-gain: 0.6) volume: 40 send: 0.3)) (patterns (pattern id: 0 rows: 12 (row 0 (1 "A-3" 1 40 "000")) (row 3 (1 "D-3" 1 34 "000")))) (order 0) (history ("claude" "2026-09-24" "A2 spot sound: a bellfrog's two-note croak, falling a fourth")))assets/tunes/spot-plop.ctsadded
(tune version: 1 name: "spot-plop" tempo: 120 speed: 3 channels: 1 (bus reverb: zitarev size: 0.5 damp: 0.6 mix: 0.15) (instruments (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)) (patterns (pattern id: 0 rows: 8 (row 0 (1 "D-4" 1 40 "000")))) (order 0) (history ("claude" "2026-09-24" "A2 spot sound: something small going into the water")))assets/tunes/tone-h2.ctsadded
(tune version: 1 name: "tone-h2" tempo: 60 speed: 15 channels: 1 (bus reverb: zitarev size: 0.8 damp: 0.5 mix: 0.3) (instruments (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)) (patterns (pattern id: 0 rows: 16 (row 0 (1 "D-3" 1 52 "000"))) (pattern id: 1 rows: 32 (row 31 (1 "..." 0 0 "B01")))) (order 0 1) (history ("claude" "2026-09-23" "S0 sketch: the finding tone for H2, here")))index.template.htmlmodified
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <title>Harkfell</title> <script> // A2: the sound renders at ?rate=N Hz (default 32000, the rate Crash's // phone measurements chose; ?rate=device for the device's own) and the // browser resamples to the device. The WebAudio bridge opens // `new AudioContext()` with no options, so the page supplies the rate by // wrapping the constructor before the loader runs (the S0 sketchbook's way). (function () { var p = new URLSearchParams(location.search).get("rate"); var want = p === "device" ? 0 : Number(p || 32000); var Base = window.AudioContext || window.webkitAudioContext; if (want > 0 && Base) { var Wrapped = function (opts) { return new Base(Object.assign({ sampleRate: want }, opts || {})); }; Wrapped.prototype = Base.prototype; window.AudioContext = Wrapped; if (window.webkitAudioContext) window.webkitAudioContext = Wrapped; } })(); </script> <!-- version: "__VERSION__" --> <style> /* The page is the room: the canvas fills the viewport and the game #dead { position: fixed; inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.8); color: #ddd; font: 48px sans-serif; } #dead[hidden] { display: none; } /* ?bench: the measurement's lines, and the tap that starts it */ #bench { position: fixed; left: 0; right: 0; top: 0; z-index: 4; max-height: 70vh; overflow: auto; margin: 0; padding: 8px; background: rgba(0, 0, 0, 0.78); color: #cfe; font: 11px/1.35 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; } #bench[hidden] { display: none; } </style></head><body> <div id="jumpbtn"></div> </div> <div id="dead" hidden>↻</div> <pre id="bench" hidden></pre> <div id="status"></div> <script> // region sheet); a tap enters a room. // ?still the world does not step (screenshots). // ?test-room A0's grey test room instead of the world. // ?rate=N|device the sound's render rate (A2; default 32000). // ?carry=2,3 play as if carrying those harmonics: their drone // voices sound in the bed (A3's pickups do this). // ?tone=X,Y the room H2's finding tone sounds from (default // 11,3: a placeholder east of Reedfen's six rooms). // ?bench the phone measurement (A2, (harkfell bench)): a tap // starts it; about 3.3 minutes; its lines show at the // top and are sent to the host (bench-report?LINE), so // the numbers need no transcribing. // // The guard follows Crash The Stack's page guard (master e737d21) in // spirit, much reduced: a FATAL line, a trap out of a dispatch or a lost deadCard.hidden = false; } deadCard.addEventListener("click", function () { location.reload(); }); // ---- A2: the sound's lines ------------------------------------------- // harkfell: fetch NAME the wasm wants assets/tunes/NAME.cts: fetched and // handed over in 8 KB chunks, then tune-done // harkfell: bench ... ?bench's lines: shown, and sent to the host var benchEl = document.getElementById("bench"); var fetched = {}, tries = {}; function fetchTune(name) { if (!/^[a-z0-9-]+$/.test(name) || fetched[name]) return; fetched[name] = true; fetch("assets/tunes/" + name + ".cts").then(function (r) { if (!r.ok) throw new Error("tune " + name + " " + r.status); return r.text(); }).then(function (text) { for (var i = 0; i < text.length; i += 8192) send("tune-chunk", text.slice(i, i + 8192)); send("tune-done", name); }, function (err) { // a failed fetch is tried again, three times in all, 2 s apart (the // wasm asks for a name once; review M4) fetched[name] = false; tries[name] = (tries[name] || 0) + 1; if (tries[name] < 3) setTimeout(function () { fetchTune(name); }, 2000); else console.error("harkfell: " + err.message); }); } function report(line) { benchEl.textContent += line + "\n"; benchEl.scrollTop = benchEl.scrollHeight; try { fetch("bench-report?" + encodeURIComponent(line), { cache: "no-store" }).catch(function () {}); } catch (err) { /* offline */ } } var log = console.log; console.log = function () { var line = String(arguments[0] || ""); if (line.indexOf("harkfell: fetch ") === 0) fetchTune(line.slice(16).trim()); else if (line.indexOf("harkfell: bench ") === 0 || (benchOn && line.indexOf("harkfell: audio ") === 0)) report(line); return log.apply(console, arguments); }; // Every gesture may resume the audio context (the autoplay policy); the // capture phase sees the touch controls' events before they stop them. ["pointerdown", "pointerup", "touchend", "click", "keydown"].forEach(function (n) { document.addEventListener(n, function () { send("gesture", n); }, true); }); var benchOn = params.has("bench"); if (benchOn) { benchEl.hidden = false; 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"; var begun = false; document.addEventListener("pointerup", function () { if (begun) return; begun = true; report("harkfell: bench env version " + (document.documentElement.innerHTML.match(/version: "([^"]*)"/) || [0, "?"])[1] + " isolated " + (self.crossOriginIsolated ? 1 : 0) + " rate-param " + (params.get("rate") || "32000") + " cores " + (navigator.hardwareConcurrency || "?") + " dpr " + (window.devicePixelRatio || 1) + " ua " + navigator.userAgent.replace(/\s+/g, "_")); withApp(function () { send("gesture", "bench"); send("bench", ""); }); }, true); } var warn = console.warn; console.warn = function () { var s = String(arguments[0] || ""); if (window.HARKFELL_WORLD_LIVE) console.log("harkfell: page world live " + files.length + " files"); files.forEach(function (f) { send("world-file", f[0] + "\n" + f[1]); }); if (files.length) send("world-done", ""); if (params.get("carry")) send("carry", params.get("carry")); if (params.get("tone")) send("tone", params.get("tone")); if (params.has("test-room")) send("test-room", ""); var room = params.get("room"); if (room) send("goto", params.get("at") ? room + "@" + params.get("at") : room);scripts/host-webmodified
rm -rf "$DST"cp -r "$SRC" "$DST"setsid node scripts/serve-web-tls.mjs "$DST" "$PORT_TLS" --isolate > "build/host-$PORT_TLS.log" 2>&1 &setsid node scripts/serve-web-tls.mjs "$DST" "$PORT_TLS" --isolate --log > "build/host-$PORT_TLS.log" 2>&1 &echo $! > "build/host-$PORT_TLS.pid"setsid sigil serve --dir "$DST" --host "$HOST" --port "$PORT_HTTP" > "build/host-$PORT_HTTP.log" 2>&1 &echo $! > "build/host-$PORT_HTTP.pid"scripts/serve-web-tls.mjsmodified
#!/usr/bin/env node// COPIED from Crash The Stack master e737d21 (scripts/serve-web-tls.mjs),// unchanged but for this header. Harkfell hosts on wg0 8797 (https) with// changed only by this header and by A2's --log (one line per request on// stdout, and GET /bench-report?LINE answered 204: the page's ?bench sends// its measurement lines there, so they land in the host log). Harkfell hosts on wg0 8797 (https) with// --isolate; the S0 sketchbook runs its own instance on 8795.// Host a build directory over HTTPS on the WireGuard address, for the phone.//if (!dir || !portArg) { console.error("usage: serve-web-tls.mjs DIR PORT [--isolate]"); process.exit(2); }const port = Number(portArg);const isolate = flags.includes("--isolate");const logging = flags.includes("--log");const root = path.resolve(dir);if (!fs.existsSync(path.join(root, "index.html"))) { console.error(`serve-web-tls: ${root}/index.html missing`); process.exit(1); }const MIME = { html: "text/html; charset=utf-8", js: "text/javascript", mjs: "text/javascript", wasm: "application/wasm", json: "application/json", 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" };const server = https.createServer({ key, cert }, (req, res) => { if (logging) console.log(new Date().toISOString() + " " + req.socket.remoteAddress + " " + req.method + " " + req.url); const urlPath = decodeURIComponent(new URL(req.url, "https://x").pathname); if (urlPath === "/bench-report") { res.writeHead(204, { "cache-control": "no-store" }); res.end(); return; } let fp = path.normalize(path.join(root, urlPath)); if (!fp.startsWith(root)) { res.writeHead(403); res.end(); return; } try { if (fs.statSync(fp).isDirectory()) fp = path.join(fp, "index.html"); } catch { /* falls to the read */ }src/harkfell/audio.sgladded
;;; (harkfell audio) - What both shells call for the sound, once a frame, and;;; the adapter from the world to the sound director's PLACE.;;;;;; (audio-boot! say) open the mixer (suspended on the web until a gesture);;; (audio-world! content) the world loaded: the director learns its moments;;; (audio-gesture!) a user gesture: resume the context (the web's autoplay policy);;; (audio-bench!) ?bench / --bench: the phone measurement ((harkfell bench));;;; the director is silent while it runs;;; (audio-carry! "2,3") ?carry= / --carry: the harmonics carried (A3's pickups do this in play);;; (audio-tone-room! "X,Y") ?tone= / --tone: where H2's finding tone is;;; (audio-frame! frame-ms work-ms) every frame, after the world's ticks;;; (audio-lines) the readout's sound lines(define-library (harkfell audio) (import (sigil core) (sigil time) (sigil math) (sigil list) (sigil string) (engine mixer) (engine body) (engine worldmap) (harkfell world) (harkfell content) (harkfell shell) (harkfell tunes) (harkfell sound) (harkfell bench)) (export audio-boot! audio-world! audio-gesture! audio-bench! audio-frame! audio-now audio-carry! audio-tone-room! audio-lines world-place room-moment-name) (begin (define *say* (lambda parts #f)) (define *last* #f) (define (audio-now) (/ (current-jiffy) (* 1.0 (jiffies-per-second)))) (define (audio-boot! say) (set! *say* say) (mixer-open!) (shell-set-extra-lines! audio-lines) (*say* "audio" (number->string (mixer-rate)) (symbol->string (mixer-state)))) (define (audio-gesture!) (mixer-resume!)) (define (audio-bench!) (sound-reset!) (bench-start! *say*)) ;; "2,3" -> (2 3) (define (numbers s) (filter number? (map string->number (string-split s ",")))) (define (audio-carry! s) (sound-set-carry! (numbers s))) (define (audio-tone-room! s) (let ((n (numbers s))) (when (= (length n) 2) (sound-set-tone-room! (car n) (cadr n))))) ;; --- the adapter ------------------------------------------------------------------- (define (kw-ref l name) (let find ((l l)) (cond ((or (not (pair? l)) (null? (cdr l))) #f) ((and (keyword? (car l)) (string=? (keyword->string (car l)) name)) (cadr l)) (else (find (cdr l)))))) ;; A room's fixed moment as a tune name (a string), or #f. (define (room-moment-name rm) (let* ((m (room-moment rm)) (t (and (list? m) (kw-ref m "tune")))) (and (symbol? t) (symbol->string t)))) (define (region-of ct sym) (find (lambda (r) (eq? (region-name r) sym)) (content-regions ct))) ;; bed: (wind 0.4 water 0.8) -> ((wind . 0.4) (water . 0.8)) (define (plist->alist l) (if (and (pair? l) (pair? (cdr l))) (cons (cons (car l) (cadr l)) (plist->alist (cddr l))) '())) (define TILE 16) ;; The place the director needs, from the running world; #f outside the ;; written world (A0's test room, the atlas, the sheet). (define (world-place w ct) (let* ((p (and w (world-room w))) (rm (and p (placed-data p)))) (and rm ct (let* ((reg (region-of ct (room-region rm))) (b (world-body w)) (wm (world-map w)) (bed (region-field reg 'bed)) (frogs (filter-map (lambda (t) (and (pair? t) (eq? (car t) 'bellfrog) (let ((at (kw-ref (cdr t) "at"))) (and (pair? at) (+ (* TILE (car at)) (/ TILE 2.0)))))) (let ((ts (room-things rm))) (if (list? ts) ts '()))))) (place key: (room-label rm) region: (room-region rm) bed-tune: (cond ((string? bed) bed) ((symbol? bed) (symbol->string bed)) (else #f)) groups: (or (region-field reg 'bed-channels) '()) levels: (plist->alist (room-bed rm)) moment: (room-moment-name rm) frogs: frogs ;; ABSOLUTE world pixels: the map's pixels start at its ;; westmost and northmost room (x0 y0), not at room (0 0) px: (+ (body-x b) 4.0 (* (worldmap-cols wm) (worldmap-size wm) (worldmap-x0 wm))) py: (+ (body-y b) 6.0 (* (worldmap-rows wm) (worldmap-size wm) (worldmap-y0 wm))) rx: (room-x rm) ry: (room-y rm) still: (and (eq? (world-phase w) 'play) (memq (body-mode b) '(ground)) (< (abs (body-vx b)) 0.01) (< (abs (body-vy b)) 0.01)) returns: (world-returns w)))))) (define (filter-map f l) (let loop ((l l) (acc '())) (if (null? l) (reverse acc) (let ((v (f (car l)))) (loop (cdr l) (if v (cons v acc) acc)))))) (define (audio-world! ct) (when ct (let ((ms (filter-map (lambda (p) (room-moment-name (placed-data p))) (content-rooms ct)))) (sound-boot! ms *say*)))) (define (audio-frame! frame-ms work-ms) (let ((now (audio-now))) (if (or (bench-running?) (bench-done?)) (when (bench-running?) (bench-frame! now frame-ms work-ms)) (let ((dt (if *last* (min 0.25 (- now *last*)) 0.0))) (sound-frame! now dt (world-place (shell-world) (shell-content))))) (set! *last* now) (mixer-pump! now))) (define (r1 x) (number->string (/ (round (* 10.0 x)) 10.0))) (define (audio-lines) (let ((st (mixer-stats))) (append (sound-lines) (list (string-append "music " (r1 (stats-ref st 'frame-ms)) " ms/frame pump " (r1 (car (stats-ref st 'pump))) " dry " (number->string (stats-ref st 'dry-sounding)) " rate " (number->string (mixer-rate)))))))))src/harkfell/bench.sgladded
;;; (harkfell bench) - The phone measurement A2 starts with (?bench).;;;;;; harkfell-design section 5 ("Budget, to measure, not assume") and section;;; 10 A2: the bed alone, the bed plus a moment player, a region-border;;; crossfade (three players), and the cost of opening a tune, on David's;;; phone at 32000, against a 4 ms music budget and the frame budget. The;;; game's room runs underneath as it would in play; nothing is asked of the;;; player but to leave the phone on the page.;;;;;; The other tunes are S0's (copied into assets/tunes): bed-reedfen (8 channels:;;; wind, water, reeds and the drone H2 H3 H4 H5 H7, EVERY channel unmuted:;;; the heaviest a bed gets, the end of the game with H7), moment-reedfen,;;; and bed-hollow as the second region's bed.;;;;;; The run, in 30 s phases, each measured over its last 25 s (from 5 s in):;;; a2bed A2's own bed as it ships (reedfen-bed: wind, water, reeds; the;;; drone's H2 and H3 notes dropped since nothing is carried);;; a2moment the same, plus the fixed moment (first-open-water, looped);;; and the duck on the bed's source gain;;; bed S0's full Reedfen bed, all 8 channels sounding (the heaviest;;; a bed gets: the end of the game, with H7);;; moment the full bed plus the moment;;; border the full bed, the moment and a second region's bed;;; (bed-hollow), the beds at half each: three players;;; opens the full bed alone; every 3 s a parse and open (then a close);;; of the moment, then of bed-hollow, three times each: one;;; open line each, with the frame's work and the dry frames of;;; the next 2.5 s;;; About 2.9 minutes in all. A2's two phases also run the finding tone;;; (tone-h2, at a distance's gain: it synthesises whatever its gain), as;;; play does. "music" is ms per 60 fps frame of audio (Crash's unit, the;;; budget's); a phone at 30 fps pays twice that per frame it draws, which;;; "per-second" (ms of synthesis per second of audio) states without a;;; frame rate.;;;;;; Lines (through say, "harkfell: bench ..."; the page beacons them):;;; bench start rate R state S;;; 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);;; 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;;; bench done(define-library (harkfell bench) (import (sigil core) (sigil math) (sigil string) (sigil list) (sigil time) (motif tune) (motif tune player) (engine mixer) (harkfell tunes) (only (harkfell sound) strip-uncarried)) (export bench-start! bench-frame! bench-running? bench-done?) (begin (define A2-BED "reedfen-bed") (define A2-MOMENT "first-open-water") (define TONE "tone-h2") (define BED-A "bed-reedfen") (define BED-B "bed-hollow") (define MOMENT "moment-reedfen") (define BUDGET-MS 4.0) (define *on* #f) (define *done* #f) (define *say* #f) (define *t0* #f) ; the clock at the run's start (s) (define *step* 0) ; the next scheduled step (define *frames* '()) ; frame intervals (ms) in the window (define *works* '()) ; frame work (ms) in the window (define *opens* '()) ; ((name parse open work) ...) of the open series (define *pending-open* #f) ; (name parse open work dry-before t) awaiting its dry figure (define *players* '()) ; ((name . player) ...) open (define (bench-running?) *on*) (define (bench-done?) *done*) (define (now-ms) (/ (* 1000.0 (current-jiffy)) (jiffies-per-second))) (define (r1 x) (number->string (/ (round (* 10.0 x)) 10.0))) (define (n x) (number->string (exact (round x)))) (define (bench-start! say) (set! *on* #t) (set! *say* say) ;; ask for the texts now (on the web the page fetches them) (for-each tune-text (list A2-BED A2-MOMENT TONE BED-A BED-B MOMENT))) (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))) ;; parse + open, timed separately. Answers (player parse-ms open-ms). (define (open-timed name loop?) (let* ((t0 (now-ms)) (t (read-tune-string (tune-text name))) (t1 (now-ms)) (p (open-tune-player t (mixer-rate) loop: loop?)) (t2 (now-ms))) (list p (- t1 t0) (- t2 t1)))) (define (start-source! id name loop? work-at) (let* ((o (open-timed name loop?)) (p (car o))) (set! *players* (cons (cons id p) *players*)) (*say* "bench" "open" name "parse" (r1 (cadr o)) "open" (r1 (caddr o)) "live") (let ((s (mixer-add! id (lambda (out off k) (player-pull! p out off k)) #t))) s))) (define (stop-source! id) (mixer-remove! id) (let ((e (assoc id *players*))) (when e (player-close! (cdr e))) (set! *players* (filter (lambda (x) (not (equal? (car x) id))) *players*)))) (define (reset-window!) (set! *frames* '()) (set! *works* '()) (mixer-stats-reset!)) (define (trip l) (list (median-of l) (percentile-of l 0.95) (if (null? l) 0.0 (apply max l)))) (define (first3 l) (list (car l) (cadr l) (caddr l))) (define (slash l) (string-join (map r1 l) "/")) (define (report! phase) (let* ((st (mixer-stats)) (per (stats-ref st 'sources))) (apply *say* (append (list "bench" phase "music" (r1 (stats-ref st 'frame-ms)) "budget" (r1 BUDGET-MS)) (apply append (map (lambda (p) (list "per" (string-append (car p) "/" (r1 (cadr p)) "/" (r1 (caddr p))))) per)) ;; the same cost as ms of synthesis per second of audio ;; (under 1000 keeps up; independent of the frame rate) (list "per-second" (r1 (* 60.0 (stats-ref st 'frame-ms))) "pump" (slash (first3 (stats-ref st 'pump))) "frame" (slash (trip *frames*)) "work" (slash (trip *works*)) "over33" (n (length (filter (lambda (x) (> x 33.4)) *frames*))) "frames" (n (length *frames*)) "dry-sounding" (n (stats-ref st 'dry-sounding)) "dry-tail" (n (stats-ref st 'dry-tail))))))) (define (dry-now) (stats-ref (mixer-stats) 'dry-sounding)) ;; one open of the series: parse + open + close, with bed A sounding (define (series-open! name work) (let* ((o (open-timed name #t))) (player-close! (car o)) (set! *pending-open* (list name (cadr o) (caddr o) (dry-now) (+ (mixer-now) 2.5))))) ;; (t . action), seconds from the start (define (duck! id) (source-factor! (mixer-find id) 'duck 0.5 2.0 (mixer-now))) (define STEPS (list (cons 0.0 (lambda () (mixer-stats-reset!) (start-a2-bed!) (source-factor! (start-source! "tone" TONE #t 0) 'dist 0.1 0.0 (mixer-now)))) (cons 5.0 reset-window!) (cons 30.0 (lambda () (report! "a2bed") (start-source! "moment" A2-MOMENT #t 0) (duck! "a2"))) (cons 35.0 reset-window!) (cons 60.0 (lambda () (report! "a2moment") (stop-source! "moment") (stop-source! "a2") (stop-source! "tone") (mixer-stats-reset!) (start-source! "bed-a" BED-A #t 0))) (cons 65.0 reset-window!) (cons 90.0 (lambda () (report! "bed") (start-source! "moment" MOMENT #t 0) (duck! "bed-a"))) (cons 95.0 reset-window!) (cons 120.0 (lambda () (report! "moment") (start-source! "bed-b" BED-B #t 0) (source-factor! (mixer-find "bed-a") 'fade 0.5 4.0 (mixer-now)) (source-factor! (mixer-find "bed-b") 'fade 0.0 0.0 (mixer-now)) (source-factor! (mixer-find "bed-b") 'fade 0.5 4.0 (mixer-now)))) (cons 125.0 reset-window!) (cons 150.0 (lambda () (report! "border") (stop-source! "moment") (stop-source! "bed-b") (source-factor! (mixer-find "bed-a") 'fade 1.0 1.0 (mixer-now)) (source-factor! (mixer-find "bed-a") 'duck 1.0 1.0 (mixer-now)) (reset-window!))) (cons 152.0 (lambda () (series-open! MOMENT 0))) (cons 155.0 (lambda () (series-open! BED-B 0))) (cons 158.0 (lambda () (series-open! MOMENT 0))) (cons 161.0 (lambda () (series-open! BED-B 0))) (cons 164.0 (lambda () (series-open! MOMENT 0))) (cons 167.0 (lambda () (series-open! BED-B 0))) (cons 170.0 (lambda () (report! "opens") (stop-source! "bed-a") (*say* "bench" "done") (set! *done* #t) (set! *on* #f))))) ;; A2's bed as the director opens it: nothing carried, so the drone's ;; notes are dropped (a muted channel still synthesises). (define (start-a2-bed!) (let* ((t0 (now-ms)) (t (strip-uncarried (read-tune-string (tune-text A2-BED)) '())) (t1 (now-ms)) (p (open-tune-player t (mixer-rate) loop: #t)) (t2 (now-ms))) (set! *players* (cons (cons "a2" p) *players*)) (*say* "bench" "open" A2-BED "parse" (r1 (- t1 t0)) "open" (r1 (- t2 t1)) "live") (mixer-add! "a2" (lambda (out off k) (player-pull! p out off k)) #t))) ;; Every frame while running: now (s, the shell's clock), the frame's ;; interval and its work (ms, the frame before this one: the work of the ;; frame in progress is not known yet). (define (bench-frame! now frame-ms work-ms) (when *on* (cond ((not *t0*) (when (ready?) (set! *t0* now) (*say* "bench" "start" "rate" (n (mixer-rate)) "state" (symbol->string (mixer-state))))) (else (set! *frames* (cons frame-ms *frames*)) (set! *works* (cons work-ms *works*)) ;; the open series' dry figure, and the work of the frame that opened (when (and *pending-open* (= (length *pending-open*) 5)) (set! *pending-open* (append *pending-open* (list work-ms)))) (when (and *pending-open* (>= now (list-ref *pending-open* 4)) (= (length *pending-open* ) 6)) (let ((p *pending-open*)) (*say* "bench" "open" (list-ref p 0) "parse" (r1 (list-ref p 1)) "open" (r1 (list-ref p 2)) "work" (r1 (list-ref p 5)) "dry" (n (- (dry-now) (list-ref p 3)))) (set! *pending-open* #f))) (let loop () (when (< *step* (length STEPS)) (let ((s (list-ref STEPS *step*))) (when (>= (- now *t0*) (car s)) (set! *step* (+ *step* 1)) ((cdr s)) (loop)))))))))))src/harkfell/ears.sgladded
;;; (harkfell ears) - The listening ears (harkfell-design section 3, "The;;; loop" 2): standing still, the creature's ears rise and point toward the;;; strongest finding tone, the cue for a mono speaker and for a player who;;; cannot hear.;;;;;; Drawn over the body (A0's pale block with ears, (harkfell draw)'s;;; draw-body-at) in the room's target, after the room: the resting ears;;; are two 1x3 strokes at the box's top; listening they grow two pixels and;;; lean toward the tone (left, right), stand straight (up), or lie out flat;;; (below). In the colour of the world palette's fur-1 (A1's placeholder;;; #e8e0cf), so they read as the same ears grown, not a marker.;;;;;; (draw-ears b ox oy dir) b: the body; ox oy: the room's origin in world;;; pixels; dir: up | left | right | below | #f(define-library (harkfell ears) (import (sigil core) (sigil math) (sigil graphics) (engine body)) (export draw-ears) (begin (define (px x y) (draw-filled-rect x y 1 1)) (define (draw-ears b ox oy dir) (when dir (let ((x (- (exact (floor (- (body-x b) ox))) 1)) (y (exact (floor (- (body-y b) oy))))) (set-color 0.910 0.878 0.812 1.0) (case dir ((up) (draw-filled-rect (+ x 2) (- y 2) 1 5) (draw-filled-rect (+ x 7) (- y 2) 1 5)) ((left right) (let ((s (if (eq? dir 'left) -1 1))) (for-each (lambda (ex) (draw-filled-rect ex y 1 3) (px (+ ex s) (- y 1)) (px (+ ex s s) (- y 2))) (list (+ x 2) (+ x 7))))) ((below) (draw-filled-rect (- x 1) (+ y 3) 3 1) (draw-filled-rect (+ x 8) (+ y 3) 3 1)) (else #f)))))))src/harkfell/shell.sglmodified
(engine roomfile) (harkfell content) (harkfell check) (harkfell legend)) (harkfell legend) (harkfell sound) (harkfell ears)) (export shell-boot! shell-frame! shell-present! shell-reset-world! shell-key-down! shell-key-up! shell-keys-clear! shell-load-content! shell-content shell-goto! shell-goto-start! shell-test-room! shell-set-mode! shell-mode shell-set-still! shell-tap! shell-replace-file! shell-replace-files! shell-room-label shell-view-size shell-set-extra-lines! VIEW-W VIEW-H) (begin (define (now-ms) (/ (* 1000.0 (current-jiffy)) (jiffies-per-second))) ;; A2: more readout lines (the sound's), from whoever sets them (define *extra-lines* (lambda () '())) (define (shell-set-extra-lines! proc) (set! *extra-lines* proc)) (define (shell-world) *world*) (define (shell-clock) *clock*) (begin-frame) (set-blend-mode 'normal) (with-render-target *room-rt* (draw-play *world*)) (draw-play *world*) ;; A2: the listening ears, over the body while you stand still (when (and (world-room *world*) (sound-ears)) (let ((o (world-origin *world*))) (draw-ears (world-body *world*) (car o) (cdr o) (sound-ears))))) (if (eq? (fit-mode f) 'integer) (draw-render-target *room-rt* (fit-x f) (fit-y f) (fit-w f) (fit-h f)) (begin (number->string (fit-scale f)) (tenths (fit-scale f))) " stick " (symbol->string (shell-stick-form)))) (shell-latency-lines)) (shell-latency-lines) (*extra-lines*)) (fit-x f) (fit-y f) (fit-w f) (max 1 (exact (floor (/ (fit-scale f) 2)))))) (end-frame)src/harkfell/shell/native.sglmodified
(harkfell feel) (harkfell source) (harkfell cli) (harkfell fixtures)) (harkfell fixtures) (harkfell audio)) (export native-main) (guard (x (#t (say "harkfell: live reload skipped a look: " (if (error-object? x) (error-object-message x) "an error")))) (check-reload!))) (let ((ticks (shell-frame! dt))) (shell-present! (frame-width) (frame-height) ticks)) (let* ((w0 (now-ms)) (ticks (shell-frame! dt))) (audio-frame! (* 1000.0 dt) *work-ms*) (shell-present! (frame-width) (frame-height) ticks) (set! *work-ms* (- (now-ms) w0))) (unless (quit-requested?) (frame (+ n 1))))))) (define *work-ms* 0.0) ; A2: the last frame's work (the bench reads it) ;; Loads the world; enters --room's door or the start. #f (after saying ;; why) when there is nothing to play. (define (enter-world! args) (key (arg-value args "--room"))) (for-each (lambda (p) (say "harkfell: " p)) ps) (set! *mtimes* (source-mtimes dir)) (audio-world! (shell-content)) (cond ((and key (let* ((a (arg-value args "--at")) (cr (and a (map string->number (string-split a ",")))) (at (and cr (= (length cr) 2) (car cr) (cadr cr) (cons (car cr) (cadr cr))))) (else (shell-boot!) (shell-set-ms! (and (member "--ms" args) #t)) ;; A2: the sound; --bench runs the phone measurement natively (audio-boot! (lambda parts (say (apply string-append "harkfell:" (map (lambda (p) (string-append " " p)) parts))))) (when (member "--bench" args) (audio-bench!)) (let ((c (arg-value args "--carry")) (t (arg-value args "--tone"))) (when c (audio-carry! c)) (when t (audio-tone-room! t))) (if (or (member "--test-room" args) (enter-world! args)) (begin (run-game "Harkfell" WINDOW-W WINDOW-H (loop))src/harkfell/shell/web.sglmodified
;;; ("tap", "X,Y") a click or tap in canvas pixels (the atlas and the;;; sheet enter the room under it);;; ("test-room", "") ?test-room: A0's grey room;;; ("gesture", NAME) a user gesture: resume the audio context (A2);;; ("bench", "") ?bench: the phone measurement ((harkfell bench));;; ("carry", "2,3") ?carry=: the harmonics carried (their drone voices sound);;; ("tone", "X,Y") ?tone=: the room H2's finding tone sounds from;;; ("tune-chunk", TEXT) part of a tune's text, then;;; ("tune-done", NAME) the text is complete (the answer to a fetch line);;;;;; Trace lines (with ?trace, on the console):;;; harkfell: boot stick FORM the stick form in force;;; harkfell: problem FILE:LINE: ... one per problem (printed always);;; harkfell: room REGION:X,Y the body entered a room (with ?trace,;;; and always after a goto or a tap);;; harkfell: audio RATE STATE the mixer opened (always);;; harkfell: fetch NAME a tune is wanted: the page fetches;;; assets/tunes/NAME.cts (always);;; harkfell: bench ... the measurement's lines (always; see;;; (harkfell bench));;; The replay and envelope lines print whether or not ?trace is on:;;; they are asked for. (harkfell shell) (harkfell replay) (harkfell envelope) (harkfell feel)) (harkfell feel) (harkfell tunes) (harkfell audio)) (export web-main web-dispatch) (define *returns* 0) (define *world-files* '()) ; the page's files, until world-done (define *room* #f) ; the last room label traced (define *work-ms* 0.0) ; A2: the last frame's work (the bench reads it) (define (say . parts) (println "~a" (apply string-append "harkfell:" (map (lambda (p) (string-append " " p)) parts)))) (define (web-tick payload) (let* ((dt (frame-dt payload)) (ms (string->number payload))) (ms (string->number payload)) (w0 (now-ms))) (shell-set-frame-stamp! ms) (let ((ticks (shell-frame! dt))) (audio-frame! (* 1000.0 dt) *work-ms*) (when (and *trace* (not (= (world-returns (shell-world)) *returns*))) (set! *returns* (world-returns (shell-world))) (say "returns" (number->string *returns*))) (set! *room* (shell-room-label)) (say "room" *room*)) (gles3-resize-to-display) (shell-present! (gles3-canvas-width) (gles3-canvas-height) ticks)))) (shell-present! (gles3-canvas-width) (gles3-canvas-height) ticks) (set! *work-ms* (- (now-ms) w0))))) ;; "A,B,C" -> a list of numbers (#f for a field that is not one). (define (fields payload) (set-viewport #f) (set-letterbox-color 0.0 0.0 0.0) (shell-boot!) ;; A2: the sound. A tune the wasm lacks is asked of the page ;; ("harkfell: fetch NAME"; it answers with tune-chunk and tune-done). (tunes-set-want! (lambda (name) (say "fetch" name))) (audio-boot! say) (gles3-start-loop "harkfell-web-tick") 0) (for-each (lambda (p) (say "problem" p)) ps) (set! *world-files* '()) (shell-goto-start!) (audio-world! (shell-content)) (if (null? ps) 1 0))) ((string=? type "goto") (if (let* ((parts (string-split payload "@")) (when r (set! *room* r) (say "room" r)) (if r 1 0))) ((string=? type "test-room") (shell-test-room!) 0) ;; A2 ((string=? type "gesture") (audio-gesture!) 0) ((string=? type "bench") (audio-bench!) 0) ((string=? type "carry") (audio-carry! payload) 0) ((string=? type "tone") (audio-tone-room! payload) 0) ((string=? type "tune-chunk") (tunes-chunk! payload) 0) ((string=? type "tune-done") (tunes-done! payload) 0) (else 0)))))src/harkfell/sound.sgladded
;;; (harkfell sound) - The sound of the place: the director (A2).;;;;;; harkfell-design section 5, as built into the game:;;;;;; THE BED. One motif player per region plays the region's bed tune (the;;; region's bed: field in world/world.sgl). Its channels are grouped the way;;; the room files name them: the region's bed-channels: list, whose first;;; three names are channels 1, 2 and 3 (Reedfen: wind water reeds) and whose;;; drone is DRONE-CHANNELS (4 H2, 5 H3, 6 H4, 7 H5, 8 H7; motif tunes have;;; at most 8 channels, so the fundamental, under the Hollow, is not a bed;;; channel). A2's Reedfen bed has two drone voices, H2 and H3.;;; On entering a room every channel RAMPS to the room's level over;;; ROOM-RAMP-S with player-ramp! (channel-volume . ch); a group the room does;;; not name goes to DEFAULT-LEVELS. A drone channel for a harmonic not;;; carried is muted (A3's pickups unmute them; ?carry= does it for;;; listening). Entering another region's room crossfades the beds over;;; BORDER-S.;;;;;; THE MOMENT. A room's fixed moment (moment: (tune: NAME ...)) is opened;;; ahead (sound-preopen!, at boot: an open is a frame-thread stall) and;;; started on the first visit the hush allows ((harkfell hush)). The bed;;; ducks DUCK-GAIN (6 dB) over DUCK-IN-S on its SOURCE GAIN, the mixer;;; factor 'duck, never on its channel volumes: player-ramp! keeps one ramp;;; per target, so a duck made of channel ramps would be wiped out by the next;;; room's levels (the gate in test/test-sound.sgl holds this). The moment;;; plays to its end and motif's release tail; the bed comes back over;;; DUCK-OUT-S from when its end is heard. Leaving the room does not stop;;; it; walking into a room whose own fixed moment is still armed fades it;;; out over FADE-S (and that room's moment stays armed: the hush counts;;; from the fade).;;;;;; LISTENING. Each unfound harmonic is a finding tone at its place (A2 has;;; H2's, at a placeholder room until A3 places the pickup): louder as you;;; get closer (TONE-DB-PER-ROOM), silent past TONE-ROOMS rooms (D22),;;; panned toward it (at most 60%, the speakers-first rule), brighter above;;; you and darker below. Standing still STILL-S seconds dips the bed 6 dB;;; (factor 'still), brings the tone forward 4 dB, and raises the ears toward;;; the strongest tone (sound-ears); moving brings the bed back.;;;;;; SPOT SOUNDS. One-shot cues rendered at boot from short tunes, scheduled;;; with a rate range each: the bellfrogs of the room croak from where they;;; sit (panned by x, quieter with distance), reed birds call in rooms with;;; reeds, water plops in rooms with water. The soft return has its low tone.;;;;;; (sound-boot! content say) the world is loaded: beds known, cues queued;;; (sound-frame! now dt place) every frame in play (place: the place struct, below);;; (sound-set-carry! hs) harmonics carried: (2 3 ...);;; (sound-set-tone-room! x y) where H2's finding tone is (a door for listening);;; (sound-ears) #f, or up | left | right | below: the ears' point;;; (sound-lines) the readout's lines;;; (sound-hush) (sound-bed-levels) (sound-moment-playing) for the gates(define-library (harkfell sound) (import (sigil core) (sigil math) (sigil struct) (sigil list) (sigil string) (motif tune) (motif tune player) (engine mixer) (harkfell tunes) (harkfell hush)) (export sound-boot! sound-frame! sound-preopen! sound-set-carry! sound-set-tone-room! sound-ears sound-lines sound-hush sound-bed-levels sound-moment-playing sound-reset! sound-set-rng! strip-uncarried place place? place-key place-region place-px place-py place-rx place-ry place-moment place-frogs place-levels bed-channels-of group-channels DUCK-GAIN DUCK-IN-S DUCK-OUT-S ROOM-RAMP-S BORDER-S FADE-S STILL-S DRONE-CHANNELS DEFAULT-LEVELS BED-ID MOMENT-ID TONE-ID) (begin (define DUCK-GAIN 0.5) ; -6 dB (define DUCK-IN-S 2.0) (define DUCK-OUT-S 4.0) (define ROOM-RAMP-S 2.0) (define BORDER-S 4.0) (define FADE-S 2.0) (define STILL-S 1.0) ; standing still this long sharpens listening (define STILL-IN-S 1.0) (define STILL-OUT-S 1.5) (define TONE-GAIN 0.63) ; the tone in its own room, at rest (define TONE-FORWARD 1.585) ; +4 dB while still (the product is held under 1) (define TONE-ROOMS 3.0) ; D22: audible from up to three rooms away (define TONE-DB-PER-ROOM 4.0) (define ROOM-W 400.0) ; a room in pixels (25 x 11 tiles of 16) (define ROOM-H 176.0) (define MAX-PAN 0.6) (define DRONE-CHANNELS '(4 5 6 7 8)) (define HARMONIC-CHANNEL '((2 . 4) (3 . 5) (4 . 6) (5 . 7) (7 . 8))) (define DEFAULT-LEVELS '((wind . 0.5) (water . 0.4) (reeds . 0.4) (texture . 0.4) (drone . 0.6))) (define BED-ID "bed") (define MOMENT-ID "moment") (define TONE-ID "tone-h2") (define TONE-TUNE "tone-h2") ;; What the director is told each frame about where the player is. ;; key the room's key (any equal?-comparable value), or #f outside the world ;; region the region's symbol; bed-tune its bed's tune name; groups its bed-channels ;; levels the room's bed: as an alist ((wind . 0.4) ...) ;; moment the room's fixed moment's tune name, or #f ;; frogs the room's bellfrogs' x positions in room pixels ;; px py the body's centre in world pixels; rx ry the room's coordinate ;; still the body is at rest this frame ;; returns soft returns so far (define-struct place (key default: #f) (region default: #f) (bed-tune default: #f) (groups default: '()) (levels default: '()) (moment default: #f) (frogs default: '()) (water default: #f) (px default: 0.0) (py default: 0.0) (rx default: 0) (ry default: 0) (still default: #f) (returns default: 0)) ;; --- state --------------------------------------------------------------------- (define *say* (lambda parts #f)) (define *hush* (hush-new)) (define *key* #f) (define *region* #f) (define *bed-tune* #f) (define *room-levels* '()) (define *groups* '()) (define *bed-player* #f) (define *bed-src* #f) (define *levels* '()) ; ((ch . #(from to t0 dur)) ...): what the channels were asked for (define *carry* '()) ; harmonics carried (define *moments* '()) ; ((name . player) ...) opened ahead (define *moment* #f) ; (name . player) playing (define *moment-key* #f) (define *tone-player* #f) (define *tone-room* (cons 11 3)) ; PLACEHOLDER: east of A1's six rooms, until A3 places H2 (define *tone-cutoff* #f) (define *pan* (cons 1.0 1.0)) ; the tone's L R gains (define *still-for* 0.0) (define *still* #f) (define *ears* #f) (define *returns* 0) (define *cues* '()) ; ((name . cue) ...) (define *cue-queue* '()) ; names still to render (define *next* '()) ; ((spot . t) ...) when each spot sound is next due (define *now* 0.0) (define *seed* 20260924) (define (sound-hush) *hush*) (define (sound-moment-playing) (and *moment* (car *moment*))) (define (sound-ears) *ears*) ;; A change of what is carried reopens the bed on the next frame (its ;; drone notes are chosen at open: strip-uncarried). (define *carry-changed* #f) (define (sound-set-carry! hs) (unless (equal? hs *carry*) (set! *carry* hs) (set! *carry-changed* #t))) (define (sound-set-tone-room! x y) (set! *tone-room* (cons x y))) (define (sound-set-rng! seed) (set! *seed* seed)) ;; MINSTD (only modulo is bound here): a number in [0, 1) (define (rand!) (set! *seed* (modulo (* *seed* 48271) 2147483647)) (/ *seed* 2147483647.0)) (define (rand-in lo hi) (+ lo (* (- hi lo) (rand!)))) ;; Back to nothing playing (tests; a world reload). (define (sound-reset!) (for-each (lambda (s) (mixer-remove! (source-id s))) (mixer-sources)) (when *bed-player* (player-close! *bed-player*)) (when *moment* (player-close! (cdr *moment*))) (for-each (lambda (m) (player-close! (cdr m))) *moments*) (when *tone-player* (player-close! *tone-player*)) (set! *hush* (hush-new)) (set! *key* #f) (set! *region* #f) (set! *groups* '()) (set! *bed-player* #f) (set! *bed-src* #f) (set! *levels* '()) (set! *moments* '()) (set! *moment* #f) (set! *moment-key* #f) (set! *tone-player* #f) (set! *tone-cutoff* #f) (set! *still-for* 0.0) (set! *still* #f) (set! *ears* #f) (set! *next* '()) (set! *waiting* #f)) ;; --- channels and levels ------------------------------------------------------------ ;; groups: the region's bed-channels, e.g. (wind water reeds drone) (define (bed-channels-of groups) (let loop ((gs groups) (ch 1) (acc '())) (cond ((null? gs) (reverse acc)) ((eq? (car gs) 'drone) (loop (cdr gs) ch (cons (cons 'drone DRONE-CHANNELS) acc))) (else (loop (cdr gs) (+ ch 1) (cons (list (car gs) ch) acc)))))) (define (group-channels groups g) (let ((e (assq g (bed-channels-of groups)))) (if e (cdr e) '()))) (define (level-now ch now) (let ((e (assv ch *levels*))) (if (not e) 1.0 (let* ((r (cdr e)) (from (vector-ref r 0)) (to (vector-ref r 1)) (t0 (vector-ref r 2)) (dur (vector-ref r 3))) (cond ((<= now t0) from) ((or (<= dur 0.0) (>= now (+ t0 dur))) to) (else (+ from (* (- to from) (/ (- now t0) dur))))))))) ;; Every channel's current target: ((ch . level) ...), for the gates and the readout. (define (sound-bed-levels) (map (lambda (e) (cons (car e) (vector-ref (cdr e) 1))) *levels*)) (define (room-level levels g) (let ((e (assq g levels))) (cond (e (cdr e)) ((assq g DEFAULT-LEVELS) (cdr (assq g DEFAULT-LEVELS))) (else 0.5)))) ;; Ramp every channel of the bed to the room's levels. (define (apply-room-levels! levels now secs) (when *bed-player* (for-each (lambda (gc) (let ((v (room-level levels (car gc)))) (for-each (lambda (ch) (let ((from (level-now ch now))) (player-ramp! *bed-player* (cons 'channel-volume ch) from v (* 1000.0 secs)) (set! *levels* (cons (cons ch (vector from v now secs)) (filter (lambda (e) (not (eqv? (car e) ch))) *levels*))))) (cdr gc)))) (bed-channels-of *groups*)))) ;; The bed as opened: the notes of every drone channel whose harmonic is ;; not carried are dropped. Measured (native dev build, 20 s of reedfen-bed ;; at 32000): as written 6.6 s of pull, channels 4 and 5 MUTED 6.4 s (a ;; muted channel still synthesises), their notes dropped 4.9 s. (define (strip-uncarried t carry) (let ((drop (filter-map* (lambda (hc) (and (not (memv (car hc) carry)) (cdr hc))) HARMONIC-CHANNEL))) (tune-with-patterns t (map (lambda (p) (tune-pattern p cells: (filter (lambda (c) (not (and (memv (cell-channel c) drop) (number? (cell-note c))))) (tune-pattern-cells p)))) (tune-patterns t))))) (define (filter-map* f l) (let loop ((l l) (acc '())) (if (null? l) (reverse acc) (let ((v (f (car l)))) (loop (cdr l) (if v (cons v acc) acc)))))) (define (apply-mutes!) (when *bed-player* (for-each (lambda (hc) (player-mute! *bed-player* (cdr hc) (not (memv (car hc) *carry*)))) HARMONIC-CHANNEL))) ;; --- opening ------------------------------------------------------------------------- (define (open name loop?) (let ((t (tune-parsed name))) (and t (guard (e (#t (*say* "sound" "error" "open" name) #f)) (open-tune-player t (mixer-rate) loop: loop?))))) (define (open-bed name) (let ((t (tune-parsed name))) (and t (guard (e (#t (*say* "sound" "error" "open" name) #f)) (open-tune-player (strip-uncarried t *carry*) (mixer-rate) loop: #t))))) (define (pull-of p) (lambda (out off n) (player-pull! p out off n))) ;; A room's fixed moment, opened ahead (the first open of a tune is a ;; frame-thread stall; do it while nothing is at stake). (define (sound-preopen! name) (unless (assoc name *moments*) (let ((p (open name #f))) (when p (set! *moments* (cons (cons name p) *moments*)))))) (define (take-moment! name) (let ((e (assoc name *moments*))) (if e (begin (set! *moments* (filter (lambda (m) (not (eq? m e))) *moments*)) (cdr e)) (open name #f)))) ;; --- the bed --------------------------------------------------------------------------- ;; A new bed for the region (a region entered, or what is carried changed): ;; the old one crossfades out over BORDER-S under an id of its own, keeping ;; its sink and its factors (its duck, its stillness); the new one fades in. ;; A bed that will not open leaves the old one playing as it was (review M2). (define *outs* 0) (define (open-region-bed! region groups bed-tune levels now) (let* ((old-src *bed-src*) (old-player *bed-player*) (first? (not old-src)) (p (and bed-tune (open-bed bed-tune)))) (if (not p) (*say* "sound" "error" "no-bed" (if bed-tune bed-tune "none")) (begin (set! *region* region) (set! *groups* groups) (set! *bed-tune* bed-tune) (set! *levels* '()) (set! *bed-player* p) (when old-src (set! *outs* (+ *outs* 1)) (let ((out-id (string-append BED-ID "-out-" (number->string *outs*)))) (mixer-rename! BED-ID out-id) (source-factor! old-src 'fade 0.0 BORDER-S now) (mixer-at! (+ now BORDER-S) (lambda () (mixer-remove! out-id) (player-close! old-player))))) (set! *bed-src* (mixer-add! BED-ID (pull-of p) #t)) (new-bed-levels! levels now first?))))) (define (enter-region! pl now) (open-region-bed! (place-region pl) (place-groups pl) (place-bed-tune pl) (place-levels pl) now)) (define (new-bed-levels! levels now first?) (let ((pl-levels levels)) (when *bed-src* ;; channels start at the room's levels (no ramp from 1), the ;; source fades in (the first bed over a second, a border over BORDER-S) (apply-room-levels! pl-levels now 0.0) (apply-mutes!) (source-factor! *bed-src* 'fade 0.0 0.0 now) (source-factor! *bed-src* 'fade 1.0 (if first? 1.0 BORDER-S) now) (when (and *moment* (mixer-find MOMENT-ID) (source-live? (mixer-find MOMENT-ID))) (source-factor! *bed-src* 'duck DUCK-GAIN 0.0 now)) (when *still* (source-factor! *bed-src* 'still DUCK-GAIN 0.0 now))))) ;; --- moments --------------------------------------------------------------------------- (define (start-moment! name key now) (let ((p (take-moment! name))) (when p (set! *moment* (cons name p)) (set! *moment-key* key) (set! *hush* (hush-started *hush* name key)) (*say* "sound" "moment" name "start") (let ((s (mixer-add! MOMENT-ID (pull-of p) #t))) (duck-bed! now) (source-on-end! s (lambda (t) (when (and *moment* (eq? (cdr *moment*) p)) (moment-over! t) (*say* "sound" "moment" name "end")) (player-close! p))))))) ;; The duck: the bed's SOURCE gain, never its channels (see the header). (define (duck-bed! now) (when *bed-src* (source-factor! *bed-src* 'duck DUCK-GAIN DUCK-IN-S now))) (define (unduck-bed! t) (when *bed-src* (source-factor! *bed-src* 'duck 1.0 DUCK-OUT-S t))) (define (moment-over! t) (set! *moment* #f) (set! *moment-key* #f) (set! *hush* (hush-ended *hush*)) (unduck-bed! t)) ;; Walking into a room whose own fixed moment is still armed: the playing ;; moment fades out and leaves. (define (fade-moment! now) (let ((s (mixer-find MOMENT-ID)) (m *moment*)) (when (and s m) (*say* "sound" "moment" (car m) "fade") (source-factor! s 'fade 0.0 FADE-S now) (source-on-end! s #f) (moment-over! now) (mixer-at! (+ now FADE-S) (lambda () (mixer-remove! MOMENT-ID) (player-close! (cdr m))))))) ;; --- a room entered ---------------------------------------------------------------------- (define (enter-room! pl now) (set! *key* (place-key pl)) (set! *hush* (hush-enter *hush* (place-key pl))) (set! *next* '()) (set! *room-levels* (place-levels pl)) (if (not (eq? (place-region pl) *region*)) (enter-region! pl now) (apply-room-levels! (place-levels pl) now ROOM-RAMP-S)) (let ((m (place-moment pl))) (when m (let ((d (hush-fixed-decision *hush* (place-key pl)))) (cond ((and *moment* (not (eq? d 'spent))) (fade-moment! now)) ((eq? d 'play) (start-moment! m (place-key pl) now)) (else #f)) (*say* "sound" "room" "moment" m (symbol->string d)))))) ;; --- listening: the tone, stillness, the ears ---------------------------------------------- (define (clamp lo hi x) (max lo (min hi x))) (define (tone-geometry pl) ;; the tone's place: the centre of its room, in world pixels (let* ((tx (+ (* ROOM-W (car *tone-room*)) (/ ROOM-W 2.0))) (ty (+ (* ROOM-H (cdr *tone-room*)) (/ ROOM-H 2.0))) (dx (/ (- tx (place-px pl)) ROOM-W)) (dy (/ (- ty (place-py pl)) ROOM-H))) (list dx dy (sqrt (+ (* dx dx) (* dy dy)))))) (define (tone-gain d) (cond ((> d (+ TONE-ROOMS 0.5)) 0.0) (else (let ((g (* TONE-GAIN (expt 10.0 (/ (* -1.0 TONE-DB-PER-ROOM (max 0.0 (- d 0.5))) 20.0))))) ;; the last half room fades to nothing (if (> d TONE-ROOMS) (* g (- 1.0 (* 2.0 (- d TONE-ROOMS)))) g))))) ;; brighter above you, darker below, darker with distance: a low-pass ;; corner in Hz, or #f for none (in the same room). Done by a one-pole ;; low-pass in the tone's own pull, not with player-set-instrument-param!: ;; that swaps a fresh voice graph in mid-note (the note is held from row 0, ;; so the fresh envelope re-attacks) and keeps every old graph in the ;; player's roots (review M6). (define (tone-cutoff d dy) (let ((base (cond ((< d 0.75) #f) ((< d 1.75) 2000.0) (else 900.0)))) (and base (cond ((< dy -0.5) (* base 1.5)) ((> dy 0.5) (* base 0.6)) (else base))))) ;; the one-pole's coefficient for a corner (1.0: no filtering), eased ;; toward its target each frame so a change of band is not a step (define *tone-a* 1.0) (define *tone-a-target* 1.0) (define *lp-l* 0.0) (define *lp-r* 0.0) (define (corner->a fc) (if fc (- 1.0 (exp (/ (* -2.0 3.14159265 fc) (mixer-rate)))) 1.0)) (define (tone-pull p) (lambda (out off n) (and (player-pull! p out off n) (let ((gl (car *pan*)) (gr (cdr *pan*)) (a *tone-a*)) (let loop ((i 0) (yl *lp-l*) (yr *lp-r*)) (if (< i n) (let* ((o (+ off (* 8 i))) (yl (+ yl (* a (- (bytevector-ieee-single-ref out o 'little) yl)))) (yr (+ yr (* a (- (bytevector-ieee-single-ref out (+ o 4) 'little) yr))))) (bytevector-ieee-single-set! out o (* gl yl) 'little) (bytevector-ieee-single-set! out (+ o 4) (* gr yr) 'little) (loop (+ i 1) yl yr)) (begin (set! *lp-l* yl) (set! *lp-r* yr)))) #t)))) (define (close-tone!) (mixer-remove! TONE-ID) (when *tone-player* (player-close! *tone-player*)) (set! *tone-player* #f)) (define (tone-frame! pl now) (when (and (not (memv 2 *carry*)) (not *tone-player*) (tune-text TONE-TUNE)) (set! *tone-player* (open TONE-TUNE #t)) (when *tone-player* (let ((s (mixer-add! TONE-ID (tone-pull *tone-player*) #t))) (source-factor! s 'dist 0.0 0.0 now)))) (let ((s (mixer-find TONE-ID))) (when s (if (memv 2 *carry*) ;; carried: the tone fades and its player closes (review M5) (unless (> (source-factor s 'dist now) 0.99) (source-factor! s 'dist 0.0 0.5 now) (let ((p *tone-player*)) (mixer-at! (+ now 0.6) (lambda () (when (eq? p *tone-player*) (close-tone!)))))) (let* ((g (tone-geometry pl)) (dx (car g)) (dy (cadr g)) (d (caddr g)) (pan (clamp (- MAX-PAN) MAX-PAN (* 0.6 dx)))) (source-factor! s 'dist (tone-gain d) 0.25 now) ;; equal power, at most MAX-PAN toward it; neither side above unity (let ((a (* 0.25 3.14159265 (+ pan 1.0)))) (set! *pan* (cons (min 1.0 (* 1.41421356 (cos a))) (min 1.0 (* 1.41421356 (sin a)))))) (set! *tone-a-target* (corner->a (tone-cutoff d dy))) (set! *tone-a* (+ *tone-a* (* 0.1 (- *tone-a-target* *tone-a*))))))))) (define (still-frame! pl dt now) (set! *still-for* (if (place-still pl) (+ *still-for* dt) 0.0)) (let ((now-still (>= *still-for* STILL-S))) (unless (eq? now-still *still*) (set! *still* now-still) (when *bed-src* (source-factor! *bed-src* 'still (if now-still DUCK-GAIN 1.0) (if now-still STILL-IN-S STILL-OUT-S) now)) (let ((s (mixer-find TONE-ID))) (when s (source-factor! s 'still (if now-still TONE-FORWARD 1.0) (if now-still STILL-IN-S STILL-OUT-S) now))))) (set! *ears* (and *still* (let* ((g (tone-geometry pl)) (dx (car g)) (dy (cadr g)) (d (caddr g))) (cond ((or (memv 2 *carry*) (> d (+ TONE-ROOMS 0.5)) (< d 0.3)) 'up) ((>= (abs dx) (abs dy)) (if (> dx 0) 'right 'left)) ((< dy 0) 'up) (else 'below)))))) ;; --- spot sounds --------------------------------------------------------------------------- ;; (name tune lo hi gain): rendered from assets/tunes/TUNE.cts at boot (define SPOTS '((frog "spot-frog" 5.0 14.0 0.55) (bird "spot-bird" 14.0 32.0 0.35) (plop "spot-plop" 7.0 18.0 0.4) (return "return-tone" 0.0 0.0 0.5))) (define (spot-ref name) (assq name SPOTS)) ;; Render the queued cues a few blocks a frame (review M7: a whole cue at ;; once, its notes and motif's 2 s release tail, was one long frame each). (define CUE-BLOCK 1024) (define CUE-BLOCKS-PER-FRAME 4) (define *cue-job* #f) ; #(name player chunks frames) being rendered (define *cue-buf* (make-bytevector (* 8 CUE-BLOCK) 0)) (define (render-next-cue!) (when (and (not *cue-job*) (pair? *cue-queue*)) (let* ((name (car *cue-queue*)) (spec (spot-ref name)) (p (and spec (open (cadr spec) #f)))) (set! *cue-queue* (cdr *cue-queue*)) (when p (set! *cue-job* (vector name p '() 0))))) (when *cue-job* (let* ((j *cue-job*) (p (vector-ref j 1)) (limit (* 6 (mixer-rate)))) (let loop ((k 0)) (when (< k CUE-BLOCKS-PER-FRAME) (let ((more (and (< (vector-ref j 3) limit) (player-pull! p *cue-buf* 0 CUE-BLOCK)))) (if more (begin (vector-set! j 2 (cons (mono-of *cue-buf* CUE-BLOCK) (vector-ref j 2))) (vector-set! j 3 (+ (vector-ref j 3) CUE-BLOCK)) (loop (+ k 1))) (finish-cue! j)))))))) (define (finish-cue! j) (let* ((frames (vector-ref j 3)) (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
;;; (harkfell tunes) - The game's .cts tunes by name: their text and their parse.;;;;;; A tune lives at assets/tunes/NAME.cts. Natively its text is read from;;; there (the game runs from the repository's root, as scripts/dev does);;;; on the web the page fetches it and hands the text over in chunks;;; (tunes-chunk!, tunes-done!), and a name the wasm needs and does not have;;; is asked for through the WANT hook (the web shell prints a line the page;;; answers with a fetch).;;;;;; Parsing is a known stall on a phone (Crash's M1: a tune open is up to;;; ~260 ms of the frame thread), so the parse is kept apart from the open:;;; tune-parsed parses once and keeps the value, and each parse's cost is;;; recorded (tunes-parse-ms).;;;;;; (tune-text name) the text, or #f (natively: read on first use);;; (tune-parsed name) the tune value, or #f (no text, or it does not read);;; (tunes-provide! name text);;; (tunes-chunk! s) (tunes-done! name) the page's delivery;;; (tunes-set-want! proc) (proc name): a text is missing;;; (tunes-parse-ms name) the last parse's ms, or #f(define-library (harkfell tunes) (import (sigil core) (sigil string) (sigil time) (sigil io) (sigil fs) (motif tune)) (export tune-text tune-parsed tunes-provide! tunes-chunk! tunes-done! tunes-set-want! tunes-parse-ms tune-path tune-exists?) (begin (define *texts* '()) ; ((name . text) ...) (define *parsed* '()) ; ((name . tune) ...) (define *parse-ms* '()) ; ((name . ms) ...) (define *chunks* '()) (define *want* #f) (define *wanted* '()) (define (now-ms) (/ (* 1000.0 (current-jiffy)) (jiffies-per-second))) (define (tune-path name) (string-append "assets/tunes/" name ".cts")) ;; Natively: does the file exist (the content check's question) (define (tune-exists? name) (file-exists? (tune-path name))) (define (tunes-set-want! proc) (set! *want* proc)) (define (tunes-provide! name text) (set! *texts* (cons (cons name text) (filter (lambda (e) (not (string=? (car e) name))) *texts*))) (set! *parsed* (filter (lambda (e) (not (string=? (car e) name))) *parsed*))) (define (tunes-chunk! s) (set! *chunks* (cons s *chunks*))) (define (tunes-done! name) (tunes-provide! name (apply string-append (reverse *chunks*))) (set! *chunks* '())) (define (tune-text name) (let ((e (assoc name *texts*))) (cond (e (cdr e)) (*want* (unless (member name *wanted*) (set! *wanted* (cons name *wanted*)) (*want* name)) #f) ((tune-exists? name) (let ((text (read-file-string (tune-path name)))) (tunes-provide! name text) text)) (else #f)))) (define (tune-parsed name) (let ((e (assoc name *parsed*))) (if e (cdr e) (let ((text (tune-text name))) (and text (let* ((t0 (now-ms)) (t (guard (x (#t #f)) (read-tune-string text)))) (set! *parse-ms* (cons (cons name (- (now-ms) t0)) *parse-ms*)) ;; a text that does not read is remembered as #f: not parsed again (set! *parsed* (cons (cons name t) *parsed*)) t)))))) (define (tunes-parse-ms name) (let ((e (assoc name *parse-ms*))) (and e (cdr e))))))test/test-place.sgladded
;;; The adapter from the running world to the sound director's PLACE;;; ((harkfell audio) world-place), on the real world (world/): the place's;;; px py are ABSOLUTE world pixels, room (rx, ry) spanning x 400*rx ..;;; 400*(rx+1) and y 176*ry .. 176*(ry+1), which is the frame the director's;;; tone and spot-sound maths use. The world map's own pixels start at its;;; westmost and northmost room (worldmap-x0/y0: Reedfen's (8 3)), not at;;; room (0 0); the first build passed those through and put H2's tone eight;;; rooms away from every room (the adversarial review's H1, 2026-09-24).;;; Checked in two rooms, so a constant offset cannot pass by luck.(import (sigil test) (sigil list) (engine worldmap) (harkfell world) (harkfell content) (harkfell source) (harkfell check) (harkfell feel) (engine mixer) (harkfell sound) (harkfell audio))(define CT (load-content (read-world-source "world")));; The body standing in room (x, y) at its start cell, as a world.(define (world-in x y) (let* ((rm (content-room CT x y)) (cell (room-start-cell CT rm))) (world-at (content-map CT) (cell-body (worldmap-tiles (content-map CT)) HARKFELL-FEEL (car cell) (cdr cell)))))(define (inside? pl) (and (<= (* 400 (place-rx pl)) (place-px pl) (* 400 (+ 1 (place-rx pl)))) (<= (* 176 (place-ry pl)) (place-py pl) (* 176 (+ 1 (place-ry pl))))))(test-group "place: absolute world pixels" (test "the world loads clean (the control)" (assert-equal '() (content-problems CT))) (test "Fen Edge (8 3): the body's place is inside room (8 3)'s absolute span" (let ((pl (world-place (world-in 8 3) CT))) (assert-equal 8 (place-rx pl)) (assert-true (inside? pl)))) (test "Climb Back (10 4): inside room (10 4)'s absolute span" (let ((pl (world-place (world-in 10 4) CT))) (assert-equal 10 (place-rx pl)) (assert-equal 4 (place-ry pl)) (assert-true (inside? pl)))) (test "Reed Bridge carries its moment and its frogs, in room pixels" (let ((pl (world-place (world-in 9 3) CT))) (assert-equal "first-open-water" (place-moment pl)) (assert-equal 2 (length (place-frogs pl))) (assert-true (every (lambda (x) (< 0 x 400)) (place-frogs pl))))));; Listening, end to end on the real world: the director given the adapter's;; place, standing still for 3 s. H2's tone is at its placeholder room (11 3),;; east of Old Stone (10 3); Fen Edge (8 3) is three rooms from it. The tone;; must sound in both, louder in Old Stone, and the ears must lean east.(define (listen-in x y) (mixer-open-headless! 32000 (lambda (id buf n gain) #f)) (sound-reset!) (sound-set-tone-room! 11 3) (let* ((w (world-in x y)) (base (world-place w CT)) (pl (place base still: #t))) (let loop ((f 0)) (let ((now (/ f 30.0))) (when (<= now 3.0) (sound-frame! now (if (= f 0) 0.0 (/ 1 30.0)) pl) (mixer-pump! now) (loop (+ f 1))))) (let ((s (mixer-find TONE-ID))) (list (sound-ears) (and s (source-gain s 3.0))))))(define OLD-STONE (listen-in 10 3))(define FEN-EDGE (listen-in 8 3))(test-group "place: listening on the real world" (test "in Old Stone, still: the ears lean east and the tone sounds" (assert-equal 'right (car OLD-STONE)) (assert-true (> (cadr OLD-STONE) 0.2))) (test "in Fen Edge, three rooms away: fainter, but there" (assert-equal 'right (car FEN-EDGE)) (assert-true (< 0.0 (cadr FEN-EDGE) (cadr OLD-STONE)))))test/test-sound.sgladded
;;; A2's gate 4 (harkfell-design section 10): a room change during a moment;;; ramps the bed's channels to the new room's levels while the duck on the;;; source gain holds. Run through the REAL director, the real motif player;;; and the real bed tune (assets/tunes/reedfen-bed.cts), in the mixer's;;; headless mode, measured at the bed's output (its samples times its;;; source gain), not by reading the director's own bookkeeping: the defect;;; this guards is motif's one-ramp-per-target rule, so the check has to go;;; through motif.;;;;;; The measurement: two runs of the same bed from t = 0, so the seeded noise;;; is the same sample for sample and only the levels differ.;;; scenario Reed Bridge (its fixed moment starts, the bed ducks), then at;;; 5 s into Old Stone (different levels; the moment plays on);;; reference Old Stone from the start: its levels, no moment, no duck;;; Over 8 s to 10 s (the room's 2 s ramp is done at 7 s) the scenario's bed;;; must be the reference's times the duck: 0.5, -6 dB. The sabotage in;;; scripts/gate-sound (the duck made of channel ramps) puts it back at the;;; reference's level, a ratio near 1.(import (sigil test) (sigil list) (sigil math) (engine mixer) (harkfell hush) (harkfell sound))(define RATE 32000)(define GROUPS '(wind water reeds drone))(define REED-BRIDGE (place key: "reedfen:9,3" region: 'reedfen bed-tune: "reedfen-bed" groups: GROUPS levels: '((wind . 0.4) (water . 0.8) (reeds . 0.5)) moment: "first-open-water" px: 3800.0 py: 620.0 rx: 9 ry: 3))(define OLD-STONE (place key: "reedfen:10,3" region: 'reedfen bed-tune: "reedfen-bed" groups: GROUPS levels: '((wind . 0.6) (reeds . 0.3) (drone . 0.4)) moment: #f px: 4200.0 py: 620.0 rx: 10 ry: 3));; The bed's energy between t-lo and t-hi: the sum of its squared samples;; times its source gain squared, block by block.(define *energy* 0.0)(define *t* 0.0)(define *lo* 8.0)(define *hi* 10.0)(define (tap id buf n gain) (when (and (string=? id BED-ID) (>= *t* *lo*) (< *t* *hi*)) (let loop ((i 0) (acc 0.0)) (if (< i (* 2 n)) (let ((x (bytevector-ieee-single-ref buf (* 4 i) 'little))) (loop (+ i 1) (+ acc (* x x)))) (set! *energy* (+ *energy* (* gain gain acc)))))));; Run the director from 0 to `until` s at 30 frames a second; `plan` gives;; the place for a time. Answers the bed's energy in the window.(define (run plan until) (mixer-open-headless! RATE tap) (sound-reset!) (set! *energy* 0.0) (let loop ((f 0)) (let ((now (/ f 30.0))) (when (<= now until) (set! *t* now) (sound-frame! now (if (= f 0) 0.0 (/ 1 30.0)) (plan now)) (mixer-pump! now) (loop (+ f 1))))) *energy*)(define (db x) (* 10.0 (/ (log x) (log 10.0))))(define SCENARIO (run (lambda (t) (if (< t 5.0) REED-BRIDGE OLD-STONE)) 10.2))(define SCENARIO-MOMENT (sound-moment-playing))(define SCENARIO-LEVELS (sound-bed-levels))(define REFERENCE (run (lambda (t) OLD-STONE) 10.2))(define REFERENCE-MOMENT (sound-moment-playing))(test-group "sound: gate 4, a room change during a moment" (test "both runs measured something (the window is not empty)" (assert-true (> REFERENCE 1.0e-3)) (assert-true (> SCENARIO 1.0e-4))) (test "the moment was playing through the window (it is what the duck is for)" (assert-equal "first-open-water" SCENARIO-MOMENT) (assert-false REFERENCE-MOMENT)) (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)" (assert-equal 0.6 (cdr (assv 1 SCENARIO-LEVELS))) (assert-equal 0.4 (cdr (assv 2 SCENARIO-LEVELS))) (assert-equal 0.3 (cdr (assv 3 SCENARIO-LEVELS))) (assert-equal 0.4 (cdr (assv 4 SCENARIO-LEVELS)))) (test "and the duck held: the bed sits 6 dB under the same room without a moment (within 1.5 dB)" (let ((d (db (/ SCENARIO REFERENCE)))) (display " scenario / reference: ") (display d) (display " dB") (newline) (assert-true (< -7.5 d -4.5)))));; A change of what is carried reopens the bed with its drone notes (a;; crossfade over 4 s). During a moment, the bed going out and the bed coming;; in both keep the duck (review M1: the outgoing bed was a fresh source and;; jumped 6 dB up for its fade), and it is not a visit (no hush count).(define CARRY-AT 6.0)(define *carried* #f)(define ROOMS-BEFORE #f)(define (carry-plan t) (when (and (>= t CARRY-AT) (not *carried*)) (set! *carried* #t) (set! ROOMS-BEFORE (hush-rooms (sound-hush))) (sound-set-carry! '(2))) REED-BRIDGE)(run carry-plan 7.0)(define BEDS (filter (lambda (s) (let ((id (source-id s))) (and (>= (string-length id) 3) (string=? (substring id 0 3) "bed")))) (mixer-sources)))(define NOW 7.0)(test-group "sound: a carry change during a moment" (test "two beds sound during the crossfade (the old one renamed, the new one as bed)" (assert-equal 2 (length BEDS)) (assert-true (mixer-find BED-ID))) (test "both hold the duck" (for-each (lambda (s) (assert-equal 0.5 (source-factor s 'duck NOW))) BEDS)) (test "and the moment still plays" (assert-equal "first-open-water" (sound-moment-playing))) (test "it was not a visit: the hush's room count did not move" (assert-equal ROOMS-BEFORE (hush-rooms (sound-hush)))))test/test-tunes.sgladded
;;; The world's sounds exist (A2): every room's fixed moment and every;;; region's bed names a tune at assets/tunes/NAME.cts that reads as a tune.;;; A1's content gate checks that a moment's name is one of its region's;;; moments:; this checks there is a file behind it, so a typo or a missing;;; file is red here rather than silent in play (the director skips a tune it;;; cannot open).;;;;;; The control and the break: the real world (clean), and a copy whose Reed;;; Bridge names a moment with no file (red, naming the room and the tune).(import (sigil test) (sigil list) (sigil string) (motif tune) (engine worldmap) (harkfell content) (harkfell source) (harkfell tunes) (harkfell audio))(define WORLD (read-world-source "world"));; Every sound the content names, as (what . tune-name).(define (named-tunes ct) (append (filter-map* (lambda (p) (let* ((rm (placed-data p)) (m (room-moment-name rm))) (and m (cons (string-append (room-label rm) " moment") m)))) (content-rooms ct)) (filter-map* (lambda (r) (let ((b (region-field r 'bed))) (and b (cons (string-append (symbol->string (region-name r)) " bed") (if (symbol? b) (symbol->string b) b))))) (content-regions ct))))(define (filter-map* f l) (let loop ((l l) (acc '())) (if (null? l) (reverse acc) (let ((v (f (car l)))) (loop (cdr l) (if v (cons v acc) acc))))));; The problems: a name with no file, or a file that does not read.(define (tune-problems ct) (filter-map* (lambda (e) (cond ((not (tune-exists? (cdr e))) (string-append (car e) ": no " (tune-path (cdr e)))) ((not (guard (x (#t #f)) (read-tune-string (tune-text (cdr e))))) (string-append (car e) ": " (tune-path (cdr e)) " does not read")) (else #f))) (named-tunes ct)))(define (ends-with? s suf) (let ((n (string-length s)) (m (string-length suf))) (and (>= n m) (string=? (substring s (- n m) n) suf))))(define (swap s a b) (let ((n (string-length s)) (m (string-length a))) (let loop ((k 0)) (cond ((> (+ k m) n) (error "swap: not found" a)) ((string=? (substring s k (+ k m)) a) (string-append (substring s 0 k) b (substring s (+ k m) n))) (else (loop (+ k 1)))))))(test-group "tunes: every named sound has its file" (test "the world names at least one moment and one bed (the check has a subject)" (let ((ns (named-tunes (load-content WORLD)))) (assert-true (find (lambda (e) (string-contains? (car e) "moment")) ns)) (assert-true (find (lambda (e) (string-contains? (car e) "bed")) ns)))) (test "the real world: every one exists and reads (the control)" (assert-equal '() (tune-problems (load-content WORLD)))) (test "a moment with no file is red, naming the room and the path" (let* ((src (map (lambda (e) (if (ends-with? (car e) "x9y3.room") (cons (car e) (swap (cdr e) "tune: first-open-water" "tune: frog-choir")) e)) WORLD)) (ps (tune-problems (load-content src)))) (assert-true (member "reedfen:9,3 moment: no assets/tunes/frog-choir.cts" ps)))))