7 · Sound
Two verbs make a noise, both in the palette’s Sound group. play plays a
file; tone synthesises a frequency. The second one is the interesting one,
because a frequency can be an expression.
The Sounds strip
Section titled “The Sounds strip”Across the top of the Studio is a fold marked ▶ Sounds. Open it and you get
the stock clip library: click the ▶ beside a clip to hear it, then use its name
in a play step. blaster.wav, charge.wav, powerup.wav, powerdown.wav,
click.wav, zap.wav and alarm.wav are enough to build a prop without
recording anything.
A play step’s file box is a picker of those clips, not a text field you
have to spell correctly.
1 · play — a file
Section titled “1 · play — a file”Add a play to the shot script: file blaster.wav, vol 0.5, and set
its flow to detached.
play can also name a folder or a list, and picks at random. That is
easiest to write in the Show tab:
{ "play": "sounds/voices" } // any clip in that folder{ "play": ["hit1.wav", "hit2.wav", "hit3.wav"] } // any of these three{ "play": ["hit1.wav", "hit2.wav"], "pick": "sequence" } // round-robin instead2 · tone — a frequency
Section titled “2 · tone — a frequency”A tone row is: a frequency box, a wave dropdown (sine / square /
saw / tri), seconds, and volume. No file, no storage. On a board
with a few hundred kilobytes of flash, a prop built out of tones rather than
samples is a genuinely different proposition.
Leave seconds empty and the tone plays until something stops it — which is what the charge-up needs.
3 · The charge-up
Section titled “3 · The charge-up”Add a var charge, then a script chargeUp with three children:
| # | Verb | Set | flow |
|---|---|---|---|
| 1 | tone |
frequency 260 + charge*1100, wave saw, seconds blank, volume 0.08 + charge*0.22 |
detached |
| 2 | pattern |
@barrel, colour i/count < charge ? hsv(0.12 - charge*0.12, 1, 1) : 0x060100, loop ticked |
— |
| 3 | tween |
charge → 1 over 1.4 s, ease in |
serial |
charge tweens from 0 to 1 over 1.4 seconds; the tone’s frequency expression is
re-read every frame, so the pitch climbs with it from 260 Hz to 1360 Hz. Nothing
is being pitch-shifted and there is no audio file — the oscillator is simply
told a different number each frame.
The second row is a bar graph made out of a strip: i/count < charge is true
for the pixels below the charge level and false above it, so the ternary lights
them and dims the rest.
And every flow is load-bearing again:
- The tone is detached because it has no
seconds. A parent cannot wait for a sound with no end; make it serial and the script hangs forever. - The pattern loops, so it detaches itself and just runs.
- The
tweenis the only serial child, which makes it the thing that decides how long a full charge takes.
4 · Hold and release
Section titled “4 · Hold and release”On the Ready card, add two more handlers beside the one from part 4:
when |
then go → |
if |
|---|---|---|
trigger.tapped |
Firing |
heat < 1 && ammo > 0 |
trigger.held |
Charging |
heat < 1 && ammo > 0 |
trigger.pressed |
Dry |
ammo <= 0 |
Then a Charging state: on enter start ▸ chargeUp, on exit stop ▸
chargeUp, and when trigger.released → HeavyFiring.
Leaving Charging stops chargeUp, which kills the endless tone — that is what
on exit is for. Release early and you fire at partial charge, because
charge is wherever the tween had got to.
5 · One script, every power level
Section titled “5 · One script, every power level”The heavyShot script scales by charge in six places at once — every one of
these goes in an ordinary parameter box:
| Row | Box | Expression |
|---|---|---|
play |
vol | 0.4 + charge*0.5 |
tone |
seconds | 0.2 + charge*0.3 |
tone |
volume | 0.10 + charge*0.25 |
set |
sec | 0.05 + charge*0.10 |
comet |
colour | hsv(0.12 - charge*0.12, 0.9, 1) |
comet |
stop after | 0.2 + charge*0.3 |
Louder, longer, brighter, redder, with a low saw underneath. A tap and a full-power blast are the same four rows.
The cost scales too, on the HeavyFiring state’s on enter:
set heat = min(heat + 0.17 + charge*0.3, 1)set ammo = max(ammo - 1 - floor(charge*2), 0)A full charge burns three rounds and nearly half the heat budget. Game design, in two boxes.
Speed flows downhill — including pitch
Section titled “Speed flows downhill — including pitch”Open the Live tab and drag master speed to 0.5 while the charge-up runs.
The whine drops an octave along with everything else, because audio is inside
the same time domain as the lights. Put 0.6 in a script card’s speed box
and the same thing happens to just that subtree.
What the editor wrote
Section titled “What the editor wrote”{
// ===========================================================
// BLASTER · STEP 7 — Sound
// Two ways to make a noise: `play` a file, or `tone` a
// frequency the engine synthesises. A tone costs no storage
// and it can be an EXPRESSION, which is how the blaster gets
// a charge-up whine that tracks a number.
// ===========================================================
"schemaVersion": 1,
"name": "Blaster 07 - Sound",
"board": "t-display-s3",
"vars": { "heat": 0, "ambient": 0, "ammo": 12, "charge": 0, "venting": 0 },
"devices": {
"barrel": { "kind": "ledstrip", "count": 8, "pin": 16, "order": "GRB", "brightness": 160 },
"hud": { "kind": "screen", "width": 320, "height": 170 }
},
"inputs": {
"trigger": { "label": "TRIGGER", "color": "#ff3b30", "pin": 14, "activeLow": true, "pull": "up" },
"reload": { "label": "RELOAD", "color": "#2ca7ff", "pin": 0, "activeLow": true }
},
"scripts": {
"hudView": {
"autostart": true,
"children": [
{ "fill": "@hud", "color": "#000000", "flow": "parallel" },
{ "text": "@hud", "value": "AMMO", "x": 12, "y": 14, "size": 1,
"color": "#4a5560", "flow": "parallel" },
{ "number": "@hud", "value": "ammo", "digits": 2, "x": 12, "y": 28, "size": 4,
"color": "ammo > 0 ? 0x00e0a0 : 0xff3b30", "flow": "parallel" },
{ "text": "@hud", "value": "HEAT", "x": 12, "y": 118, "size": 1,
"color": "#4a5560", "flow": "parallel" },
{ "meter": "@hud", "value": "heat", "min": 0, "max": 1,
"x": 12, "y": 132, "w": 120, "h": 16,
"color": "hsv(0.33 - heat*0.33, 1, 1)", "flow": "parallel" },
// The ring TIGHTENS as the shot charges: `r` is a number,
// and any number in a show can be an expression.
{ "circle": "@hud", "x": 215, "y": 85, "r": "42 - charge*12", "outline": true, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "circle": "@hud", "x": 215, "y": 85, "r": "3 + charge*4", "flow": "parallel",
"color": "hsv(0.12 - charge*0.12, 0.9, 1)" },
{ "line": "@hud", "x0": 215, "y0": 30, "x1": 215, "y1": 39, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "line": "@hud", "x0": 215, "y0": 131, "x1": 215, "y1": 140, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "line": "@hud", "x0": 160, "y0": 85, "x1": 169, "y1": 85, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "line": "@hud", "x0": 261, "y0": 85, "x1": 270, "y1": 85, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "text": "@hud", "value": "READY", "x": 150, "y": 150, "size": 1, "flow": "parallel",
"color": "venting == 0 && ammo > 0 ? 0x00e0a0 : 0x1d2a24" },
{ "text": "@hud", "value": "EMPTY", "x": 200, "y": 150, "size": 1, "flow": "parallel",
"color": "ammo <= 0 ? 0xff3b30 : 0x2a1d1d" },
{ "text": "@hud", "value": "VENT", "x": 252, "y": 150, "size": 1, "flow": "parallel",
"color": "venting ? 0xffb000 : 0x2a2418" }
]
},
"idle": {
"children": [
{ "follow": "heat", "target": "ambient", "rate": 0.22 },
{ "pattern": "@barrel", "loop": true,
"color": "hsv(0.09 - heat*0.09, 1, 0.16 + 0.55*heat + 0.10*sin(time*3 + i*0.7))" }
]
},
// ---- HOLD TO CHARGE -------------------------------------------
// The whine is ONE tone whose frequency is an expression. As
// `charge` tweens 0 -> 1 the pitch climbs with it, because the
// expression is re-read every frame. There is no audio file
// here and nothing is being pitch-shifted.
//
// A `tone` with no `seconds` plays until something stops it, so
// it has to be DETACHED: a parent cannot wait for a sound with
// no end. The `tween` is the only serial child, so the tween is
// what decides how long a full charge takes.
"chargeUp": {
"children": [
{ "tone": "260 + charge*1100", "wave": "saw",
"volume": "0.08 + charge*0.22", "flow": "detached" },
{ "pattern": "@barrel", "loop": true,
"color": "i/count < charge ? hsv(0.12 - charge*0.12, 1, 1) : 0x060100" },
{ "tween": "charge", "to": 1, "sec": 1.4, "ease": "in" }
]
},
// A tap. `play` names a file in the show folder — in the
// Studio, anything listed in the Sounds panel.
"shot": {
"children": [
{ "play": "blaster.wav", "volume": 0.5, "flow": "detached" },
{ "set": "@barrel", "color": "#ffffff", "sec": 0.04 },
{ "comet": "@barrel", "color": "hsv(0.09 - heat*0.09, 0.9, 1)", "lapSec": 0.2, "tail": 5, "for": 0.2 },
{ "wait": 0.07 }
]
},
// A charged shot. Every parameter is scaled by `charge`, so one
// script covers both a tap and a full-power blast: louder,
// longer, brighter, with a low tail underneath it.
"heavyShot": {
"children": [
{ "play": "blaster.wav", "volume": "0.4 + charge*0.5", "flow": "detached" },
{ "tone": 150, "wave": "saw", "seconds": "0.2 + charge*0.3",
"volume": "0.10 + charge*0.25", "flow": "detached" },
{ "set": "@barrel", "color": "#ffffff", "sec": "0.05 + charge*0.10" },
{ "comet": "@barrel", "color": "hsv(0.12 - charge*0.12, 0.9, 1)",
"lapSec": 0.18, "tail": 6, "for": "0.2 + charge*0.3" },
{ "set": "charge", "to": 0 },
{ "wait": 0.06 }
]
},
"vent": {
"children": [
{ "play": "powerdown.wav", "volume": 0.6, "flow": "detached" },
{ "ramp": "heat", "to": 0, "rate": 0.5, "flow": "detached" },
{ "blink": "@barrel", "color": "#4d8cff", "off": "#02060f", "sec": 0.3, "for": 2.2 }
]
},
// Nothing left to fire: a dry click, and no light at all.
"dryFire": {
"children": [
{ "play": "click.wav", "volume": 0.5, "flow": "detached" },
{ "wait": 0.12 }
]
},
"reloadSeq": {
"children": [
{ "play": "powerup.wav", "volume": 0.6, "flow": "detached" },
{ "chase": "@barrel", "color": "#00e0a0", "off": "#001a12", "sec": 0.06, "for": 0.7 },
{ "set": "ammo", "to": 12 }
]
}
},
"rules": [
{ "when": "heat >= 1", "to": "Overheated" }
],
"states": {
"initial": "Ready",
"Ready": {
"enter": [ { "start": "idle" } ],
"exit": [ { "stop": "idle" } ],
"on": {
"trigger.tapped": { "to": "Firing", "when": "heat < 1 && ammo > 0" },
"trigger.held": { "to": "Charging", "when": "heat < 1 && ammo > 0" },
"trigger.pressed": { "to": "Dry", "when": "ammo <= 0" },
"reload.tapped": { "to": "Reloading", "when": "ammo < 12" }
}
},
// Held. Charging ends the moment the trigger comes back up,
// whether it reached full power or not.
"Charging": {
"enter": [ { "start": "chargeUp" } ],
"exit": [ { "stop": "chargeUp" } ],
"on": { "trigger.released": "HeavyFiring" }
},
"Firing": {
"enter": [
{ "set": "heat", "to": "min(heat + 0.17, 1)" },
{ "set": "ammo", "to": "max(ammo - 1, 0)" },
{ "run": "shot" }
],
"on": { "shot.done": "Ready" }
},
"HeavyFiring": {
"enter": [
{ "set": "heat", "to": "min(heat + 0.17 + charge*0.3, 1)" },
{ "set": "ammo", "to": "max(ammo - 1 - floor(charge*2), 0)" },
{ "run": "heavyShot" }
],
"on": { "heavyShot.done": "Ready" }
},
"Dry": { "enter": [ { "run": "dryFire" } ], "on": { "dryFire.done": "Ready" } },
"Reloading": { "enter": [ { "run": "reloadSeq" } ], "on": { "reloadSeq.done": "Ready" } },
"Overheated": {
"enter": [ { "set": "venting", "to": 1 }, { "run": "vent" } ],
"exit": [ { "set": "venting", "to": 0 } ],
"on": { "vent.done": "Ready" }
}
}
}
Next: 8 · The blaster — segments, a boot sequence, and the finished prop.