Some test games, microphone input support, and the start of some rolling demos.

This commit is contained in:
Scott Duensing 2026-10-05 13:41:01 -05:00
parent 59741f25e8
commit 6f5792eede
131 changed files with 12161 additions and 74 deletions

View file

@ -9,6 +9,13 @@ SINGE 3.00
API Changes
-----------
- onOverlayUpdate is due 15 ms after the previous call began, not 15 ms after
it returned. A script that took 5 ms a frame used to run every 20 ms, and
one whose work varied from frame to frame ran at an uneven pace; now the
script's own time falls inside the 15 ms, and a script that takes less than
that runs on a steady 66 times a second. After a stall it is due a frame
from then, so it never runs several times in a row to catch up.
- controls.cfg, settings.cfg and gamecontrollerdb.txt are read game first.
The four places are the same, but the file beside the game's script --
what its author shipped -- is now read first, then the machine's own in
@ -1114,6 +1121,65 @@ API Changes
New Features
------------
- A microphone, for singing games and anything that listens. micOpen starts
listening to the machine's default microphone or one named by micList;
micPitch says which note is being sung, as its frequency and how clearly it
is one note; micLevel says how loud; micRead hands over the samples
themselves; micRecordStart writes a WAV file; and micMonitor plays the
singer back through the speakers, off until asked for. micCalibrate
measures how far behind the microphone runs, with clicks played and timed
coming back, and the answer is kept for the machine beside the audio
calibration, for a game that judges a note against the beat.
- The pieces a voice chat is built from, without the network: Opus encoding
and decoding (opusEncoderNew, opusEncode, opusDecode, with a lost packet
covered over), a voice processor that takes the echo of the speakers out of
the microphone, suppresses noise, evens the level, and says when somebody
is talking (voiceProcessorNew, voiceProcess), and a jitter buffer that plays
packets arriving late and out of order in the right order (jitterNew,
jitterPut, jitterGet). SpeexDSP 1.2.1 is new to Singe; Opus is the copy
SDL_mixer already carried.
- A SQLite database can ship inside a packed game. sqlite3.open finds a
name the way io.open does: opened read only, a database inside the .game
is read where it lies, with nothing unpacked; opened for writing, it is
copied once to the game's data directory, under files/ with everything else
a packed game writes, and the copy is opened then and from then on. The
.game is never written. Before, sqlite3.open took only a path on disk, so a
database packed into a game would not open at all, though the same game run
loose worked.
- musicGetPosition(id) says where the listener is in a playing track, in
seconds: the mixer's own position, less the device queue, the machine's
audio calibration and the game's delay, so a picture timed to it lands with
the sound however long the song plays. It moves smoothly between the
mixer's buffers. Animating to the music no longer means counting ticks from
musicPlay and hoping they keep pace.
- Sprites can be added, multiplied, faded and tinted. spriteSetBlend chooses
SPRITE_ALPHA, SPRITE_ADD or SPRITE_MULTIPLY; spriteSetAlpha fades a sprite
from 255 to nothing; spriteSetTint multiplies its colour, so one white
picture draws in every colour. All three hold for every draw of the sprite,
turned, stretched, cut out or framed, and cost nothing a draw.
- Three demos in demos/: Neon Coast, Mirrorball and Insert Coin, each set to
a song and timed to it bar by bar, beat by beat and drum hit by drum hit.
Between them they use the 3D scene with chrome, glass, bloom and fog,
physics, cloth and particles, video scrubbed by the music and played on
surfaces in 3D, and sprites turned, scaled, stretched a row at a time,
tinted and added. The tools that made them are in util/: demoSync.py
reads a song's beats, bars and drums, demoVideo.py precalculates the video
effects, demoCapture.sh renders a demo to a video, and an art script per
demo draws every picture.
- spriteDrawGrid can stretch the rectangle it cuts out: given x2 and y2 after
the rectangle, it fills that box the way spriteDraw's stretched form does.
Cutting one-pixel rows out of a strip of road and stretching each to the
road's width at its row of the screen is how the arcade road-racing games
drew their roads, curves and hills included, and it costs one call a row.
It also draws the part of a sprite left showing above a hill's crest.
spriteScale could not do either, since it rebuilds the whole picture.
- An OpenGL ES 3.1 backend for the 3D scene and the GUI. SDL_GPU has
Vulkan, Direct3D 12 and Metal and no OpenGL of any kind, which left the
cheap Linux handhelds and the Raspberry Pi with 2D only: their Mali and

View file

@ -405,6 +405,10 @@ set(SINGE_SOURCE
src/midi.h
src/midiIo.c
src/midiIo.h
src/mic.c
src/mic.h
src/voice.c
src/voice.h
src/netTrust.c
src/netTrust.h
thirdparty/tinysoundfont/tsf.h
@ -439,6 +443,22 @@ set(SQLITE_SOURCE
)
set_source_files_properties(thirdparty/lsqlite3/lsqlite3.c PROPERTIES COMPILE_DEFINITIONS "LSQLITE_VERSION=\"${LSQLITE_VERSION}\"")
# SpeexDSP 1.2.1 (thirdparty/speexdsp): echo cancellation, noise suppression, gain, voice activity,
# and a jitter buffer, for voice.c. It has no CMake build, so its library sources are compiled here,
# floating point, with its own small FFT. speexdsp_config_types.h is written by hand (see its top).
set(SPEEXDSP_SOURCE
thirdparty/speexdsp/libspeexdsp/buffer.c
thirdparty/speexdsp/libspeexdsp/fftwrap.c
thirdparty/speexdsp/libspeexdsp/filterbank.c
thirdparty/speexdsp/libspeexdsp/jitter.c
thirdparty/speexdsp/libspeexdsp/mdf.c
thirdparty/speexdsp/libspeexdsp/preprocess.c
thirdparty/speexdsp/libspeexdsp/resample.c
thirdparty/speexdsp/libspeexdsp/scal.c
thirdparty/speexdsp/libspeexdsp/smallft.c
)
set_source_files_properties(${SPEEXDSP_SOURCE} PROPERTIES COMPILE_DEFINITIONS "FLOATING_POINT;USE_SMALLFT;EXPORT=" COMPILE_OPTIONS "-w")
set(ARG_PARSER_SOURCE
thirdparty/arg_parser/carg_parser.c
thirdparty/arg_parser/carg_parser.h
@ -603,6 +623,7 @@ add_executable(${CMAKE_PROJECT_NAME}
${LUA_RS232_SOURCE}
${UTHASH_SOURCE}
${MANYMOUSE_SOURCE}
${SPEEXDSP_SOURCE}
)
if(WIN32)
if(SINGE_ZIG_TARGET)
@ -677,6 +698,10 @@ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
thirdparty/luasec/src
thirdparty/librs232/include
thirdparty/basis_universal/transcoder
# Opus is the one SDL3_mixer builds for Ogg Opus music (libopus.a in STATIC_LIBS), so the voice
# codec (voice.c) and the music decoder are one copy of one version.
thirdparty/SDL3_mixer/external/opus/include
thirdparty/speexdsp/include
)
target_link_directories(${CMAKE_PROJECT_NAME} PRIVATE

View file

@ -40,7 +40,7 @@ lume 2.3.0 MIT https://github.com/rxi/lume
manymouse 0.0.3 Zlib https://icculus.org/manymouse
middleclass 4.1.1 MIT https://github.com/kikito/middleclass
openssl 3.5.8 Apache-2.0 https://www.openssl.org
opus 1.4 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer)
opus 1.4 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer; also Singe's voice codec)
opusfile 0.12 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer)
plutosvg 0.0.8 MIT https://github.com/sammycage/plutosvg (bundled with SDL3_ttf)
plutovg 1.3.3 MIT https://github.com/sammycage/plutovg (bundled with SDL3_ttf)
@ -52,6 +52,7 @@ SDL3_mixer 3.2.4 Zlib https://www.libsdl.org
SDL3_ttf 3.2.2 Zlib https://www.libsdl.org
SDL_shadercross main 2026-09 Zlib https://github.com/libsdl-org/SDL_shadercross (host tool only)
skcms 0.7.3-SDL BSD-3-Clause https://skia.googlesource.com/skcms (bundled with libjxl)
speexdsp 1.2.1 BSD-3-Clause https://www.speex.org
SPIRV-Cross main 2026-09 Apache-2.0 https://github.com/KhronosGroup/SPIRV-Cross (host tool only)
sqlite 3.53.4 Public-Domain https://sqlite.org
timerwheel.lua 1.0.2 MIT https://tieske.github.io/timerwheel.lua

View file

@ -528,8 +528,8 @@ if SINGE_LEGACY_SPRITE_ARGS and spriteDraw ~= nil and not SINGE_LEGACY_SPRITE_WR
-- spriteDrawGrid is the one Hypseus writes with the handle in the middle,
-- (x, y, spr, rectX, rectY, width, height), not on the end.
local newSpriteDrawGrid = spriteDrawGrid
spriteDrawGrid = function(x, y, id, sourceX, sourceY, width, height)
return newSpriteDrawGrid(id, x, y, sourceX, sourceY, width, height)
spriteDrawGrid = function(x, y, id, ...)
return newSpriteDrawGrid(id, x, y, ...)
end
end

View file

@ -85,7 +85,7 @@ cat > "$stage/README.txt" <<EOF
Singe $version documentation
Reference.pdf / .html Everything a game can do: the programming model and every function.
Learn.pdf / .html Learn to Program with Singe: thirty lessons from nothing to a finished game.
Learn.pdf / .html Learn to Program with Singe: thirty-one lessons from nothing to a finished game.
Forge.pdf / .html Forge, the authoring tool: ten tutorials and the vocabulary.
learn/ The finished script of every lesson, and the art kit the lessons use.
COPYING The licence, GPL version 3.

114
demos/README.md Normal file
View file

@ -0,0 +1,114 @@
# Singe demos
Three demos in the old demoscene way: a song each, and everything on screen timed to it. Every
one runs live, nothing on screen is a recording of the demo itself, and every motion takes its time
from the music's clock (`musicGetPosition`), so a demo stays with its song on any machine at any
frame rate.
| Demo | Song | Length |
|---|---|---|
| Neon Coast | "Neons in Napoli" by eliza_music | 3:08 |
| Mirrorball | "Vintage Funky Groove" by miiira | 4:05 |
| Insert Coin | "Game Is On" by poorartistt | 4:10 |
Each folder has its own `games.dat`, so Singe runs a demo like any game: from this folder,
`Singe neonCoast/neonCoast.singe`. They need the 3D scene. `kit.lua` is the code all three share:
the music clock, beats and bars, the envelopes and drum hits, the scene runner, and the effects
more than one demo uses (vector bobs, the sine scroller, flashes and strobes, chrome and glass
materials, a camera-filling quad, a torus knot).
## How the timing works
`util/demoSync.py` listens to a song once, offline, and writes `sync.lua` beside the demo:
- **Beats.** A song on a fixed tempo gets a grid, found by folding the onsets of the whole track at
every candidate period and keeping the sharpest; Neon Coast's is 122.000 beats a minute and
Insert Coin's 135.000, with the tracked beats within 4 ms of them. Mirrorball's song speeds up
from about 111 to 117, so it gets the tracker's beats, each moved to the strongest onset near it
and smoothed through its neighbours, and carried on past where the tracker gave up.
- **Bars.** Beat one is the beat where the loudness and the bass jump most often, since songs
change section on a downbeat.
- **Envelopes.** Kick, snare, and hat (the change in each drum's frequency band), bass, loudness,
and brightness, sixty samples a second, each 0 to 1, packed one character a sample.
- **Hits.** The times of the kicks, snares, and hats, for effects that fire once per hit.
A scene in a demo says which bars it spans, so cuts land on downbeats, and inside it effects read
the envelopes and hits at the current time: a flash that decays from every kick, a formation that
steps on every beat, a tunnel whose speed the kick pushes.
## The songs, and what happens in them
**Neon Coast** (bar n starts at 0.1008 + (n - 1) x 1.96721 s)
| Bars | Song | Demo |
|---|---|---|
| 1-3 | pads only | a tape starts: snow, the deck's lettering, the title typed out |
| 4-16 | bass in | the sunset grid; the chrome logotype rises at 9, the title slams in at 13 |
| 17-18 | bass out, snare build | the logotype turns, the fog closes in, strobes on the snare roll |
| 19-28 | full groove | the night drive: a streetlight passes the car on every beat |
| 29-30 | bass out | the car takes a crest and flies, in slow motion |
| 31-46 | main section | the chrome knot and glass, a cut every two bars; plasma billboards from 39 |
| 47-48 | filtered drop | the tape rewinds |
| 49-58 | busy hats | the tunnel, scrubbed by the kick; vector bobs and the scroller |
| 59-60 | bass out | the tunnel stops, the bobs fall in, ARE YOU READY |
| 61-68 | loud | plasma, a rotozoomed logo, a ring of palms |
| 69-76 | breakdown, then the kick | a flag on the night grid, blown by the kicks once they return |
| 77-78 | riser, dead stop | into hyperspace, white, and black for the stop |
| 79-94 | finale | the road and the chrome, cut every two bars, fireworks on the snares |
| 95- | ending | the set switches off; credits |
**Mirrorball** (eight-bar phrases from bar 1 at 0.79 s; the bars shorten as the song speeds up)
| Bars | Song | Demo |
|---|---|---|
| 1-8 | choppy drums and bass | copper bars gated by the groove, the title dropping a letter at a time |
| 9-16 | chords | the club: the ball comes down, spots sweep, the floor swaps on every beat |
| 17-24 | lead | a rotozoomer, copper bars coloured by the note being played, the scroller |
| 25-32 | full | a mirrored kaleidoscope of plasma under vector bobs |
| 33-40 | choppy again | the club, cut on every beat, black in the gaps |
| 41-48 | denser hats | the twister, drawn a scanline at a time, over copper bars |
| 49-56 | horns | a checkered ball bouncing on the beat; a ball drops on every snare and piles up |
| 57-64 | | glenz glass in a tunnel scrubbed by the kick |
| 65-72 | dense | lasers in the haze |
| 73-80 | | plasma and a rotozoomed logo |
| 81-88 | breakdown | the dark club; the floor relights a tile a beat when the bass returns |
| 89-96 | build | everything back on, strobing |
| 97-112 | full, then final | the party, then everything at once |
| 113- | the last downbeat | the ball shatters; credits |
**Insert Coin** (bar n starts at 0.0067 + (n - 1) x 1.77778 s)
| Bars | Song | Demo |
|---|---|---|
| 1-4 | intro | a tube warms up; INSERT COIN, and a coin drops on the downbeat of 5 |
| 5-8 | drums in | 1-bit: bat and ball in phosphor, the ball meeting a bat on every beat |
| 9-16 | groove | 8-bit: a formation that steps on the beat; the cannon fires on the snare |
| 17-24 | lead melody | 16-bit: a runner over parallax hills, jumping on the backbeat |
| 25-32 | lead melody | 16-bit: a kart round a track seen in perspective |
| 33-40 | breakdown, riser | a tape loading |
| 41-56 | full | 32-bit: a flat-shaded ship through rings, one a beat, snapped to a grid |
| 57-72 | heavy | now: chrome balls into a wall of glossy bricks, real physics, on the kick |
| 73-80 | breakdown | GAME OVER, and a countdown to continue, a number a bar |
| 81-96 | melody returns | the boss, firing on the kick, the hats, and the snare |
| 97-112 | full | the bonus stage: coins pour in on the beat |
| 113-120 | melody | the high score table, which is the credits |
| 121-136 | final | every era again, two bars each, then GAME ON |
| 137- | outro | GAME OVER, and the set switched off on the last hit |
## Making them again
Everything in `art/`, `video/`, and `sync.lua` is generated:
- `util/neonCoastArt.py`, `util/mirrorballArt.py`, `util/insertCoinArt.py` draw each demo's
pictures (PIL and numpy).
- `util/demoVideo.py` precalculates the per-pixel effects as videos: the tunnel (every frame a
keyframe, so a demo can seek to any frame and run the tunnel at the music's speed), the plasma,
and the tape's snow.
- `util/demoSync.py` writes `sync.lua` from `music.mp3`. It needs librosa, which Singe does not:
`python3 -m venv env && env/bin/pip install librosa`.
`util/demoCapture.sh neonCoast 0 190 [every] [out.mp4]` runs a stretch of a demo headless on a
clock that moves exactly one 20 ms step a frame, saves the frames, and with `every` = 1 puts the
same stretch of the music under them in an MP4. The engine plays the music on real time, so a
capture is silent and the song is added afterwards; a demo run normally plays it itself. Set
`DEMO_STATS = true` before loading a demo to have it print its frame rate every second.

BIN
demos/insertCoin/art/bossArm.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/bossCore.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/bullet.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/cannon.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/coin.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/duskSky.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/font.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/ground.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/hills.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/invaderA.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/invaderB.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/kart.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/mountains.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/raceSky.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/runner.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/scanlines.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/ship.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/track.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/turret.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,12 @@
-- Insert Coin: a demo for Singe, to "Game Is On" by poorartistt.
GAMES = {
{
TITLE = "Insert Coin",
SCRIPT = "insertCoin/insertCoin.singe",
CANVAS_X = 640,
CANVAS_Y = 480,
RESOLUTION_X = 1024,
RESOLUTION_Y = 768,
REQUIRES = { "SCENE", "POST", "SHADOWS" }
}
}

File diff suppressed because it is too large Load diff

BIN
demos/insertCoin/music.mp3 (Stored with Git LFS) Normal file

Binary file not shown.

337
demos/insertCoin/sync.lua Normal file

File diff suppressed because one or more lines are too long

BIN
demos/insertCoin/video/plasma.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/video/tunnel.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

657
demos/kit.lua Normal file
View file

@ -0,0 +1,657 @@
-- The demo kit: what the three demos share. A demo loads it with dofile(DIR .. "../kit.lua") and
-- gets back one table.
--
-- Time is the music's. K.time() is where the listener is in the track: musicGetPosition while the
-- demo plays, or a clock that moves exactly one frame a frame when DEMO_CAPTURE asks for a capture
-- (see util/demoCapture.sh), so a captured frame lands on the music added afterwards. Everything
-- else is read off that time: beats and bars from the beat list util/demoSync.py wrote, the drums,
-- the bass, the loudness and the brightness as envelopes from 0 to 1, and the drum hits as lists.
--
-- A demo is a list of scenes, each with the bars it spans: { from = 17, to = 33, enter = fn, draw =
-- fn, leave = fn }. K.run calls enter once as the scene starts, draw every frame it is on with the
-- time, the bar, and how far through it is from 0 to 1, and leave once as it ends. Scenes may
-- overlap, and are drawn in list order.
local K = {}
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local ENVELOPES = { "kick", "snare", "hat", "bass", "level", "bright", "noteClear" }
local HIT_LISTS = { "kicks", "snares", "hats" }
local LEVELS = 63
local PIXEL_PNG = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0b\x49\x44\x41\x54\x78\x9c\x63\xf8\x0f\x04\x00\x09\xfb\x03\xfd\xfb\x5e\x6b\x2b\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"
local FONT_FILE = "Singe/FreeSansBold.ttf"
local TAIL = 2 -- Seconds the demo runs on after the music ends
local sync = nil
local music = nil
local fonts = {}
local widths = {}
local pixel = nil
local startTicks = nil
local lastTime = 0
local lastTicks = 0
local frames = 0
local stats = { second = 0, frames = 0, worst = 0, last = 0 }
K.W = 640
K.H = 480
-- The fractional bar at time t, counting from 1: bar 1 starts on the beat the sync data names.
function K.bar(t)
return (K.beat(t) - (sync.barStart - 1)) / 4 + 1
end
-- When bar b starts; fractional bars land between beats.
function K.barTime(b)
return K.beatTime((b - 1) * 4 + (sync.barStart - 1))
end
-- The fractional beat at time t, counting the first beat of the list as 0. Before the first beat
-- and after the last the spacing of the nearest two carries on.
function K.beat(t)
local beats = sync.beats
local n = #beats
local lo = 1
local hi = n
if t <= beats[1] then
return (t - beats[1]) / (beats[2] - beats[1])
end
if t >= beats[n] then
return n - 1 + (t - beats[n]) / (beats[n] - beats[n - 1])
end
while hi - lo > 1 do
local mid = (lo + hi) // 2
if beats[mid] <= t then
lo = mid
else
hi = mid
end
end
return lo - 1 + (t - beats[lo]) / (beats[hi] - beats[lo])
end
-- When fractional beat i falls.
function K.beatTime(i)
local beats = sync.beats
local n = #beats
local whole = math.floor(i)
local part = i - whole
if whole < 0 then
return beats[1] + i * (beats[2] - beats[1])
end
if whole >= n - 1 then
return beats[n] + (i - (n - 1)) * (beats[n] - beats[n - 1])
end
return beats[whole + 1] + part * (beats[whole + 2] - beats[whole + 1])
end
-- Vector bobs: count balls on a shape that changes every two bars (a ring, a helix, a sphere, a
-- flower), turned in 3D and drawn back to front at the size their distance gives them, each tinted
-- round the colour wheel. sprite is a white shaded ball; spread scales the shape; size is a ball's
-- radius in pixels at the shape's middle distance.
local BOB_DEPTH = 5
local function bobShape(kind, i, count)
local k = (i - 1) / count
local a = k * 2 * math.pi
if kind == 0 then
return math.cos(a) * 1.6, math.sin(a * 3) * 0.3, math.sin(a) * 1.6
elseif kind == 1 then
return math.cos(a * 3) * 1.0, (k - 0.5) * 3.2, math.sin(a * 3) * 1.0
elseif kind == 2 then
local phi = math.acos(1 - 2 * (i - 0.5) / count)
local theta = math.pi * (1 + math.sqrt(5)) * i
return math.cos(theta) * math.sin(phi) * 1.5, math.cos(phi) * 1.5, math.sin(theta) * math.sin(phi) * 1.5
end
local r = 1 + 0.5 * math.sin(a * 5)
return math.cos(a) * r * 1.4, math.sin(a) * r * 1.4, math.sin(a * 5) * 0.4
end
function K.bobs(t, bar, sprite, count, spread, size, cx, cy)
local shape = math.floor((bar - 1) / 2) % 4
local next = (shape + 1) % 4
local morph = K.smooth(0.75, 1.0, ((bar - 1) / 2) % 1)
local ay = t * 0.9
local ax = t * 0.5
local pts = {}
local pump = 1 + 0.25 * K.decay("kicks", t, 8)
for i = 1, count do
local x1, y1, z1 = bobShape(shape, i, count)
local x2, y2, z2 = bobShape(next, i, count)
local x = K.lerp(x1, x2, morph) * spread * pump
local y = K.lerp(y1, y2, morph) * spread * pump
local z = K.lerp(z1, z2, morph) * spread * pump
local x3 = x * math.cos(ay) + z * math.sin(ay)
local z3 = -x * math.sin(ay) + z * math.cos(ay)
local y3 = y * math.cos(ax) - z3 * math.sin(ax)
local z4 = y * math.sin(ax) + z3 * math.cos(ax)
pts[i] = { x = x3, y = y3, z = z4 + BOB_DEPTH, i = i }
end
table.sort(pts, function(a, b) return a.z > b.z end)
for _, p in ipairs(pts) do
local s = BOB_DEPTH / p.z
local r = size * s
local px = cx + p.x * s * 60
local py = cy + p.y * s * 60
local cr, cg, cb = K.hue(p.i / count + t * 0.1)
spriteSetTint(sprite, cr, cg, cb)
spriteDraw(sprite, math.floor(px - r), math.floor(py - r), math.floor(px + r), math.floor(py + r))
end
spriteSetTint(sprite, 255, 255, 255)
end
-- A filled rectangle, corners inclusive, in any colour and alpha: the kit's one-pixel sprite
-- stretched and tinted. blend is a SPRITE_ constant, alpha by default.
function K.box(x1, y1, x2, y2, r, g, b, a, blend)
if x2 < x1 or y2 < y1 then
return
end
spriteSetTint(pixel, r, g, b)
spriteSetAlpha(pixel, a or 255)
spriteSetBlend(pixel, blend or SPRITE_ALPHA)
spriteDraw(pixel, math.floor(x1), math.floor(y1), math.floor(x2), math.floor(y2))
end
-- A chrome material: metal, polished to roughness, tinted r, g, b. It needs a sky to reflect.
function K.chrome(r, g, b, roughness)
local m = materialNew()
materialSetColor(m, r, g, b)
materialSetMetallic(m, 1)
materialSetRoughness(m, roughness)
return m
end
function K.clamp(v, lo, hi)
if v < lo then
return lo
end
if v > hi then
return hi
end
return v
end
-- How long ago the most recent hit in a list ("kicks", "snares", "hats") was at time t; huge when
-- there has been none. An effect fed exp(-age * k) flashes on every hit and dies away.
function K.decay(list, t, k)
return math.exp(-K.hitAge(list, t) * k)
end
function K.ease(t)
t = K.clamp(t, 0, 1)
return t * t * (3 - 2 * t)
end
function K.easeIn(t)
t = K.clamp(t, 0, 1)
return t * t * t
end
function K.easeInOut(a, b, t)
return a + (b - a) * (0.5 - math.cos(K.clamp(t, 0, 1) * math.pi) / 2)
end
function K.easeOut(t)
t = 1 - K.clamp(t, 0, 1)
return 1 - t * t * t
end
-- An envelope's value at time t, 0 to 1: "kick", "snare", "hat", "bass", "level", "bright", or
-- "noteClear".
function K.env(name, t)
local curve = sync[name]
local f = t * sync.rate
local i = math.floor(f)
if i < 0 then
return curve[1]
end
if i >= #curve - 1 then
return curve[#curve]
end
return curve[i + 1] + (f - i) * (curve[i + 2] - curve[i + 1])
end
-- The whole screen in one colour: a flash, a fade, or a tint.
function K.flash(r, g, b, a, blend)
K.box(0, 0, K.W - 1, K.H - 1, r, g, b, a, blend)
end
-- Coloured glass: see-through by alpha, polished, drawn after everything solid.
function K.glass(r, g, b, alpha)
local m = materialNew()
materialSetColor(m, r, g, b, alpha or 90)
materialSetBlend(m, true)
materialSetRoughness(m, 0.05)
return m
end
-- Seconds since the most recent hit in a list at time t.
function K.hitAge(list, t)
local hits = sync[list]
local lo = 0
local hi = #hits + 1
while hi - lo > 1 do
local mid = (lo + hi) // 2
if hits[mid] <= t then
lo = mid
else
hi = mid
end
end
if lo == 0 then
return math.huge
end
return t - hits[lo]
end
-- How many hits in a list fall after t0 and up to t1: what a frame's worth of time held.
function K.hitCount(list, t0, t1)
local count = 0
for _, h in ipairs(sync[list]) do
if h > t1 then
break
end
if h > t0 then
count = count + 1
end
end
return count
end
-- A full-bright colour from a hue, 0 to 1 round the wheel.
function K.hue(h)
local f = (h % 1) * 6
local i = math.floor(f)
local p = f - i
local c = ({ { 1, p, 0 }, { 1 - p, 1, 0 }, { 0, 1, p }, { 0, 1 - p, 1 }, { p, 0, 1 }, { 1, 0, 1 - p } })[i + 1]
return math.floor(c[1] * 255), math.floor(c[2] * 255), math.floor(c[3] * 255)
end
function K.lerp(a, b, t)
return a + (b - a) * t
end
-- Reads the demo's sync data and its music, makes the overlay, and loads the kit's own pixel.
-- Envelopes become arrays of numbers from 0 to 1 here, once.
function K.load(dir, width, height)
local value = {}
K.W = width or K.W
K.H = height or K.H
overlaySetResolution(K.W, K.H)
for i = 1, #ALPHABET do
value[ALPHABET:byte(i)] = (i - 1) / LEVELS
end
sync = dofile(dir .. "sync.lua")
for _, name in ipairs(ENVELOPES) do
local text = sync[name]
local curve = {}
for i = 1, #text do
curve[i] = value[text:byte(i)]
end
sync[name] = curve
end
local notes = {}
for i = 1, #sync.note do
notes[i] = math.floor(value[sync.note:byte(i)] * LEVELS + 0.5)
end
sync.note = notes
for _, name in ipairs(HIT_LISTS) do
sync[name] = sync[name] or {}
end
K.duration = sync.duration
if not DEMO_CAPTURE then
music = musicLoad(dir .. "music.mp3")
end
pixel = spriteLoadData(PIXEL_PNG)
return sync
end
-- The strongest pitch class at time t (0 is C) and how clearly it stands out, 0 to 1.
function K.note(t)
local i = K.clamp(math.floor(t * sync.rate) + 1, 1, #sync.note)
return sync.note[i], sync.noteClear[i]
end
-- Every mesh node under a model instance gets the material.
function K.paint(node, material)
nodeSetMaterial(node, material)
for _, child in ipairs(nodeGetChildren(node)) do
K.paint(child, material)
end
end
-- Starts the music and the clock, and takes over onOverlayUpdate to run the scenes. Under
-- DEMO_CAPTURE = { from = seconds, to = seconds, every = frames } nothing plays: the clock starts
-- at from and moves by the frame (run Singe with --deterministic), every frame (or every "every"th)
-- is saved as frameNNNNNN.png, and the demo quits at to.
function K.run(scenes)
local active = {}
if DEMO_CAPTURE then
debugPrint(string.format("DEMO capture from %.3f to %.3f", DEMO_CAPTURE.from, DEMO_CAPTURE.to))
else
musicPlay(music)
end
function onOverlayUpdate()
local t = K.time()
local bar = K.bar(t)
frames = frames + 1
if DEMO_STATS then
K.stats(t, bar)
end
colorBackground(0, 0, 0, 0)
overlayClear()
for i, scene in ipairs(scenes) do
local on = bar >= scene.from and bar < scene.to
if on and not active[i] then
active[i] = true
if scene.enter then
scene.enter(t)
end
elseif not on and active[i] then
active[i] = false
if scene.leave then
scene.leave(t)
end
end
if on and scene.draw then
scene.draw(t, bar, (bar - scene.from) / (scene.to - scene.from))
end
end
if DEMO_CAPTURE then
if frames % (DEMO_CAPTURE.every or 1) == 0 then
singeScreenshot(string.format("frame%06d", frames))
end
if t >= DEMO_CAPTURE.to then
debugPrint(string.format("DEMO captured %d frames", frames))
singeQuit()
end
elseif t >= sync.duration + TAIL then
singeQuit()
end
return OVERLAY_UPDATED
end
end
-- Under DEMO_STATS, once a second: frames drawn in the last second, and the longest gap between two.
function K.stats(t, bar)
local now = singeGetTicks()
if stats.last > 0 then
stats.worst = math.max(stats.worst, now - stats.last)
end
stats.last = now
stats.frames = stats.frames + 1
if now - stats.second >= 1000 then
debugPrint(string.format("DEMO t %6.2f bar %6.2f %3d fps worst %3d ms", t, bar, stats.frames, stats.worst))
stats.second = now
stats.frames = 0
stats.worst = 0
end
end
-- A flat quad that exactly fills the camera's view at distance d (oversize makes it larger, to stay
-- covering while it turns): for a video over, or as, the whole picture in 3D, where it can turn and
-- glow. What comes back is a pivot on the camera's axis with the quad stood up under it, so the
-- pivot's roll, nodeSetRotation(q, 0, 0, angle), turns the quad in its own plane. It starts hidden.
function K.screenQuad(camera, fov, material, d, oversize)
local h = 2 * d * math.tan(math.rad(fov / 2)) * (oversize or 1)
local pivot = nodeNew(camera)
local quad = nodeNew(pivot)
nodeSetPosition(pivot, 0, 0, -d)
nodeSetMesh(quad, meshPlane(h * K.W / K.H, h), material)
nodeSetRotation(quad, 90, 0, 0)
nodeSetVisible(pivot, false)
return pivot
end
-- A sine scroller: each letter a sprite made once in the font, coloured by where it is on the
-- screen, riding a wave of amplitude amp. It enters from the right at t0 and runs at speed pixels
-- a second.
local glyphs = {}
function K.scroller(t, font, text, y, amp, speed, t0)
local x = K.W - (t - t0) * speed
for i = 1, #text do
local key = font .. text:sub(i, i)
local g = glyphs[key]
if not g then
fontSelect(font)
colorForeground(255, 255, 255, 255)
fontQuality(FONT_QUALITY_BLENDED)
local s = fontToSprite(text:sub(i, i))
g = { sprite = s, w = spriteGetWidth(s) }
glyphs[key] = g
end
if x > K.W then
break
end
if x + g.w > 0 then
local r, gg, b = K.hue(x / K.W * 0.5 + t * 0.2)
spriteSetTint(g.sprite, r, gg, b)
spriteDraw(g.sprite, math.floor(x), math.floor(y + math.sin(x * 0.018 + t * 4) * amp))
end
x = x + g.w
end
end
function K.smooth(edge0, edge1, x)
return K.ease((x - edge0) / (edge1 - edge0))
end
-- A full-screen flash that fires on every hit in a list and dies away at rate k: a = 0 is off.
function K.strobe(t, list, k, r, g, b, a)
local v = K.decay(list, t, k)
if v > 0.02 and (a or 255) > 0 then
K.flash(r, g, b, math.floor((a or 255) * v))
end
end
-- How wide text is in the bundled font at a point size, in pixels; measured once and remembered.
function K.textWidth(text, size)
local key = size .. ":" .. text
local w = widths[key]
if not w then
local font = fonts[size]
if not font then
font = fontLoad(FONT_FILE, size)
fonts[size] = font
end
fontSelect(font)
local sprite = fontToSprite(text)
w = spriteGetWidth(sprite)
widths[key] = w
spriteUnload(sprite)
end
return w
end
-- Text in the bundled font at a point size, in a colour, left, centred ("center"), or right
-- aligned on x.
function K.text(x, y, text, size, r, g, b, align)
local font = fonts[size]
if not font then
font = fontLoad(FONT_FILE, size)
fonts[size] = font
end
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
colorForeground(r, g, b, 255)
if align then
local w = K.textWidth(text, size)
fontSelect(font)
if align == "center" then
x = x - w // 2
else
x = x - w
end
end
fontPrint(math.floor(x), math.floor(y), text)
end
-- Where the listener is in the music, in seconds. It never runs backwards, which a mixer
-- position read just after a buffer moved can otherwise do by a millisecond, and once the music has
-- ended it runs on with the clock.
function K.time()
local now = singeGetTicks()
local t = 0
startTicks = startTicks or now
if DEMO_CAPTURE then
t = DEMO_CAPTURE.from + (now - startTicks) / 1000
else
t = musicGetPosition(music)
if t < 0 then
t = (lastTime > 0) and (lastTime + (now - lastTicks) / 1000) or 0
end
end
lastTime = math.max(lastTime, t)
lastTicks = now
return lastTime
end
-- A (2, 3) torus knot as a tube, built vertex by vertex for meshNew: the curve, a frame along it, and a ring
-- of vertices round each point.
function K.torusKnot(radius, tube, segments, sides)
local positions = {}
local normals = {}
local uvs = {}
local indices = {}
local function curve(phi)
local r = radius * (1 + 0.42 * math.cos(3 * phi))
return r * math.cos(2 * phi), r * math.sin(2 * phi), radius * 0.42 * math.sin(3 * phi)
end
for i = 0, segments - 1 do
local phi = i / segments * 2 * math.pi
local px, py, pz = curve(phi)
local qx, qy, qz = curve(phi + 0.01)
local tx = qx - px
local ty = qy - py
local tz = qz - pz
local tl = math.sqrt(tx * tx + ty * ty + tz * tz)
tx = tx / tl
ty = ty / tl
tz = tz / tl
-- A normal that never lines up with the tangent: the knot never runs straight up the z axis.
local nx = ty * 1 - tz * 0
local ny = tz * 0 - tx * 1
local nz = tx * 0 - ty * 0
local nl = math.sqrt(nx * nx + ny * ny + nz * nz) + 1e-9
nx = nx / nl
ny = ny / nl
nz = nz / nl
local bx = ty * nz - tz * ny
local by = tz * nx - tx * nz
local bz = tx * ny - ty * nx
for j = 0, sides - 1 do
local a = j / sides * 2 * math.pi
local cx = math.cos(a) * nx + math.sin(a) * bx
local cy = math.cos(a) * ny + math.sin(a) * by
local cz = math.cos(a) * nz + math.sin(a) * bz
positions[#positions + 1] = px + cx * tube
positions[#positions + 1] = py + cy * tube
positions[#positions + 1] = pz + cz * tube
normals[#normals + 1] = cx
normals[#normals + 1] = cy
normals[#normals + 1] = cz
uvs[#uvs + 1] = i / segments
uvs[#uvs + 1] = j / sides
end
end
for i = 0, segments - 1 do
local i2 = (i + 1) % segments
for j = 0, sides - 1 do
local j2 = (j + 1) % sides
local a = i * sides + j + 1
local b = i2 * sides + j + 1
local c = i2 * sides + j2 + 1
local d = i * sides + j2 + 1
indices[#indices + 1] = a
indices[#indices + 1] = b
indices[#indices + 1] = c
indices[#indices + 1] = a
indices[#indices + 1] = c
indices[#indices + 1] = d
end
end
return meshNew(positions, normals, uvs, indices)
end
return K

BIN
demos/mirrorball/art/bob.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/boing.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/club.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/copper.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/glow.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/roto.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/singe.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/sparkle.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/tile.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/art/twister.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,12 @@
-- Mirrorball: a demo for Singe, to "Vintage Funky Groove" by miiira.
GAMES = {
{
TITLE = "Mirrorball",
SCRIPT = "mirrorball/mirrorball.singe",
CANVAS_X = 640,
CANVAS_Y = 480,
RESOLUTION_X = 1024,
RESOLUTION_Y = 768,
REQUIRES = { "SCENE", "POST", "SHADOWS" }
}
}

View file

@ -0,0 +1,949 @@
-- Mirrorball: a demo for Singe, to "Vintage Funky Groove" by miiira.
--
-- A night in a nineties club, in eight-bar phrases as the song is built: a choppy intro, the ball
-- coming down over a lit floor, the oldschool effects the dance parties ran on (copper bars, a
-- rotozoomer, a twister, vector bobs, glenz glass, a bouncing checkered ball), a laser show, a
-- breakdown in the dark, and a finale in which the ball shatters on the last downbeat.
--
-- The song speeds up as it goes, from about 111 to 117 beats a minute, so nothing here assumes a
-- tempo: bars and beats come from the beat list in sync.lua, and everything that lands on a beat
-- lands where the drummer put it.
dofile("Singe/Framework.singe")
local K = dofile(DIR .. "../kit.lua")
local W = 640
local H = 480
local FOV = 60
local TILES = 12 -- The dance floor is TILES by TILES
local TILE = 2 -- World units across a tile
local BALL_Y = 6.2 -- Where the ball hangs
local BALL_R = 1.45
local FACET = 0.2
local SPARKLES = 70 -- Points of light the ball throws on the walls
local LASERS = 9
local RAIN_MAX = 70 -- Balls that fall on the snares in the boing scene
local TWIST_R = 92 -- The twister's half width, in pixels
local TWIST_ROW = 2 -- Screen rows each slice of the twister covers
local DISCO = { { 255, 40, 80 }, { 255, 160, 20 }, { 255, 240, 60 }, { 60, 255, 120 }, { 40, 200, 255 }, { 160, 60, 255 } }
local sync = K.load(DIR, W, H)
local art = {}
local video = {}
local world = {}
local lastT = 0
local SCROLL_TEXT = " MIRRORBALL ... A SINGE DEMO ... THIS GROOVE SPEEDS UP AS IT GOES, AND EVERY FLASH, EVERY CUT AND EVERY BOUNCE FOLLOWS IT, BEAT BY BEAT, BECAUSE THE BEATS WERE READ OUT OF THE MUSIC ... COPPER BARS, A ROTOZOOMER, A TWISTER DRAWN ONE SCANLINE AT A TIME, GLENZ GLASS AND A BOUNCING BALL: THE OLD TRICKS, ON A NEW ENGINE ... GREETINGS TO EVERYONE WHO EVER CARRIED A COMPUTER TO A PARTY ... "
-- Loading --------------------------------------------------------------------------------------------
local function loadArt()
for _, name in ipairs({ "logo", "singe", "twister", "copper", "bob", "glow", "sparkle" }) do
art[name] = spriteLoad(DIR .. "art/" .. name .. ".png")
end
spriteQuality(art.logo, RENDER_SMOOTH)
spriteQuality(art.singe, RENDER_SMOOTH)
art.font = fontLoad("Singe/FreeSansBold.ttf", 44)
video.plasma = videoLoad(DIR .. "video/plasma.mp4")
video.tunnel = videoLoad(DIR .. "video/tunnel.mp4")
video.tunnelFrames = videoGetFrameCount(video.tunnel)
videoSetVolume(video.plasma, 0, 0)
videoSetVolume(video.tunnel, 0, 0)
end
-- The world --------------------------------------------------------------------------------------------
local function buildFloor()
local tileImage = spriteLoad(DIR .. "art/tile.png")
local mesh = meshBox(TILE * 0.96, 0.2, TILE * 0.96)
world.tiles = {}
for i = 1, TILES do
for j = 1, TILES do
local m = materialNew()
local n = nodeNew()
materialSetColor(m, 40, 30, 50)
materialSetEmissiveMap(m, tileImage)
materialSetRoughness(m, 0.2)
materialSetMetallic(m, 0.3)
nodeSetMesh(n, mesh, m)
nodeSetPosition(n, (i - TILES / 2 - 0.5) * TILE, -0.1, (j - TILES / 2 - 0.5) * TILE)
world.tiles[#world.tiles + 1] = { node = n, material = m, i = i, j = j }
end
end
-- One static body under them all for whatever falls, and walls round the edge to keep it on.
world.floorBody = nodeNew()
nodeSetPosition(world.floorBody, 0, -0.2, 0)
bodyNew(world.floorBody, BODY_STATIC, SHAPE_BOX, TILES * TILE, 0.2, TILES * TILE)
local half = TILES * TILE / 2
for _, wall in ipairs({ { half, 0, 0.5, TILES * TILE }, { -half, 0, 0.5, TILES * TILE }, { 0, half, TILES * TILE, 0.5 }, { 0, -half, TILES * TILE, 0.5 } }) do
local n = nodeNew()
nodeSetPosition(n, wall[1], 2, wall[2])
bodyNew(n, BODY_STATIC, SHAPE_BOX, wall[3], 4, wall[4])
end
end
-- The ball: facets in rings of latitude round a dark core, all one mesh and one material so they
-- draw in one call, on a pivot that turns. Each facet faces out from the centre.
local FACET_GROUPS = 4
local function buildBall()
local mesh = meshBox(FACET * 0.92, FACET * 0.92, 0.04)
-- Facets in four groups, each its own material, so each group can catch the light in turn: a
-- real ball's facets flash as they come round to a lamp. Four materials still draw in four calls.
world.facetMaterials = {}
for g = 1, FACET_GROUPS do
world.facetMaterials[g] = K.chrome(200 + g * 10, 205 + g * 8, 225, 0.06 + g * 0.03)
end
world.ball = nodeNew()
world.facets = {}
local rings = math.floor(math.pi * BALL_R / FACET)
for r = 1, rings - 1 do
local lat = -math.pi / 2 + r / rings * math.pi
local count = math.max(4, math.floor(2 * math.pi * BALL_R * math.cos(lat) / FACET))
for c = 0, count - 1 do
local lon = (c + (r % 2) * 0.5) / count * 2 * math.pi
local f = nodeNew(world.ball)
local x = math.cos(lat) * math.cos(lon) * BALL_R
local y = math.sin(lat) * BALL_R
local z = math.cos(lat) * math.sin(lon) * BALL_R
nodeSetMesh(f, mesh, world.facetMaterials[math.random(1, FACET_GROUPS)])
nodeSetPosition(f, x, y, z)
nodeLookAt(f, 0, 0, 0)
world.facets[#world.facets + 1] = { node = f, x = x, y = y, z = z }
end
end
local core = materialNew()
materialSetColor(core, 10, 10, 14)
world.core = nodeNew(world.ball)
nodeSetMesh(world.core, meshSphere(BALL_R - 0.03, 24), core)
local rope = materialNew()
materialSetColor(rope, 30, 30, 34)
world.rope = nodeNew()
nodeSetMesh(world.rope, meshCylinder(0.03, 12, 6), rope)
nodeSetPosition(world.ball, 0, BALL_Y, 0)
-- The light the ball throws: sparkles on a great sphere round it, turning with it.
world.sparkleRing = nodeNew()
nodeSetPosition(world.sparkleRing, 0, BALL_Y, 0)
world.sparkles = {}
for i = 1, SPARKLES do
local phi = math.acos(1 - 2 * (i - 0.5) / SPARKLES) * 0.75
local theta = math.pi * (1 + math.sqrt(5)) * i
local n = nodeNew(world.sparkleRing)
nodeSetSprite(n, art.sparkle, 1.8, false)
nodeSetBillboard(n, BILLBOARD_ALL)
nodeSetPosition(n, math.cos(theta) * math.sin(phi + 0.6) * 16, -math.cos(phi + 0.6) * 9, math.sin(theta) * math.sin(phi + 0.6) * 16)
world.sparkles[i] = n
end
end
local function buildLights()
world.spots = {}
for i = 1, 4 do
local l = lightNew(LIGHT_SPOT)
local c = DISCO[i + 1]
lightSetColor(l, c[1], c[2], c[3])
lightSetIntensity(l, 0)
lightSetRange(l, 60)
lightSetCone(l, 10, 18)
world.spots[i] = l
end
world.ballSpot = lightNew(LIGHT_SPOT)
nodeSetPosition(world.ballSpot, 0, 14, 8)
nodeLookAt(world.ballSpot, 0, BALL_Y, 0)
lightSetColor(world.ballSpot, 255, 250, 240)
lightSetIntensity(world.ballSpot, 0)
lightSetCone(world.ballSpot, 5, 9)
world.sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(world.sun, 4, 10, 6)
nodeLookAt(world.sun, 0, 0, 0)
lightSetIntensity(world.sun, 0)
lightSetShadow(world.sun, true)
end
-- Lasers: thin bright rods fanning out from a point high on the back wall.
local function buildLasers()
local m = materialNew()
materialSetColor(m, 0, 0, 0)
materialSetEmissive(m, 60, 255, 90)
world.laserMaterial = m
world.laserFan = nodeNew()
nodeSetPosition(world.laserFan, 0, 9, -16)
world.lasers = {}
for i = 1, LASERS do
local arm = nodeNew(world.laserFan)
local rod = nodeNew(arm)
nodeSetMesh(rod, meshBox(0.1, 0.1, 40), m)
nodeSetPosition(rod, 0, 0, 20)
world.lasers[i] = arm
end
end
-- Glenz vectors: a glass octahedron inside a glass cube, both faceted, turning against each other.
local function octahedron(r)
local v = { { r, 0, 0 }, { -r, 0, 0 }, { 0, r, 0 }, { 0, -r, 0 }, { 0, 0, r }, { 0, 0, -r } }
local faces = { { 1, 3, 5 }, { 5, 3, 2 }, { 2, 3, 6 }, { 6, 3, 1 }, { 5, 4, 1 }, { 2, 4, 5 }, { 6, 4, 2 }, { 1, 4, 6 } }
local positions = {}
local normals = {}
local indices = {}
for _, f in ipairs(faces) do
local a = v[f[1]]
local b = v[f[2]]
local c = v[f[3]]
local nx = (a[1] + b[1] + c[1]) / 3
local ny = (a[2] + b[2] + c[2]) / 3
local nz = (a[3] + b[3] + c[3]) / 3
local l = math.sqrt(nx * nx + ny * ny + nz * nz)
for _, p in ipairs({ a, b, c }) do
positions[#positions + 1] = p[1]
positions[#positions + 1] = p[2]
positions[#positions + 1] = p[3]
normals[#normals + 1] = nx / l
normals[#normals + 1] = ny / l
normals[#normals + 1] = nz / l
indices[#indices + 1] = #positions / 3
end
end
return meshNew(positions, normals, nil, indices)
end
local function buildGlenz()
world.glenz = nodeNew()
world.glenzOuter = nodeNew(world.glenz)
world.glenzInner = nodeNew(world.glenz)
local outer = K.glass(255, 60, 200, 110)
local inner = K.glass(60, 220, 255, 150)
materialSetDoubleSided(outer, true)
materialSetEmissive(outer, 70, 12, 60)
materialSetEmissive(inner, 12, 60, 75)
nodeSetMesh(world.glenzOuter, meshBox(2.4, 2.4, 2.4), outer)
nodeSetMesh(world.glenzInner, octahedron(1.25), inner)
end
-- The boing ball, and the balls that rain on the snares.
local function buildBoing()
local skin = materialNew()
materialSetTexture(skin, spriteLoad(DIR .. "art/boing.png"))
materialSetRoughness(skin, 0.35)
world.boing = nodeNew()
nodeSetMesh(world.boing, meshSphere(1.6, 32), skin)
world.rainMesh = meshSphere(0.32, 16)
world.rainMaterials = {}
for i, c in ipairs(DISCO) do
local m = materialNew()
materialSetColor(m, c[1], c[2], c[3])
materialSetMetallic(m, 0.4)
materialSetRoughness(m, 0.2)
world.rainMaterials[i] = m
end
world.rain = {}
end
-- Screens for the 2D-in-3D effects: the rotozoomer, the tunnel, and a kaleidoscope of four mirrored
-- copies of the plasma.
local function buildScreens()
local roto = materialNew()
materialSetTexture(roto, spriteLoad(DIR .. "art/roto.png"))
materialSetUnlit(roto, true)
materialSetDoubleSided(roto, true)
materialSetFilter(roto, FILTER_NEAREST)
world.rotoMaterial = roto
world.roto = K.screenQuad(world.camera, FOV, roto, 2, 2.2)
local tunnel = materialNew()
materialSetVideo(tunnel, video.tunnel)
materialSetUnlit(tunnel, true)
materialSetDoubleSided(tunnel, true)
world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 60, 1.8)
world.kaleido = nodeNew(world.camera)
nodeSetPosition(world.kaleido, 0, 0, -2)
local h = 2 * 2 * math.tan(math.rad(FOV / 2))
local w = h * W / H
for _, q in ipairs({ { -1, 1, 1, 1 }, { 1, 1, -1, 1 }, { -1, -1, 1, -1 }, { 1, -1, -1, -1 } }) do
local m = materialNew()
local n = nodeNew(world.kaleido)
materialSetVideo(m, video.plasma)
materialSetUnlit(m, true)
materialSetDoubleSided(m, true)
materialSetTiling(m, q[3], q[4])
nodeSetMesh(n, meshPlane(w / 2, h / 2), m)
nodeSetPosition(n, q[1] * w / 4, q[2] * h / 4, 0)
nodeSetRotation(n, 90, 0, 0)
end
end
local function buildWorld()
sceneEnable(true)
sceneSetSky(DIR .. "art/club.png")
sceneSetSkyIntensity(1.0)
sceneSetAmbient(20, 16, 30)
sceneSetBloom(0.7, 0.55)
world.camera = nodeNew()
cameraSet(world.camera)
cameraSetPerspective(FOV, 0.1, 400)
buildFloor()
buildBall()
buildLights()
buildLasers()
buildGlenz()
buildBoing()
buildScreens()
end
-- What is shown --------------------------------------------------------------------------------------
local function setVisible(list, on)
for _, n in ipairs(list) do
nodeSetVisible(n, on)
end
end
local function hideAll()
for _, tile in ipairs(world.tiles) do
nodeSetVisible(tile.node, false)
end
setVisible({ world.ball, world.rope, world.sparkleRing, world.laserFan, world.glenz, world.boing, world.roto, world.tunnel, world.kaleido }, false)
for _, l in ipairs(world.spots) do
lightSetIntensity(l, 0)
end
lightSetIntensity(world.ballSpot, 0)
lightSetIntensity(world.sun, 0)
sceneSetFog()
sceneSetSkyIntensity(1.0)
end
local function showClub(withBall)
for _, tile in ipairs(world.tiles) do
nodeSetVisible(tile.node, true)
end
setVisible({ world.ball, world.rope, world.sparkleRing }, withBall ~= false)
end
-- The club in motion ----------------------------------------------------------------------------------
-- The ball turns, hangs at height y, and its sparkles turn with it, twinkling on the hats.
local function ballSpin(t, y, speed, sparkle)
local spin = t * (speed or 30)
nodeSetPosition(world.ball, 0, y, 0)
nodeSetRotation(world.ball, 0, spin, 0)
nodeSetPosition(world.rope, 0, y + 6, 0)
nodeSetPosition(world.sparkleRing, 0, y, 0)
nodeSetRotation(world.sparkleRing, 0, spin, 0)
local s = (sparkle or 1) * (0.6 + 0.6 * K.env("hat", t))
for g, m in ipairs(world.facetMaterials) do
local v = (sparkle or 1) * (40 + 215 * math.max(0, math.sin(t * 9 + g * 1.7)) ^ 6 + 60 * K.decay("kicks", t, 8))
materialSetEmissive(m, math.floor(math.min(255, v)), math.floor(math.min(255, v)), math.floor(math.min(255, v * 1.05)))
end
nodeSetScale(world.sparkleRing, 1)
for i, n in ipairs(world.sparkles) do
nodeSetScale(n, s * (0.5 + 0.5 * math.sin(t * 7 + i * 2.3)))
end
lightSetIntensity(world.ballSpot, 60 * (sparkle or 1))
end
-- The spotlights sweep the room on circles of their own, flaring on the kicks.
local function spots(t, level)
local flare = 1 + 1.5 * K.decay("kicks", t, 6)
for i, l in ipairs(world.spots) do
local a = t * 0.7 + i * math.pi / 2
nodeSetPosition(l, math.cos(i * 1.57) * 10, 11, math.sin(i * 1.57) * 10)
nodeLookAt(l, math.cos(a) * 6, 0, math.sin(a) * 6)
lightSetIntensity(l, 120 * level * flare)
end
end
-- The floor's patterns, one per phrase: a checker that swaps on every beat, ripples from the middle
-- on the kicks, diagonal waves, and a scatter that changes every beat. level dims it all.
local function floor(t, pattern, level)
local b = K.beat(t)
local whole = math.floor(b)
local ripple = K.hitAge("kicks", t) * 14
local colour = DISCO[whole % #DISCO + 1]
for _, tile in ipairs(world.tiles) do
local i = tile.i
local j = tile.j
local v = 0
if pattern == 0 then
v = ((i + j + whole) % 2 == 0) and 1 or 0.08
elseif pattern == 1 then
local d = math.sqrt((i - TILES / 2 - 0.5) ^ 2 + (j - TILES / 2 - 0.5) ^ 2) * TILE
v = math.max(0.06, math.exp(-math.abs(d - ripple) * 0.9))
elseif pattern == 2 then
v = 0.5 + 0.5 * math.sin((i + j) * 0.8 - t * 6)
else
v = (((i * 7 + j * 13 + whole * 31) % 17) < 6) and 1 or 0.05
end
local c = colour
if pattern == 2 or pattern == 1 then
c = DISCO[(i + j + whole) % #DISCO + 1]
end
v = v * level
materialSetEmissive(tile.material, math.floor(c[1] * v), math.floor(c[2] * v), math.floor(c[3] * v))
end
end
local function lasers(t, level)
for i, arm in ipairs(world.lasers) do
local spread = (i - (LASERS + 1) / 2) / LASERS
local sweep = math.sin(t * 1.3) * 25 + spread * (60 + 30 * math.sin(t * 0.7))
nodeSetRotation(arm, 0, sweep, -10 + 12 * math.sin(t * 0.9 + i))
end
local v = math.floor(255 * level * (0.6 + 0.4 * K.decay("snares", t, 8)))
materialSetEmissive(world.laserMaterial, v // 4, v, v // 3)
end
local function orbit(t, radius, height, speed, lookY, phase)
local a = t * speed + (phase or 0)
nodeSetPosition(world.camera, math.cos(a) * radius, height, math.sin(a) * radius)
nodeLookAt(world.camera, 0, lookY, 0)
end
-- 2D effects --------------------------------------------------------------------------------------------
-- Copper bars: count bars swinging up and down, coloured by hue, each a gradient strip stretched.
local function copperBars(t, count, y0, amp, hueShift, alpha)
for i = 1, count do
local y = y0 + math.sin(t * 2.1 + i * 0.55) * amp
local r, g, b = K.hue(i / count * 0.6 + hueShift)
spriteSetTint(art.copper, r, g, b)
spriteSetAlpha(art.copper, alpha or 255)
spriteDraw(art.copper, 0, math.floor(y), W - 1, math.floor(y) + 23)
end
spriteSetTint(art.copper, 255, 255, 255)
spriteSetAlpha(art.copper, 255)
end
-- The twister: a square bar turning about the vertical, its turn varying down the screen. Each
-- scanline shows the faces whose edges come round in order there, each face one row of its texture
-- stretched between its two edges and shaded by how squarely it faces the viewer.
local function twister(t, cx)
local faceW = 64
for y = 0, H - 1, TWIST_ROW do
local a = t * 1.6 + math.sin(y * 0.011 + t * 1.2) * 2.2 + math.sin(t * 0.5) * y * 0.004
local row = math.floor(y + t * 60) % 64
local x = {}
local wobble = math.sin(y * 0.02 + t * 2) * 30
for e = 0, 4 do
x[e] = cx + wobble + math.sin(a + e * math.pi / 2) * TWIST_R
end
for e = 0, 3 do
if x[e + 1] > x[e] + 0.5 then
local shade = math.floor(80 + 175 * math.sin(math.min(math.pi, (x[e + 1] - x[e]) / (2 * TWIST_R) * math.pi * 0.72)))
spriteSetTint(art.twister, shade, shade, shade)
spriteDrawGrid(art.twister, math.floor(x[e]), y, e * faceW, row, faceW, 1, math.floor(x[e + 1]), y + TWIST_ROW - 1)
end
end
end
spriteSetTint(art.twister, 255, 255, 255)
end
-- The title, a letter at a time: each falls from above, lands on its beat, and bounces lower each
-- beat after.
local TITLE = "MIRRORBALL"
local function titleDrop(t, firstBeat, beatsApart, y)
local b = K.beat(t)
local widths = {}
local total = 0
for i = 1, #TITLE do
widths[i] = K.textWidth(TITLE:sub(i, i), 72) + 4
total = total + widths[i]
end
local x = W / 2 - total / 2
for i = 1, #TITLE do
local land = firstBeat + (i - 1) * beatsApart
local since = b - land
if since > -1 then
local lift = 0
if since < 0 then
lift = (since * since) * 300
else
local bounce = since % 1
local height = 60 * math.exp(-math.floor(since) * 0.8)
lift = 4 * height * bounce * (1 - bounce)
end
local r, g, bb = K.hue(i / #TITLE + t * 0.05)
K.text(x + widths[i] / 2, y - lift, TITLE:sub(i, i), 72, r, g, bb, "center")
end
x = x + widths[i]
end
end
-- The shatter -------------------------------------------------------------------------------------------
-- Every facet leaves the ball: put where it is in the world, given a body, and thrown outward and
-- up with a spin.
local function shatter(t)
local spin = math.rad(t * world.lastSpin)
local c = math.cos(spin)
local s = math.sin(spin)
local by = world.lastBallY
nodeSetVisible(world.core, false)
nodeSetVisible(world.rope, false)
for _, f in ipairs(world.facets) do
-- The ball turned about y by spin; the facet's place in the room follows.
local x = f.x * c + f.z * s
local z = -f.x * s + f.z * c
nodeSetParent(f.node, 0)
nodeSetPosition(f.node, x, by + f.y, z)
nodeLookAt(f.node, 0, by, 0)
bodyNew(f.node, BODY_DYNAMIC, SHAPE_BOX, FACET, FACET, 0.04)
bodySetMass(f.node, 0.05)
bodySetBounce(f.node, 0.35)
local out = 3 + math.random() * 5
bodySetVelocity(f.node, x / BALL_R * out, (f.y / BALL_R) * out + 3, z / BALL_R * out)
bodySetAngularVelocity(f.node, (math.random() - 0.5) * 20, (math.random() - 0.5) * 20, (math.random() - 0.5) * 20)
end
world.shattered = true
end
-- The scenes -------------------------------------------------------------------------------------------
local SCENES = {
-- Bars 1 to 8: the choppy intro. Copper bars light only while the band plays, so the gaps in
-- the groove are black; the title drops in a letter every two beats.
{
from = 0, to = 9,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(0, 0, 0, 255)
local gate = K.clamp((K.env("level", t) - 0.15) * 2.5, 0, 1)
copperBars(t, 10, 210, 170, t * 0.05, math.floor(255 * gate))
titleDrop(t, K.beat(K.barTime(2)), 2, 230)
if bar >= 7 then
local w = spriteGetWidth(art.singe) * 0.5
spriteSetAlpha(art.singe, math.floor(255 * K.smooth(7, 7.5, bar)))
spriteDraw(art.singe, math.floor(W / 2 - w / 2), 330, math.floor(W / 2 + w / 2), 330 + spriteGetHeight(art.singe) // 2)
spriteSetAlpha(art.singe, 255)
K.text(W / 2, 312, "PRESENTED WITH", 18, 200, 180, 140, "center")
end
K.strobe(t, "kicks", 16, 255, 220, 160, 40)
if bar > 8.75 then
K.flash(255, 255, 255, math.floor(255 * K.smooth(8.75, 9, bar)))
end
end,
},
-- Bars 9 to 16: the club. The ball comes down over the floor, the spots sweep, the floor
-- checker swaps on every beat, and the title settles at the bottom.
{
from = 9, to = 17,
enter = function(t)
hideAll()
showClub(true)
end,
draw = function(t, bar, k)
local drop = K.easeOut((bar - 9) / 2)
ballSpin(t, K.lerp(16, BALL_Y, drop), 30, drop)
orbit(t, 13 - 3 * k, 2.5 + 2 * k, 0.12, 3.5 + 1.5 * drop)
spots(t, drop)
floor(t, 0, 0.3 + 0.7 * drop)
if bar >= 13 then
local a = math.floor(255 * K.smooth(13, 13.5, bar))
local w = spriteGetWidth(art.logo) * 0.85
local h = spriteGetHeight(art.logo) * 0.85
spriteSetAlpha(art.logo, a)
spriteDraw(art.logo, math.floor(W / 2 - w / 2), math.floor(H - 20 - h), math.floor(W / 2 + w / 2), H - 20)
spriteSetAlpha(art.logo, 255)
end
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(9, 9.25, bar))))
end,
},
-- Bars 17 to 24: the lead comes in, and the rotozoomer with it: the floor's own disco tile, on a
-- screen that turns and zooms, under copper bars coloured by the note being played and the
-- scroller.
{
from = 17, to = 25,
enter = function(t)
hideAll()
nodeSetVisible(world.roto, true)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
local zoom = 4 + 3 * math.sin(t * 0.6) + 2 * K.decay("kicks", t, 7)
materialSetTiling(world.rotoMaterial, zoom * W / H, zoom)
nodeSetRotation(world.roto, 0, 0, t * 30 + math.sin(t * 0.8) * 40)
local note, clear = K.note(t)
copperBars(t, 6, 40, 30, note / 12, math.floor(120 + 135 * clear))
K.scroller(t, art.font, SCROLL_TEXT, H - 80, 22, 180, K.barTime(17))
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(17, 17.2, bar))))
end,
},
-- Bars 25 to 32: a kaleidoscope of the plasma, four mirrored copies, under vector bobs that take
-- a new shape every two bars.
{
from = 25, to = 33,
enter = function(t)
hideAll()
nodeSetVisible(world.kaleido, true)
videoPlay(video.plasma)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
nodeSetRotation(world.kaleido, 0, 0, math.sin(t * 0.4) * 20)
nodeSetScale(world.kaleido, 1.15 + 0.1 * K.decay("kicks", t, 6))
K.bobs(t, bar, art.bob, 56, 1.4, 21, W / 2, H / 2)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(25, 25.2, bar))))
end,
},
-- Bars 33 to 40: the chop comes back, and the picture cuts on every beat between the club's
-- corners; the gaps in the groove go black.
{
from = 33, to = 41,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
local beat = math.floor(K.beat(t))
local shots = {
{ 0, 1.5, 10, 0, 5, 0 }, { 9, 9, 9, 0, 3, 0 }, { 0, 13, 0.1, 0, 0, 0 }, { -3, 6.5, 3, 0, 6.2, 0 },
{ 12, 1, -4, 0, 4, 0 }, { 0, 4, -11, 0, 5, 0 }, { -9, 2, 6, 0, 6, 0 }, { 3, 5.5, 2.5, 0, 6.2, 0 },
}
local s = shots[beat % #shots + 1]
nodeSetPosition(world.camera, s[1], s[2], s[3])
nodeLookAt(world.camera, s[4], s[5], s[6])
ballSpin(t, BALL_Y, 40, 1)
spots(t, 1)
lasers(t, 0.8)
floor(t, 3, 1)
local gate = K.clamp((K.env("level", t) - 0.12) * 3, 0, 1)
K.flash(0, 0, 0, math.floor(255 * (1 - gate)))
K.flash(255, 255, 255, math.floor(160 * math.exp(-(K.beat(t) % 1) * 14)))
end,
},
-- Bars 41 to 48: the twister over copper bars, drawn one scanline at a time.
{
from = 41, to = 49,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(14, 4, 22, 255)
copperBars(t, 14, 220, 200, t * 0.03, 200)
twister(t, W / 2 + math.sin(t * 0.7) * 120)
K.scroller(t, art.font, SCROLL_TEXT, 30, 14, 200, K.barTime(41) - 40)
K.strobe(t, "kicks", 14, 255, 255, 255, 50)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(41, 41.2, bar))))
end,
},
-- Bars 49 to 56: the boing ball, bouncing on every beat and crossing the room every bar, while
-- a ball drops from the ceiling on every snare and piles up on the floor.
{
from = 49, to = 57,
enter = function(t)
hideAll()
showClub(false)
nodeSetVisible(world.boing, true)
lightSetIntensity(world.sun, 3)
end,
draw = function(t, bar, k)
local b = K.beat(t)
local hop = b % 1
local across = math.sin((bar - 49) * math.pi)
nodeSetPosition(world.boing, across * 8, 1.6 + 4 * 4 * hop * (1 - hop) * 1.4, 2)
nodeSetRotation(world.boing, 20, t * 120, 0)
orbit(t, 12, 4, 0.08, 2.5, 1.2)
floor(t, 1, 0.8)
spots(t, 0.6)
if K.hitCount("snares", lastT, t) > 0 and #world.rain < RAIN_MAX then
local n = nodeNew()
nodeSetMesh(n, world.rainMesh, world.rainMaterials[#world.rain % #world.rainMaterials + 1])
nodeSetPosition(n, (math.random() - 0.5) * 16, 14, (math.random() - 0.5) * 10 - 2)
bodyNew(n, BODY_DYNAMIC, SHAPE_SPHERE, 0.32)
bodySetMass(n, 0.5)
bodySetBounce(n, 0.7)
world.rain[#world.rain + 1] = n
end
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(49, 49.2, bar))))
end,
leave = function(t)
for _, n in ipairs(world.rain) do
bodyDelete(n)
nodeDelete(n)
end
world.rain = {}
end,
},
-- Bars 57 to 64: glenz glass in the tunnel, the tunnel scrubbed by the kick.
{
from = 57, to = 65,
enter = function(t)
hideAll()
nodeSetVisible(world.tunnel, true)
nodeSetVisible(world.glenz, true)
world.tunnelPhase = 0
world.tunnelLast = t
sceneSetSkyIntensity(1.5)
lightSetIntensity(world.sun, 5)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
world.tunnelPhase = (world.tunnelPhase + (t - world.tunnelLast) * (0.5 + 1.5 * K.decay("kicks", t, 5))) % 1
world.tunnelLast = t
videoSeek(video.tunnel, math.floor(world.tunnelPhase * video.tunnelFrames))
nodeSetRotation(world.tunnel, 0, 0, -t * 20)
local pump = 1 + 0.2 * K.decay("kicks", t, 8)
nodeSetPosition(world.glenz, 0, 0, -6)
nodeSetScale(world.glenz, pump)
nodeSetRotation(world.glenzOuter, t * 37, t * 53, t * 19)
nodeSetRotation(world.glenzInner, -t * 61, t * 29, -t * 43)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(57, 57.2, bar))))
end,
},
-- Bars 65 to 72: the laser show, in a haze, the camera moving through the beams.
{
from = 65, to = 73,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
sceneSetFog(16, 8, 30, 6, 70)
end,
draw = function(t, bar, k)
orbit(t, 12, 2 + math.sin(t * 0.4) * 1.5, -0.15, 5)
ballSpin(t, BALL_Y, 45, 1)
spots(t, 0.8)
lasers(t, 1)
floor(t, 2, 0.7)
K.strobe(t, "snares", 20, 255, 255, 255, 60)
end,
},
-- Bars 73 to 80: the plasma flat on the screen, the Singe logo rotozoomed over it, a ring of bobs.
{
from = 73, to = 81,
enter = function(t)
hideAll()
videoPlay(video.plasma)
end,
draw = function(t, bar, k)
videoDraw(video.plasma, 0, 0, W - 1, H - 1)
local punch = 1 + 0.3 * K.decay("kicks", t, 8)
for i = 1, 12 do
local a = i / 12 * 2 * math.pi + t
local r = 190 * punch
local cr, cg, cb = K.hue(i / 12 + t * 0.1)
spriteSetTint(art.bob, cr, cg, cb)
spriteDraw(art.bob, math.floor(W / 2 + math.cos(a) * r - 18), math.floor(H / 2 + math.sin(a) * r * 0.8 - 18), math.floor(W / 2 + math.cos(a) * r + 18), math.floor(H / 2 + math.sin(a) * r * 0.8 + 18))
end
spriteSetTint(art.bob, 255, 255, 255)
spriteRotateAndScale(art.singe, math.sin(t * 0.9) * 35, (1 + 0.4 * math.sin(t * 1.1)) * punch)
spriteDraw(art.singe, W // 2, H // 2, true)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(73, 73.2, bar))))
end,
leave = function(t)
spriteRotateAndScale(art.singe, 0, 1)
end,
},
-- Bars 81 to 88: the breakdown. The kick is gone; the club is dark but for the ball and its
-- sparkles, turning slowly. When the bass returns at bar 85 the floor comes back a tile a beat.
{
from = 81, to = 89,
enter = function(t)
hideAll()
showClub(true)
end,
draw = function(t, bar, k)
orbit(t, 9, 3 + k * 2, 0.06, BALL_Y)
ballSpin(t, BALL_Y, 12, 0.8 + 0.4 * k)
sceneSetSkyIntensity(0.4 + 0.6 * k)
local lit = (bar >= 85) and math.floor(K.beat(t) - K.beat(K.barTime(85))) * 9 or 0
for n, tile in ipairs(world.tiles) do
local on = ((n * 37) % #world.tiles) < lit
local c = DISCO[n % #DISCO + 1]
local v = on and 0.8 or 0
materialSetEmissive(tile.material, math.floor(c[1] * v), math.floor(c[2] * v), math.floor(c[3] * v))
end
end,
},
-- Bars 89 to 96: the build. Everything comes back on, the camera climbs, and the beat strobes.
{
from = 89, to = 97,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
orbit(t, 14 - 4 * k, 2 + 7 * k, 0.2, BALL_Y - 2 * k)
ballSpin(t, BALL_Y, 30 + 30 * k, 1)
spots(t, k)
lasers(t, k)
floor(t, math.floor((bar - 89) / 2) % 4, 0.4 + 0.6 * k)
K.flash(255, 255, 255, math.floor(110 * k * math.exp(-(K.beat(t) % 1) * 10)))
copperBars(t, 4, 10, 8, t * 0.1, math.floor(200 * k))
copperBars(t, 4, H - 40, 8, t * 0.1 + 0.5, math.floor(200 * k))
end,
},
-- Bars 97 to 112: the party, then everything at once. The floor changes pattern every phrase,
-- the bobs and the scroller come up over the club, and in the last phrase the camera spins.
{
from = 97, to = 113,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
local final = bar >= 105
if final then
orbit(t, 9 + 3 * math.sin(t * 0.5), 2 + 2 * math.sin(t * 0.3), 0.45, BALL_Y - 1)
else
orbit(t, 13, 3.5, 0.18, BALL_Y - 1.5)
end
ballSpin(t, BALL_Y, final and 70 or 40, 1)
spots(t, 1)
lasers(t, 1)
floor(t, math.floor((bar - 97) / 2) % 4, 1)
sceneSetBloom(0.7, 0.55 + (final and 0.4 or 0.2) * K.decay("kicks", t, 7))
K.bobs(t, bar, art.bob, 36, 0.9, 13, W / 2, 150)
K.scroller(t, art.font, " GREETINGS TO EVERYONE ON THE DANCE FLOOR ... KEEP IT FUNKY ... MIRRORBALL ... A SINGE DEMO ... ", H - 70, 16, 190, K.barTime(97))
K.strobe(t, "kicks", 18, 255, 255, 255, final and 50 or 25)
if bar > 112.5 then
K.flash(255, 255, 255, math.floor(200 * K.smooth(112.5, 113, bar)))
end
end,
},
-- Bar 113 on: the last downbeat. The ball shatters, its facets rain onto the floor, the lights
-- go down, and the credits come up.
{
from = 113, to = 200,
enter = function(t)
hideAll()
showClub(true)
world.lastSpin = 70
world.lastBallY = BALL_Y
shatter(t)
world.endCamera = t
end,
draw = function(t, bar, k)
local p = t - K.barTime(113)
orbit(world.endCamera + p * 0.3, 13 + p * 0.5, 3 + p * 0.25, 0.2, 2)
lightSetIntensity(world.ballSpot, 0)
sceneSetSkyIntensity(math.max(0.25, 1 - p * 0.12))
floor(t, 1, math.max(0, 1 - p * 0.15))
for i, l in ipairs(world.spots) do
lightSetIntensity(l, math.max(0, 120 - p * 25))
end
K.flash(255, 255, 255, math.floor(255 * math.exp(-p * 3)))
local lines = {
{ "MIRRORBALL", 56, 255, 200, 90 },
{ "music \"Vintage Funky Groove\" by miiira", 26, 255, 255, 255 },
{ "code and art Claude", 26, 255, 255, 255 },
{ "engine Singe", 26, 255, 255, 255 },
}
local y = 90
for i, line in ipairs(lines) do
local a = K.smooth(1.5 + i * 0.4, 2.1 + i * 0.4, p) * (1 - K.smooth(K.duration - K.barTime(113) - 1, K.duration - K.barTime(113) + 1, p))
if a > 0 then
K.text(W / 2, y, line[1], line[2], math.floor(line[3] * a), math.floor(line[4] * a), math.floor(line[5] * a), "center")
end
y = y + line[2] + 22
end
end,
},
-- The time at the end of each frame, for "what happened since last frame".
{
from = -10, to = 1000,
draw = function(t, bar, k)
lastT = t
end,
},
}
loadArt()
buildWorld()
hideAll()
K.run(SCENES)

BIN
demos/mirrorball/music.mp3 (Stored with Git LFS) Normal file

Binary file not shown.

301
demos/mirrorball/sync.lua Normal file

File diff suppressed because one or more lines are too long

BIN
demos/mirrorball/video/plasma.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/mirrorball/video/tunnel.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/bob.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/car.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/flag.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/glow.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/grid.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/gridCyan.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/lamp.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/mountains.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/palm.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/road.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/singe.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/sky.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/skyline.png (Stored with Git LFS) Normal file

Binary file not shown.

12
demos/neonCoast/games.dat Normal file
View file

@ -0,0 +1,12 @@
-- Neon Coast: a demo for Singe, to "Neons in Napoli" by eliza_music.
GAMES = {
{
TITLE = "Neon Coast",
SCRIPT = "neonCoast/neonCoast.singe",
CANVAS_X = 640,
CANVAS_Y = 480,
RESOLUTION_X = 1024,
RESOLUTION_Y = 768,
REQUIRES = { "SCENE", "POST" }
}
}

BIN
demos/neonCoast/music.mp3 (Stored with Git LFS) Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

273
demos/neonCoast/sync.lua Normal file

File diff suppressed because one or more lines are too long

BIN
demos/neonCoast/video/noise.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/video/plasma.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/video/tunnel.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -116,7 +116,7 @@ in the same download as this one.
== The Series
Thirty lessons in four parts. Each one needs the one before it and nothing
Thirty-one lessons in four parts. Each one needs the one before it and nothing
else.
*Part One: The Ideas.* No artwork, no video, nothing to install. You draw with
@ -166,6 +166,7 @@ order.
. *Characters That Move Themselves* -- navigation and pathfinding.
. *Music and MIDI* -- tracks, soundfonts, and instruments.
. *Online* -- accounts, high score tables, and the catalogue.
. *Keeping Data* -- databases, tables, and the questions you ask of them.
. *Shipping It* -- packing, patching, licences, and the release.
. *In a Cabinet* -- controls, service menus, bezels, and the things that only
matter on real hardware.
@ -226,6 +227,8 @@ include::lessons/27-midi.adoc[]
include::lessons/28-online.adoc[]
include::lessons/29-shipping.adoc[]
include::lessons/29-databases.adoc[]
include::lessons/30-cabinet.adoc[]
include::lessons/30-shipping.adoc[]
include::lessons/31-cabinet.adoc[]

View file

@ -21,7 +21,7 @@ Singe uses the Lua programming language (http://www.lua.org) for scripting game
logic. Lua is fast, lightweight, object-oriented, easy to use, and actually
used in the games industry. This book does not teach it; _Learn to Program
with Singe_, in the same download as this one, does, from nothing to a
finished game in thirty lessons. Video tutorials for Lua are on the Kangaroo
finished game in thirty-one lessons. Video tutorials for Lua are on the Kangaroo
Punch YouTube channel, and the language's own reference is at
http://www.lua.org.
@ -307,7 +307,7 @@ When Singe runs a game from a database the menu passes `CONTAINER` in the
For your own data, `require("sqlite3")` gives scripts the
https://lua.sqlite.org[lsqlite3] binding to the same SQLite the engine
uses. Keep such databases in `singeGetDataPath()`:
uses. Keep databases the game writes in `singeGetDataPath()`:
[source,lua]
----
@ -320,6 +320,23 @@ end
db:close()
----
`sqlite3.open` finds a name the way `io.open` does, so a database can ship
inside the game -- level tables, dialogue, item lists -- and still open when
the game is packed. Opened read only, a packed database is read where it lies,
with nothing copied:
[source,lua]
----
local levels = sqlite3.open(DIR .. "levels.db", sqlite3.OPEN_READONLY)
----
Opened for writing, which is `sqlite3.open`'s default, a packed database is
copied once to the game's data directory, under `files/` like everything else
a packed game writes, and the copy is opened. From then on the name means the
copy, read only or not, and the packed original is never changed. A loose
game's database opens in place exactly as before. `testScripts/packedSql` is
the test; its header says how to run it.
The file format, for anyone writing tools: three tables.
`meta(key, value)` holds `version` (1), `gamedir` (the packed directory's
name), `chunk` (chunk size in bytes, 4194304), and `packer`.
@ -2460,6 +2477,7 @@ available to `controls.cfg` and to `Framework.singe` alike:
| `MOUSE_SINGLE`, `MOUSE_MANY` (also `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments for `mouseSetMode`.
| `OVERLAY_NOT_UPDATED`, `OVERLAY_UPDATED` | Return values for `onOverlayUpdate`.
| `RENDER_PIXELATED`, `RENDER_SMOOTH` | Arguments for `spriteQuality` / `videoQuality`.
| `SPRITE_ALPHA`, `SPRITE_ADD`, `SPRITE_MULTIPLY` | Blending for `spriteSetBlend`.
| `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Return values of `discGetState`.
| `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Arguments for `lightNew`.
| `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Body types for `bodyNew`.
@ -8370,6 +8388,295 @@ nodeSetRotation(ring, 90, 0, 0)
[#model]
[#midi]
=== Microphone
A microphone, for a singing game or anything that listens. One recording
device is open at a time, heard as one channel at 48000 samples a second
whatever the hardware gives; SDL converts. Singe keeps the newest eight seconds
of what it hears, and every call below answers from that, so asking costs
nothing the game did not already pay for.
What a game usually wants is <<micpitch,`micPitch`>> -- the note being sung --
and <<miclevel,`micLevel`>>. A game that analyses the sound itself, or sends it
somewhere, reads the samples with <<micread,`micRead`>>; the
<<voice,Voice>> calls encode them for a network.
The microphone closes when the game ends. Nothing is opened until a script asks.
[NOTE]
====
A sung note reaches the game a little after it was sung: the sound card, the
driver, and the buffers each hold it for a moment. A singing game that judges
timing subtracts that. <<miccalibrate,`micCalibrate`>> measures it by playing
clicks through the speakers and timing them coming back, and the answer is kept
for the machine, like the <<singesetaudiocalibration,audio calibration>>.
Monitoring and calibration have only been tested without real hardware so far;
the pitch, level, reading, and recording have been tested with a known signal.
====
[#miccalibrate]
==== micCalibrate
[source,text]
----
started = micCalibrate()
----
Measures the microphone's latency: plays five clicks through the speakers, a
fraction of a second apart, and times each one coming back through the
microphone. It takes a few seconds; <<miccalibration,`micCalibration`>> says
when it is done. The answer is the middle of the clicks heard, and is kept for
this machine. Do it in a quiet room, with the speakers loud enough for the
microphone to hear them.
*Returns:* `true` when it started; `false` with no microphone open or no
speaker to play the clicks.
*Since:* 3.00.
*See also:* <<miccalibration,micCalibration>>, <<miclatency,micLatency>>
[#miccalibration]
==== micCalibration
[source,text]
----
state, latency = micCalibration()
----
How a calibration is going.
*Returns:* `state`, one of `"idle"`, `"running"`, `"done"`, or `"failed"`
(fewer than three of the clicks were heard back), and the latency in
milliseconds once one is known, otherwise `nil`.
*Since:* 3.00.
*See also:* <<miccalibrate,micCalibrate>>
.Example
[source,lua]
----
local state, latency = micCalibration()
if state == "done" then
message = "Your microphone runs " .. latency .. " ms behind."
elseif state == "failed" then
message = "I could not hear the clicks. Turn the speakers up and try again."
end
----
[#micclose]
==== micClose
[source,text]
----
micClose()
----
Stops listening, and stops any recording and monitoring with it. The microphone
also closes when the game ends.
*Since:* 3.00.
[#micisopen]
==== micIsOpen
[source,text]
----
open = micIsOpen()
----
*Returns:* `true` while a microphone is open.
*Since:* 3.00.
[#miclatency]
==== micLatency
[source,text]
----
milliseconds = micLatency()
----
*Returns:* the time between a sound being made and the game hearing it, as
measured on this machine by <<miccalibrate,`micCalibrate`>> (at any time, in any
game), or set by <<micsetlatency,`micSetLatency`>>; `nil` until then.
*Since:* 3.00.
[#miclevel]
==== micLevel
[source,text]
----
rms, peak = micLevel()
----
How loud the last moment heard was.
*Returns:* the RMS and the peak of the last block of samples, each `0` to `1`.
A steady tone at full scale reads about `0.71` RMS. Both are `0` with no
microphone open.
*Since:* 3.00.
[#miclist]
==== micList
[source,text]
----
names = micList()
----
*Returns:* a list of the names of the microphones this machine has, for
<<micopen,`micOpen`>>. It is taken afresh each time, so a microphone plugged in
since the last call appears.
*Since:* 3.00.
[#micmonitor]
==== micMonitor
[source,text]
----
ok = micMonitor(on [, volume])
----
Plays what the microphone hears back through the speakers, so a singer hears
themselves over the music. Off until asked for: on most hardware the voice comes
back late enough to be off-putting, and on a laptop the speakers feed the
microphone. Anything waiting to be played longer than a tenth of a second is
dropped rather than heard late.
*Parameters:*
* `on` -- boolean.
* `volume` -- number, `0` to `1`; `1` unless given.
*Returns:* `true`, or `false` with no microphone open or no speaker to play to.
*Since:* 3.00.
[#micopen]
==== micOpen
[source,text]
----
opened = micOpen([name])
----
Starts listening, to the microphone named (from <<miclist,`micList`>>) or the
machine's default. A microphone already open is closed first.
*Returns:* `true` when it opened.
*Since:* 3.00.
.Example
[source,lua]
----
if not micOpen() then
message = "No microphone. This game needs one."
end
----
[#micpitch]
==== micPitch
[source,text]
----
hz, clarity = micPitch()
----
The note being sung, found in the newest 43 milliseconds heard. It finds a
fundamental between 50 and 2000 Hz, which covers every singing voice, and it
answers within a hertz or two on a clean tone.
*Returns:* the fundamental in Hz, and how clearly the sound is one note, `0` to
`1`. With nothing voiced -- silence, a breath, a cough, a crowd -- `hz` is `0`.
Turn Hz into a note number with `69 + 12 * math.log(hz / 440, 2)`: 69 is the A
above middle C, and every 1 is a semitone.
*Since:* 3.00.
.Example
[source,lua]
----
local hz, clarity = micPitch()
if hz > 0 and clarity > 0.8 then
local note = 69 + 12 * math.log(hz / 440, 2)
local off = note - targetNote -- semitones sharp (positive) or flat
if math.abs(off) < 0.5 then
score = score + 1
end
end
----
[#micread]
==== micRead
[source,text]
----
samples = micRead([max])
----
What was heard since the last call, oldest first.
*Parameters:*
* `max` -- number, the most samples to return; everything waiting unless given.
*Returns:* a string of 16-bit samples, two bytes each, little endian, one
channel at 48000 a second: `#samples / 2` of them. Read one with
`string.unpack("<i2", samples, n * 2 - 1)`. A game that does not read for more
than eight seconds loses the oldest.
*Since:* 3.00.
[#micrecordstart]
==== micRecordStart
[source,text]
----
started = micRecordStart(name)
----
Writes everything heard from now on to a WAV file (one channel, 16-bit, 48000 a
second) until <<micrecordstop,`micRecordStop`>>. The name is any file the game
may write, and goes where the game's other written files go.
*Returns:* `true` when the file was opened.
*Since:* 3.00.
[#micrecordstop]
==== micRecordStop
[source,text]
----
micRecordStop()
----
Finishes the recording.
*Since:* 3.00.
[#micsetlatency]
==== micSetLatency
[source,text]
----
micSetLatency(milliseconds | nil)
----
Sets this machine's microphone latency, as a service menu does from a
measurement made some other way; `nil` forgets it. It is kept with the machine's
other settings.
*Parameters:*
* `milliseconds` -- number, `0` to `1000`, or `nil`.
*Since:* 3.00.
=== MIDI
Two unrelated things share this name, and it is worth keeping them apart.
@ -9166,6 +9473,40 @@ Every format the sound family reads is read here: WAV, Ogg Vorbis, Opus, FLAC, M
Pausing the engine (the pause key, or `singeSetPauseFlag`) pauses the music with it, and resuming brings it back; a reload stops and frees every track.
[#musicgetposition]
==== musicGetPosition
[source,text]
----
seconds = musicGetPosition(id)
----
Where the listener is in a playing track, in seconds: the mixer's own position, less what is still on its way to the speakers -- the device queue, the machine's audio calibration, and the game's audio delay -- so a picture timed to it lands with the sound. It is the clock to animate to when the music leads, as in a demo or a rhythm game, and it stays with the song however long it plays, where counting frames or ticks drifts. The mixer moves its position a buffer at a time, so between moves the answer runs on with the clock, never more than 50 ms, and it moves smoothly every frame. A paused track holds still. A track that is not playing answers `-1`.
*Parameters:*
* `id` -- number; a music handle.
*Returns:* number; seconds into the track, or `-1`.
*Since:* 3.00
*See also:* <<musicplay,musicPlay>>, <<singegetaudiolatency,singeGetAudioLatency>>, <<singegetaudiocalibration,singeGetAudioCalibration>>
.Example
[source,lua]
----
-- Flash on every beat of a 120 beat a minute song, on the beat as it is heard.
function onOverlayUpdate()
overlayClear()
local t = musicGetPosition(song)
if t >= 0 and (t * 2) % 1 < 0.1 then
colorBackground(255, 255, 255, 120)
overlayClear()
end
return OVERLAY_UPDATED
end
----
[#musicisplaying]
==== musicIsPlaying
@ -15102,7 +15443,7 @@ Hypseus extension, accepted and ignored. It decides whether Hypseus sets colour
spriteBlend(boolean, sprite)
----
Hypseus extension, accepted and ignored. It is a workaround for that engine: Hypseus loads sprites with SDL's blend mode off, and this turns it back on for one sprite. Singe draws every sprite with its alpha channel already, so blending is never off to begin with. The sprite handle is still checked, so a bad one is still an error.
Hypseus extension, accepted and ignored. It is a workaround for that engine: Hypseus loads sprites with SDL's blend mode off, and this turns it back on for one sprite. Singe draws every sprite with its alpha channel already, so blending is never off to begin with. The sprite handle is still checked, so a bad one is still an error. To add a sprite's light or multiply by it instead, see <<spritesetblend,`spriteSetBlend`>>.
*Returns:* nothing.
@ -15191,13 +15532,16 @@ end
[source,text]
----
spriteDrawGrid(id, x, y, sourceX, sourceY, width, height)
spriteDrawGrid(id, x, y, sourceX, sourceY, width, height, x2, y2)
----
Hypseus extension. Draws one rectangle out of a sprite, at its own size, with its top left corner at `x`, `y`. It is the only call that takes a source rectangle: <<spritedraw,`spriteDraw`>> stretches the whole sprite into a destination and <<spritedrawframe,`spriteDrawFrame`>> takes a whole frame of a sheet, while this takes any rectangle at all -- which is how a game cuts a tile, a digit or a letter out of one image without loading it many times over.
Hypseus extension. Draws one rectangle out of a sprite with its top left corner at `x`, `y`: at its own size, or, given `x2` and `y2`, stretched to fill the box out to that corner, the way <<spritedraw,`spriteDraw`>> stretches a whole sprite. It is the only call that takes a source rectangle: `spriteDraw` takes the whole sprite and <<spritedrawframe,`spriteDrawFrame`>> takes a whole frame of a sheet, while this takes any rectangle at all. That is how a game cuts a tile, a digit, or a letter out of one image without loading it many times over.
The rectangle is in the sprite's own pixels and must lie inside it; a rectangle that does not, or one with no width or height, terminates the script naming the sprite's size. The sprite drawn from is the sprite as it is currently drawn, so a <<spriterotate,`spriteRotate`>> or a <<spritescale,`spriteScale`>> changes what the coordinates mean.
The stretched form is Singe's own, and it is what a road-racing game is built on. Each row of the screen below the horizon is a slice of road at some distance, so the game cuts one row out of a picture of the road's cross-section and stretches it to the road's width at that distance, centred where the bends have carried it. A hundred-odd calls a frame draw the whole road, curves and hills included, and nothing is rebuilt. The same form draws a sprite partly hidden behind the crest of a hill: cut its top part out and stretch it to what is left showing. <<spritescale,`spriteScale`>> can do neither, since it rebuilds the whole picture each time the size changes.
Under `LEGACY_SPRITE_ARGS` the handle is third, `spriteDrawGrid(x, y, spr, sourceX, sourceY, width, height)`, which is where Hypseus puts it.
The rectangle is in the sprite's own pixels and must lie inside it; a rectangle that does not, or one with no width or height, terminates the script naming the sprite's size, and so does an `x2` with no `y2`. A stretch whose box has no width or height, because `x2` is left of `x` or `y2` above `y`, draws nothing. The stretch picks the nearest pixel rather than blending, which keeps a one-pixel row's colours exactly as drawn. The sprite drawn from is the sprite as it is currently drawn, so a <<spriterotate,`spriteRotate`>> or a `spriteScale` changes what the coordinates mean.
Under `LEGACY_SPRITE_ARGS` the handle is third, `spriteDrawGrid(x, y, spr, sourceX, sourceY, width, height)`, which is where Hypseus puts it, and `x2`, `y2` follow on the end as they do here.
*Parameters:*
@ -15205,10 +15549,11 @@ Under `LEGACY_SPRITE_ARGS` the handle is third, `spriteDrawGrid(x, y, spr, sourc
* `x`, `y` -- numbers; where the rectangle's top left corner lands, in overlay coordinates.
* `sourceX`, `sourceY` -- numbers; the rectangle's top left corner in the sprite.
* `width`, `height` -- numbers; its size, at least 1.
* `x2`, `y2` -- numbers, optional together; the bottom right corner of the box to stretch it into, inclusive.
*Returns:* nothing.
*Since:* 3.00, as a Hypseus Singe extension.
*Since:* 3.00, as a Hypseus Singe extension; the stretched form is new in 3.00 and Singe's own.
*See also:* <<spritedraw,spriteDraw>>, <<spritedrawframe,spriteDrawFrame>>
.Example
@ -15223,6 +15568,27 @@ local function drawNumber(sheet, x, y, value)
end
----
.Example
[source,lua]
----
-- A straight road to the horizon, one screen row at a time. road.png is 256 pixels across and two
-- rows deep: a light cross-section of road and a dark one, which alternate down the road to show
-- it moving. Row y of the screen is the ground EYE * FOCAL / (y - HORIZON) away.
local EYE = 60
local FOCAL = 160
local HORIZON = 110
local HALF = 100
local function drawRoad(road, travelled)
for y = HORIZON + 1, 239 do
local z = EYE * FOCAL / (y - HORIZON)
local half = HALF * FOCAL / z
local light = math.floor((z + travelled) / 60) % 2
spriteDrawGrid(road, 160 - half, y, 0, light, 256, 1, 160 + half, y)
end
end
----
[#spritedrawrotatedframe]
==== spriteDrawRotatedFrame
@ -15873,6 +16239,68 @@ function onOverlayUpdate()
end
----
[#spritesetalpha]
==== spriteSetAlpha
[source,text]
----
spriteSetAlpha(id, alpha)
----
Multiplies every pixel's alpha by `alpha / 255` on every draw of the sprite, in every form: `spriteDraw`, `spriteDrawFrame`, `spriteDrawGrid`, and `spriteDrawRotatedFrame`, turned or not. `255`, the default, draws it as it is and `0` not at all, so a logo fades in and out with no second image. Out-of-range values are clamped.
*Parameters:*
* `id` -- number; a sprite handle.
* `alpha` -- number; `0` to `255`.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<spritesetblend,spriteSetBlend>>, <<spritesettint,spriteSetTint>>
.Example
[source,lua]
----
-- The title fades in over two seconds.
spriteSetAlpha(title, math.floor(255 * math.min(1, elapsed / 2)))
spriteDraw(title, 120, 80)
----
[#spritesetblend]
==== spriteSetBlend
[source,text]
----
spriteSetBlend(id, SPRITE_ALPHA | SPRITE_ADD | SPRITE_MULTIPLY)
----
How every draw of the sprite lays it over what is already on the overlay. `SPRITE_ALPHA`, the default, draws it over by its alpha. `SPRITE_ADD` adds its colour, scaled by its alpha, to what is there, so it can only brighten: glows, light, sparks, and the shadebobs of the old demos, where a ball drawn over itself again and again burns to white. `SPRITE_MULTIPLY` darkens what is there by its colour, for shadows and tinted glass.
Added and multiplied pixels keep the overlay's own alpha, so they show only where something is already drawn on the overlay: over a transparent overlay, above a video or the 3D scene, they change nothing. Draw something opaque first, or use `emitterSetBlend` with `PARTICLE_ADD`, which adds over everything. Anything other than the three constants raises an error.
*Parameters:*
* `id` -- number; a sprite handle.
* `blend` -- `SPRITE_ALPHA`, `SPRITE_ADD`, or `SPRITE_MULTIPLY`.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<spritesetalpha,spriteSetAlpha>>, <<spritesettint,spriteSetTint>>, <<emittersetblend,emitterSetBlend>>
.Example
[source,lua]
----
-- Shadebobs: a soft white ball added along a path, never cleared, so the trail builds to white.
spriteSetBlend(ball, SPRITE_ADD)
spriteSetAlpha(ball, 40)
for i = 0, 15 do
local a = t * 2 + i * 0.4
spriteDraw(ball, 320 + math.cos(a) * 200, 240 + math.sin(a * 1.3) * 150, true)
end
----
[#spritesetframe]
==== spriteSetFrame
@ -15903,6 +16331,38 @@ function onOverlayUpdate()
end
----
[#spritesettint]
==== spriteSetTint
[source,text]
----
spriteSetTint(id, r, g, b)
----
Multiplies every pixel's colour by `r`, `g`, `b` / 255 on every draw of the sprite, in every form. A white sprite draws in any colour, and any sprite can be darkened or shifted toward a colour, with no second image: one white ball becomes a rainbow of them, and a one-pixel white sprite stretched with `spriteDraw` becomes a filled rectangle of any colour. `255, 255, 255`, the default, draws it as it is. Out-of-range values are clamped.
*Parameters:*
* `id` -- number; a sprite handle.
* `r`, `g`, `b` -- numbers; `0` to `255`.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<spritesetalpha,spriteSetAlpha>>, <<spritesetblend,spriteSetBlend>>
.Example
[source,lua]
----
-- A ring of balls, each its own colour, from one white picture.
for i = 1, 12 do
local hue = i / 12
spriteSetTint(ball, 128 + 127 * math.sin(hue * 6.28), 128 + 127 * math.sin(hue * 6.28 + 2.1), 128 + 127 * math.sin(hue * 6.28 + 4.2))
spriteDraw(ball, 320 + math.cos(hue * 6.28) * 150, 240 + math.sin(hue * 6.28) * 150, true)
end
spriteSetTint(ball, 255, 255, 255)
----
[#spriteunload]
==== spriteUnload
@ -18379,6 +18839,240 @@ discSearch(TITLE_FRAME)
----
[#enginecallbacks]
[#voice]
=== Voice
The pieces a voice chat is built from, without the network: Opus to make a
voice small enough to send and back again, a processor that cleans a microphone
up before it is sent, and a jitter buffer to play what arrives late and out of
order in the right order. Sending the packets and finding the other players are
a game's own business, or a later version of Singe's.
Samples are strings of 16-bit values, two bytes each, little endian, as
<<micread,`micRead`>> gives them; with two channels they alternate left and
right. A *frame* is a fixed slice of time: 20 milliseconds, 960 samples at
48000, is the usual one for a voice.
Every object here is a handle. <<voicefree,`voiceFree`>> releases one; all of
them are released when the game ends.
.A microphone, cleaned, encoded, and decoded again
[source,lua]
----
micOpen()
local processor = voiceProcessorNew()
local encoder = opusEncoderNew()
local decoder = opusDecoderNew()
local pending = ""
function onOverlayUpdate()
pending = pending .. micRead()
while #pending >= 960 * 2 do
local frame = pending:sub(1, 960 * 2)
pending = pending:sub(960 * 2 + 1)
local cleaned, voiced = voiceProcess(processor, frame)
if voiced then
local packet = opusEncode(encoder, cleaned) -- this is what you send
local heard = opusDecode(decoder, packet) -- and this is what the other end plays
end
end
return OVERLAY_UPDATED
end
----
[#jitterget]
==== jitterGet
[source,text]
----
packet, status = jitterGet(jitter [, span])
----
The packet due to be played now, and the buffer moved on by one step. Call it
once a frame's worth of time, whatever arrived.
*Returns:* the packet, or `nil`, and a status: `"ok"`, `"missing"` (nothing is
due: hand `nil` to <<opusdecode,`opusDecode`>> to cover the gap), or
`"insertion"` (the buffer is growing to absorb late packets: play a little
silence).
*Since:* 3.00.
[#jitternew]
==== jitterNew
[source,text]
----
jitter = jitterNew([step])
----
A jitter buffer: packets go in by their timestamp as they arrive, and come out
in order, one step at a time, a little behind so a late one still makes it.
*Parameters:*
* `step` -- number, the samples each <<jitterget,`jitterGet`>> moves on; `960`
unless given.
*Returns:* the jitter buffer's handle.
*Since:* 3.00.
[#jitterput]
==== jitterPut
[source,text]
----
jitterPut(jitter, packet, timestamp [, span])
----
A packet that arrived. The buffer keeps its own copy.
*Parameters:*
* `packet` -- string, 1 to 4000 bytes.
* `timestamp` -- number, where the packet starts, in samples: the sender counts
them, `0`, `960`, `1920`, and so on.
* `span` -- number, the samples the packet covers; the step unless given.
*Since:* 3.00.
[#opusdecode]
==== opusDecode
[source,text]
----
samples = opusDecode(decoder, packet | nil)
----
A packet back to samples. `nil` for a packet that never arrived asks Opus to
cover the gap with something plausible, as long as the last packet.
*Returns:* the samples.
*Since:* 3.00.
[#opusdecodernew]
==== opusDecoderNew
[source,text]
----
decoder = opusDecoderNew([rate [, channels]])
----
*Parameters:*
* `rate` -- number: 8000, 12000, 16000, 24000, or 48000; `48000` unless given.
* `channels` -- number, `1` or `2`; `1` unless given.
*Returns:* the decoder's handle.
*Since:* 3.00.
[#opusencode]
==== opusEncode
[source,text]
----
packet = opusEncode(encoder, samples)
----
One frame of samples as one packet. A frame is 2.5, 5, 10, 20, 40, or 60
milliseconds of samples; anything else is refused.
*Returns:* the packet, a string.
*Since:* 3.00.
[#opusencodernew]
==== opusEncoderNew
[source,text]
----
encoder = opusEncoderNew([rate [, channels [, application]]])
----
*Parameters:*
* `rate` -- number: 8000, 12000, 16000, 24000, or 48000; `48000` unless given.
* `channels` -- number, `1` or `2`; `1` unless given.
* `application` -- string: `"voip"` for speech, tuned for being understood;
`"audio"` for music; `"lowdelay"` for the least delay at some cost in quality.
`"voip"` unless given.
*Returns:* the encoder's handle.
*Since:* 3.00.
[#opusencoderset]
==== opusEncoderSet
[source,text]
----
opusEncoderSet(encoder, bitrate [, complexity])
----
*Parameters:*
* `bitrate` -- number, bits a second; `0` leaves it as it is. A voice is clear
at 16000 to 24000.
* `complexity` -- number, `0` to `10`: how much work to spend for quality.
*Since:* 3.00.
[#voicefree]
==== voiceFree
[source,text]
----
voiceFree(handle)
----
Releases an Opus encoder or decoder, a voice processor, or a jitter buffer.
*Since:* 3.00.
[#voiceprocess]
==== voiceProcess
[source,text]
----
cleaned, voiced = voiceProcess(processor, mic [, speaker])
----
One frame of the microphone, cleaned: the echo of what the speakers were playing
taken out, background noise suppressed, and the level evened out.
*Parameters:*
* `mic` -- string, one frame of samples from the microphone.
* `speaker` -- string, the same frame of what the speakers played, if anything:
the voices of the other players, say. The echo of it is what is taken out.
*Returns:* the cleaned frame, and `true` when it sounds like a voice -- the way
to send nothing while nobody is talking.
*Since:* 3.00.
[#voiceprocessornew]
==== voiceProcessorNew
[source,text]
----
processor = voiceProcessorNew([frame [, rate [, tailMs]]])
----
*Parameters:*
* `frame` -- number, samples a call; `960` unless given.
* `rate` -- number; `48000` unless given.
* `tailMs` -- number, the longest echo to cancel, in milliseconds: how long a
sound rings around the room. `200` unless given.
*Returns:* the processor's handle.
*Since:* 3.00.
=== Engine Callbacks
These are functions the script defines and Singe calls when the matching event happens; all are optional, and an undefined one is skipped. Input callbacks fire as events are read, `onOverlayUpdate` runs at most once every 15 milliseconds, and none of them runs while the pause key holds the game frozen (see <<pausing,Pausing>>). An error inside a callback ends the game with a traceback. In the threaded model `singeMain` takes the place of most per-frame logic (see <<threaded,Threaded>>): the other callbacks still fire, but `Framework.singe` installs its own `onOverlayUpdate` to drive the `singeMain` coroutine, so a threaded game defines `singeMain`, `onShutdown` and whichever input callbacks it wants.
@ -18695,7 +19389,7 @@ function onOverlayUpdate()
end
----
The one callback where drawing is allowed. Singe calls it at most once every 15 milliseconds while the game is not frozen; any `overlay*`, `spriteDraw`, `videoDraw`, `fontPrint` or similar call made elsewhere will not appear on screen and may corrupt the overlay. Return `OVERLAY_UPDATED` (`1`) when the overlay changed so that Singe composites it over the video again, or `OVERLAY_NOT_UPDATED` (`0`) when nothing changed so the previous composite is reused; returning nothing counts as `OVERLAY_NOT_UPDATED`. The display is redrawn regardless whenever a new video frame arrives or a 3D scene is enabled, so the return value is an optimization for static screens. After it returns the engine clears the values behind `keyboardGetLastDown` and `keyboardGetLastUp`. In the threaded model `Framework.singe` defines this function itself to resume `singeMain` and always returns `OVERLAY_UPDATED`; do not define it as well.
The one callback where drawing is allowed. Singe calls it at most once every 15 milliseconds while the game is not frozen, counted from when the previous call began, so the time the script itself takes falls inside those 15 milliseconds rather than being added to them; any `overlay*`, `spriteDraw`, `videoDraw`, `fontPrint` or similar call made elsewhere will not appear on screen and may corrupt the overlay. Return `OVERLAY_UPDATED` (`1`) when the overlay changed so that Singe composites it over the video again, or `OVERLAY_NOT_UPDATED` (`0`) when nothing changed so the previous composite is reused; returning nothing counts as `OVERLAY_NOT_UPDATED`. The display is redrawn regardless whenever a new video frame arrives or a 3D scene is enabled, so the return value is an optimization for static screens. After it returns the engine clears the values behind `keyboardGetLastDown` and `keyboardGetLastUp`. In the threaded model `Framework.singe` defines this function itself to resume `singeMain` and always returns `OVERLAY_UPDATED`; do not define it as well.
*Returns:* `OVERLAY_UPDATED` or `OVERLAY_NOT_UPDATED`.

BIN
docs/images/learn/29-databases.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,133 @@
-- Lesson 29: Keeping Data. The finished script, exactly as the lesson ends.
dofile("Singe/Framework.singe")
local sqlite3 = require("sqlite3")
local PERFECT = 1000
-- What ships with the game: the stages, read once and closed.
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
-- What the game writes: every run anybody plays, kept in the data directory.
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local stageAt = 1
local state = "attract"
local goAt = 0
local score = 0
local best = {}
local perStage = {}
local played = 0
-- The questions, asked when a run is added rather than every frame.
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
perStage = {}
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
perStage[#perStage + 1] = row
end
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
end
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
refresh()
end
local function play()
local stage = stages[stageAt]
state = "waiting"
goAt = singeGetTicks() + math.random(stage.waitMin, stage.waitMax)
score = 0
end
function onInputPressed(what)
if what == SWITCH_BUTTON2 and state ~= "waiting" and state ~= "go" then
stageAt = stageAt % #stages + 1
return
end
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
elseif state == "go" then
score = math.max(0, PERFECT - (singeGetTicks() - goAt))
state = "result"
record(stages[stageAt].name, score)
else
play()
end
end
function onOverlayUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST: " .. stages[stageAt].name)
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play, button 2 for another stage.")
elseif state == "waiting" then
overlayPrint(2, 2, "Wait for it...")
elseif state == "go" then
overlayPrint(2, 2, "NOW!")
else
overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.")
end
overlayPrint(2, 4, "Runs kept: " .. played)
overlayPrint(2, 6, "BEST RUNS")
for place, row in ipairs(best) do
overlayPrint(2, 6 + place, string.format("%d. %-8s %4d", place, row.stage, row.score))
end
overlayPrint(24, 6, "BY STAGE")
for line, row in ipairs(perStage) do
overlayPrint(24, 6 + line, string.format("%-8s best %4d of %d", row.stage, row.top, row.tries))
end
return OVERLAY_UPDATED
end
function onShutdown()
addRun:finalize()
runs:close()
end
refresh()

View file

@ -1,4 +1,4 @@
-- Lesson 29: Shipping It. The finished script, exactly as the lesson ends.
-- Lesson 30: Shipping It. The finished script, exactly as the lesson ends.
dofile("Singe/Framework.singe")

View file

@ -1,4 +1,4 @@
-- Lesson 30: In A Cabinet. The finished script, exactly as the lesson ends.
-- Lesson 31: In A Cabinet. The finished script, exactly as the lesson ends.
dofile("Singe/Framework.singe")

View file

@ -11,8 +11,10 @@ will find these files where they say they are.
walk.png 96 x 32 Four frames of a walk cycle, each 24 x 32, side by side.
shoot.wav A shot.
boom.wav An explosion.
stages.db The stages of lesson twenty-nine's reaction test, a SQLite
database: stages(id, name, waitMin, waitMax).
All of it is drawn and synthesised by util/learnKit.py rather than painted, so
All of it is drawn, synthesised, or written by util/learnKit.py rather than painted, so
it is part of Singe and carries Singe's licence. Use it in your own games,
change it, or replace it with something better as soon as you can.

BIN
docs/learn/art/stages.db Normal file

Binary file not shown.

View file

@ -297,7 +297,7 @@ when you want to print the whole save while you are hunting something.
It does not go beside your script, and this is not an accident.
A finished game may live on a disc, on a network share, or inside a single
packed file (lesson twenty-nine), and none of those can be written to. A game
packed file (lesson thirty), and none of those can be written to. A game
that saves next to its own script works perfectly on the machine you wrote it
on and fails on half the machines it is installed on. Singe 2 did it that way
and it was wrong.

View file

@ -212,7 +212,7 @@ finished Singe game can be packed into a single `.game` file that the engine
runs directly, artwork and sounds and all, which a player copies next to Singe
and is done.
Nothing in your game has to change for that. It is lesson twenty-nine, and it
Nothing in your game has to change for that. It is lesson thirty, and it
is worth waiting for: pack a game that is not finished and you will pack it
again tomorrow.
@ -305,7 +305,7 @@ folder. Put the `DIR ..` back and start it from both places.
folder beside it, sorted by title.
* A game launched by name gets its `games.dat` entry too, so it behaves the
same either way.
* A finished game can be packed into one file later, in lesson twenty-nine.
* A finished game can be packed into one file later, in lesson thirty.
=== Next Time

View file

@ -472,7 +472,7 @@ appearing to have died.
*No mouse at all.* A cabinet with a joystick and no pointing device can drive
the cursor from the stick, which `joyMouseEnable` turns on.
None of that changes the game you wrote. All of it is lesson thirty.
None of that changes the game you wrote. All of it is lesson thirty-one.
=== Try It

View file

@ -216,7 +216,7 @@ has a game has it.
Your game does not call any of this, and should not. The catalogue matters to
you as the author of a game rather than as the writer of a script: it is where
your `.game` file goes when you publish it, which is lesson twenty-nine, and it
your `.game` file goes when you publish it, which is lesson thirty, and it
is another reason your `games.dat` needs a `GAME_ID` that never changes. If
you ever do want the list from inside a script, `Singe/Master.singe` is the
module underneath all of this and is worth reading once.
@ -527,6 +527,7 @@ in `Singe/Framework.singe`.
=== Next Time
Your game runs on your machine, out of a folder you have been editing for
twenty-eight lessons. Lesson twenty-nine turns that folder into one file a
stranger can copy onto their own machine and play.
A high score is one number, and lesson twelve's save is the right place for it.
Lesson twenty-nine is for everything else a game wants to keep: every round
anybody plays, the stages you ship, and the questions you want to ask of both.
It takes this reaction test and gives it a database.

View file

@ -0,0 +1,488 @@
== Lesson 29: Keeping Data
image::learn/29-databases.png[The finished lesson, 480]
Lesson twelve saved a high score: one number, under one name, there the next
morning. That is exactly right for a high score, and wrong for almost
everything else a game wants to keep. A list of every round anybody has played.
The best time on each of twenty tracks. Forty levels somebody designed in a
spreadsheet. The question "what is my best on the hard stage?", which a pile of
saved numbers cannot answer without you writing the answer by hand.
That is what a database is for, and Singe has one built in. This lesson takes
the reaction test from lesson twenty-eight, gives it stages that ship with the
game, and has it keep every run anybody plays and answer questions about them.
=== What a Database Is
A database is a file of *tables*. A table is rows and columns, like a
spreadsheet with the column names decided in advance: a table of runs might
have a stage, a score, and a time for every row. You put rows in and you ask
questions to get rows out, and the questions are written in *SQL*, a small
language that has been doing this job since before most programming languages
you have heard of.
The database Singe carries is *SQLite*. It is not a server you install or a
service you sign up for. It is a library, the database is one ordinary file,
and the same SQLite is in your phone, your browser, and probably your car. You
do not have to know SQL to follow this lesson; every statement in it is
explained as it goes past, and the three you will use most -- `CREATE TABLE`,
`INSERT`, and `SELECT` -- are all here.
=== Opening One
[source,lua]
----
local sqlite3 = require("sqlite3")
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
`require("sqlite3")` loads a library that comes with Singe and hands it back as
a table, so everything SQLite can do is `sqlite3.` something. `sqlite3.open`
opens a database file and
creates it if it is not there yet, so the first run of your game makes an
empty one and every run after that finds it again.
The file goes in `singeGetDataPath()`, and for the same reason lesson twelve's
save does: a game's own folder may be on a disc, on a share, or packed into one
file, and none of those can be written to. Everything a game writes goes in its
data directory.
=== Making a Table
An empty database has no tables. You make one by describing it:
[source,lua]
----
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
----
`exec` runs SQL that does not hand anything back. The double square brackets
are Lua's long string: everything between them, newlines included, is one
string, which is how you keep SQL readable.
`CREATE TABLE` names the table and its columns, each with a type: `TEXT` for
words and `INTEGER` for whole numbers. `NOT NULL` means every row must have a
value there. `INTEGER PRIMARY KEY` is special: SQLite fills it in by itself,
counting up, so every row gets its own number without you thinking about it.
`IF NOT EXISTS` is what makes this safe to run every time the game starts. The
first time, it makes the table. Every time after that, the table is already
there and the line does nothing. Without those three words the second run of
your game would stop with an error saying the table already exists.
=== Putting a Row In
You could build the SQL for a new row by gluing strings together:
[source,lua]
----
runs:exec("INSERT INTO runs (stage, score, at) VALUES ('" .. stage .. "', " .. score .. ", " .. os.time() .. ")")
----
Do not. It works until a name has an apostrophe in it, at which point the quote
ends the string early and the statement is nonsense. In a game that lets
players type, it gets worse: whatever they type becomes part of your SQL.
The right way is to write the statement once, with question marks where the
values go, and hand the values over separately:
[source,lua]
----
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
end
----
`prepare` turns the SQL into a *statement* that can be used again and again.
`bind_values` puts a value in each question mark, in order. Whatever a value
contains, apostrophes included, it is a value and never part of the SQL. `step`
runs the statement, and `reset` makes it ready to run again. `os.time()` is
plain Lua: the current time as a number of seconds, which is the easiest way to
store "when".
Preparing once and reusing the statement is also faster, because SQLite works
out how to run it only once. For a game that writes one row a minute that does
not matter. For a level editor that writes a thousand, it does.
=== Asking Questions
Rows come back out with `nrows`, which runs a `SELECT` and hands you each row as
a table with the column names as keys:
[source,lua]
----
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC LIMIT 5") do
debugPrint(row.stage .. " " .. row.score)
end
----
Read the SQL left to right. `SELECT stage, score` says which columns you want.
`FROM runs` says which table. `ORDER BY score DESC` sorts the rows by score,
highest first, and `LIMIT 5` stops after five. That is a high score table, and
it never needed sorting code of yours.
The questions can be cleverer than the ones you would want to write by hand:
[source,lua]
----
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
debugPrint(row.stage .. ": best " .. row.top .. " of " .. row.tries)
end
----
`GROUP BY stage` gathers the rows into one group per stage, and `MAX(score)` and
`COUNT(*)` are worked out for each group: the best score on that stage and how
many times it has been played. `AS top` names the answer, which is the key you
read it back with.
When a question has one answer, `urows` hands back the columns themselves
rather than a table, which is tidier for a count:
[source,lua]
----
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
----
=== Ask When It Changes
None of those questions belong in `onOverlayUpdate`. A query is fast, but sixty
of them a second, to get answers that change once a minute, is the game asking
the same question and getting the same answer fifty-nine times out of sixty.
So the game asks when the answer can have changed -- once at the start, and
again each time a run is recorded -- and keeps what came back in ordinary
tables:
[source,lua]
----
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
-- and the same for the other two questions
end
----
`onOverlayUpdate` draws from `best`, which costs nothing. `record` calls
`refresh` after every new row, so the table on screen is never out of date.
=== Data That Ships With the Game
The other half of a game's data is the part you write before anybody plays:
the stages, the levels, the items, the lines of dialogue. That can be a
database too, made with any SQLite tool -- the `sqlite3` program on the
command line, or a program with windows such as DB Browser for SQLite -- and put
in your game's folder beside its art. The art kit has one, `stages.db`, with a
name and the shortest and longest wait for each stage.
A game opens that one differently:
[source,lua]
----
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
----
`DIR` is your game's folder, where the art is. `sqlite3.OPEN_READONLY` says you
only mean to read it, and that matters for the day you ship. When you pack your
game into one file in the next lesson, a database opened read only is read
straight out of that file, with nothing unpacked. One opened for writing is
copied, once, to the game's data directory, and the copy is what you change from
then on; the file you shipped is never written.
So keep the two kinds apart. What you made goes in your folder and is opened
read only. What the game makes goes in the data directory. The stages here are
read once at the start into an ordinary table and the database is closed,
because a list of four stages is not worth keeping a file open for.
=== Closing Up
Statements and databases hold memory and a file open until you say you are
finished, and the tidy place to say it is `onShutdown`:
[source,lua]
----
function onShutdown()
addRun:finalize()
runs:close()
end
----
`finalize` is `close` for a statement. A database will not close while a
statement made from it is still around, so the statements go first.
=== The Whole Script
[source,lua]
----
dofile("Singe/Framework.singe")
local sqlite3 = require("sqlite3")
local PERFECT = 1000
-- What ships with the game: the stages, read once and closed.
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
-- What the game writes: every run anybody plays, kept in the data directory.
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local stageAt = 1
local state = "attract"
local goAt = 0
local score = 0
local best = {}
local perStage = {}
local played = 0
-- The questions, asked when a run is added rather than every frame.
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
perStage = {}
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
perStage[#perStage + 1] = row
end
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
end
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
refresh()
end
local function play()
local stage = stages[stageAt]
state = "waiting"
goAt = singeGetTicks() + math.random(stage.waitMin, stage.waitMax)
score = 0
end
function onInputPressed(what)
if what == SWITCH_BUTTON2 and state ~= "waiting" and state ~= "go" then
stageAt = stageAt % #stages + 1
return
end
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
elseif state == "go" then
score = math.max(0, PERFECT - (singeGetTicks() - goAt))
state = "result"
record(stages[stageAt].name, score)
else
play()
end
end
function onOverlayUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST: " .. stages[stageAt].name)
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play, button 2 for another stage.")
elseif state == "waiting" then
overlayPrint(2, 2, "Wait for it...")
elseif state == "go" then
overlayPrint(2, 2, "NOW!")
else
overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.")
end
overlayPrint(2, 4, "Runs kept: " .. played)
overlayPrint(2, 6, "BEST RUNS")
for place, row in ipairs(best) do
overlayPrint(2, 6 + place, string.format("%d. %-8s %4d", place, row.stage, row.score))
end
overlayPrint(24, 6, "BY STAGE")
for line, row in ipairs(perStage) do
overlayPrint(24, 6 + line, string.format("%-8s best %4d of %d", row.stage, row.top, row.tries))
end
return OVERLAY_UPDATED
end
function onShutdown()
addRun:finalize()
runs:close()
end
refresh()
----
Run it. Button 1 starts a round and stops it, as in lesson twenty-eight, and
button 2 between rounds moves to the next stage. Play a few on each. Then quit
and run it again: every run is still there, the best five at the left and each
stage's best at the right.
=== What Just Happened
[source,lua]
----
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
Two files are in play, and they are not alike. `art/stages.db` is yours: you
wrote it, it ships with the game, and the game only reads it. `runs.db` is the
game's: it did not exist until the first run made it, it lives in the data
directory, and nobody ships it.
[source,lua]
----
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
----
A button pressed too soon still records a run, with a score of nothing. The
database is a record of what happened, and a jumped start happened. Whether to
show those rows is a question for the `SELECT`, not something to hide by never
writing them.
[source,lua]
----
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
----
`ORDER BY score DESC, id` sorts by score, and where two scores are equal, by
`id` -- the order they were played. Without the second key, two equal scores
come back in whatever order SQLite finds convenient, which can change between
runs. A table that reshuffles itself for no visible reason looks broken even
when it is not.
=== Try It
. *Show the date.* `os.date("%Y-%m-%d", row.at)` turns a stored time back into
something a person can read. Add the date to each of the best runs.
. *Ignore the jumped starts.* Add `WHERE score > 0` to the best-runs question,
between `FROM runs` and `ORDER BY`. Then count the jumped starts on each stage
with a question of your own.
. *Add a stage.* Open `stages.db` in a SQLite tool, add a row, and run the game.
Nothing in the script changes. That is the point of keeping data out of the
script.
. *Reset the history.* `runs:exec("DELETE FROM runs")` empties the table. Put it
on a button, call `refresh` after it, and watch everything go to nothing.
This is the "clear high scores" in a service menu.
. *Write a thousand rows.* Loop `record` a thousand times and time it with
`singeGetTicks`. Then put `runs:exec("BEGIN")` before the loop and
`runs:exec("COMMIT")` after it, and time it again. A *transaction* makes many
writes one, and the difference is large enough to be worth remembering.
=== Break It on Purpose
Take `addRun:reset()` out of `record` and play three rounds on three different
stages.
No error. The game plays perfectly. But the table at the right says you played
the first stage three times, and the best runs are three copies of your first
score. `bind_values` could not put new values into a statement that had already
run and not been reset, so it gave up -- quietly, returning a number you were
not checking -- and `step` ran the statement again with the values it already
had.
This is the bug that matters in this lesson, because it does not look like one.
A query that fails is loud. A write that writes the wrong thing is silent, and
you find it a week later in the data. When the rows in a database are not what
you expect, look at the code that put them there before the code that reads
them.
Put `reset` back, and break it the loud way: change `score` to `scroe` in the
first `SELECT` in `refresh`.
----
Error running script: no such column: scroe
stack traceback:
[C]: in method 'nrows'
29-databases.singe:43: in local 'refresh'
29-databases.singe:133: in main chunk
----
SQLite reads the column names when it prepares the question, so a misspelled
one stops the game at once, and the message names it exactly. The traceback
reads from the inside out: `nrows` complained, `refresh` called it at line 43,
and the script itself called `refresh` at line 133, the last line, as the game
started. That is why it says *running script* rather than naming a function
the engine called: it never got as far as the first frame.
=== What You Learned
* A database is a file of tables; a table is rows with named columns.
* `require("sqlite3")` gives you SQLite. `sqlite3.open` opens a file and creates
it if it is missing.
* What the game writes goes in `singeGetDataPath()`. What you ship goes in your
folder, opened with `sqlite3.OPEN_READONLY`.
* `CREATE TABLE IF NOT EXISTS` is safe to run every time the game starts.
* Values go in through `prepare`, `bind_values`, `step`, and `reset`, never by
gluing strings into SQL.
* `nrows` hands back rows as tables; `urows` hands back the columns themselves.
* `ORDER BY`, `LIMIT`, `GROUP BY`, `MAX`, and `COUNT` answer the questions you
would otherwise write loops for.
* Ask when the answer can change, not every frame.
* Statements are finalized, then the database is closed, in `onShutdown`.
=== Next Time
Your game now has a folder full of things: a script, art, sounds, and a
database or two. Lesson thirty turns that folder into one file a stranger can
copy onto their own machine and play -- and your read-only database goes with
it, still opening exactly as it does now.

View file

@ -1,6 +1,6 @@
== Lesson 29: Shipping It
== Lesson 30: Shipping It
image::learn/29-shipping.png[The finished lesson, 480]
image::learn/30-shipping.png[The finished lesson, 480]
You have a folder. It has a script in it, some pictures, a few sounds, and the
`games.dat` you wrote in lesson thirteen. It runs, because everything it needs

View file

@ -1,6 +1,6 @@
== Lesson 30: In a Cabinet
== Lesson 31: In a Cabinet
image::learn/30-cabinet.png[The finished lesson, 480]
image::learn/31-cabinet.png[The finished lesson, 480]
There is a difference between a game and an arcade machine, and it is not the
wood. A machine switches itself on in the morning, plays to an empty room, takes
@ -683,7 +683,7 @@ turn the quit key off, never later.
=== The End
That is thirty lessons, and you can write a game.
That is thirty-one lessons, and you can write a game.
Not a perfect one, and not a large one yet, but the gap between here and
either of those is time and practice rather than another book. You started

View file

@ -59,6 +59,7 @@
#include "main.h"
#include "midi.h"
#include "mic.h"
#include "midiIo.h"
#include "stddclmr.h"
#include "util.h"
@ -2653,6 +2654,7 @@ int main(int argc, char *argv[]) {
_stopSDL();
midiIoQuit();
micQuit();
midiQuit();
vfsQuit();
free(_commandLine);

671
src/mic.c Normal file
View file

@ -0,0 +1,671 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
// Singe: the microphone. SDL hands captured audio to _micCaptured on its own thread; that keeps
// the newest MIC_RING_SECONDS in a ring, the level of each block, and -- while calibrating -- the
// moment a click came back. Everything else runs on the engine's thread from the ring: reading,
// pitch, recording, and stepping the calibration. The speaker stream (monitoring and the clicks) is
// a second SDL playback stream beside the mixer, which SDL mixes onto the same output.
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <SDL3/SDL.h>
#include "mic.h"
#include "util.h"
#define MIC_RING_SAMPLES (MIC_RATE * MIC_RING_SECONDS)
#define MIC_BLOCK_SAMPLES 1024 // Read from SDL this many at a time
#define MIC_RECORD_SAMPLES 4096 // Written to a recording this many at a time
#define MIC_MONITOR_QUEUE_MS 100 // More than this waiting to be heard is dropped: the singer would hear the lag
#define MIC_PITCH_WINDOW 2048 // Newest samples the pitch is found in (43 ms)
#define MIC_PITCH_DECIMATE 2 // ...halved to 24 kHz first, a quarter of the work
#define MIC_PITCH_MIN_HZ 50.0f // Below any singer
#define MIC_PITCH_MAX_HZ 2000.0f // Above any singer's fundamental
#define MIC_PITCH_THRESHOLD 0.15f // YIN's dip that counts as periodic
#define MIC_PITCH_SILENCE 0.01f // RMS under which nothing is voiced
#define MIC_CLICKS 5 // Clicks a calibration plays; the median is the answer
#define MIC_CLICKS_NEEDED 3 // Heard back at least this many times, or it failed
#define MIC_CLICK_SAMPLES 96 // 2 ms
#define MIC_CLICK_LEVEL 0.9f
#define MIC_CLICK_GAP_NS (400 * 1000000LL)
#define MIC_CLICK_TIMEOUT_NS (1000 * 1000000LL)
#define MIC_FLOOR_NS (300 * 1000000LL) // Quiet listened to before the first click
#define MIC_FLOOR_FACTOR 6.0f // A click is this much louder than the quiet
#define MIC_THRESHOLD_MIN 0.05f
#define NS_PER_MS 1000000LL
#define NS_PER_SECOND 1000000000LL
#define WAV_HEADER_BYTES 44
typedef struct MicS {
SDL_AudioStream *capture;
SDL_AudioStream *speaker;
SDL_Mutex *lock; // The ring, the level, and what the callback reports
SDL_AudioDeviceID *devices;
int32_t deviceCount;
float *ring;
int64_t written; // Samples ever heard; the ring holds the newest of them
int64_t readAt; // micRead's place
int64_t recordAt; // The recording's place
FILE *record;
int64_t recordBytes;
float rms;
float peak;
bool monitoring;
float monitorVolume;
MicCalibrationE calibration;
int32_t clicksPlayed;
int32_t results[MIC_CLICKS];
int32_t resultCount;
int64_t clickAtNs; // When the click will leave the speaker
int64_t heardAtNs; // When the callback heard it, 0 until then
int64_t nextClickNs;
int64_t timeoutNs;
int64_t floorUntilNs;
float floorPeak;
float threshold;
bool listening; // A click is out and has not been heard
bool measuringFloor;
int32_t latency;
} MicT;
static void SDLCALL _micCaptured(void *userData, SDL_AudioStream *stream, int32_t additional, int32_t total);
static int32_t _micCompareInt(const void *a, const void *b);
static void _micRecordWrite(void);
static bool _micSpeakerOpen(void);
static int16_t _micToInt16(float sample);
static void _micWavHeader(FILE *file, int64_t dataBytes);
static void _micWrite16(FILE *file, uint16_t value);
static void _micWrite32(FILE *file, uint32_t value);
static MicT _mic = { .monitorVolume = 1.0f, .latency = MIC_LATENCY_UNKNOWN };
// SDL's thread: everything newly heard, into the ring, with its level, the monitor fed, and a click
// noticed if one is being listened for.
static void SDLCALL _micCaptured(void *userData, SDL_AudioStream *stream, int32_t additional, int32_t total) {
float block[MIC_BLOCK_SAMPLES];
float scaled[MIC_BLOCK_SAMPLES];
int32_t bytes = 0;
int32_t count = 0;
int32_t i = 0;
int64_t now = 0;
int64_t at = 0;
double squares = 0.0;
float peak = 0.0f;
float value = 0.0f;
(void)userData;
(void)additional;
(void)total;
for (;;) {
bytes = SDL_GetAudioStreamData(stream, block, (int32_t)sizeof(block));
if (bytes <= 0) {
break;
}
count = bytes / (int32_t)sizeof(float);
now = (int64_t)SDL_GetTicksNS();
squares = 0.0;
peak = 0.0f;
for (i = 0; i < count; i++) {
value = fabsf(block[i]);
squares += (double)block[i] * (double)block[i];
if (value > peak) {
peak = value;
}
}
SDL_LockMutex(_mic.lock);
for (i = 0; i < count; i++) {
_mic.ring[(_mic.written + i) % MIC_RING_SAMPLES] = block[i];
}
_mic.written += count;
_mic.rms = (float)sqrt(squares / (double)count);
_mic.peak = peak;
if (_mic.measuringFloor && (peak > _mic.floorPeak)) {
_mic.floorPeak = peak;
}
if (_mic.listening && (_mic.heardAtNs == 0)) {
for (i = 0; i < count; i++) {
if (fabsf(block[i]) >= _mic.threshold) {
// The block ends now; sample i was heard that many samples earlier.
at = now - ((int64_t)(count - i) * NS_PER_SECOND) / MIC_RATE;
if (at > _mic.clickAtNs) {
_mic.heardAtNs = at;
break;
}
}
}
}
if (_mic.monitoring && (_mic.speaker != NULL)) {
// A queue longer than a singer can bear is dropped rather than heard late.
if (SDL_GetAudioStreamQueued(_mic.speaker) > (int32_t)((MIC_RATE * MIC_MONITOR_QUEUE_MS / 1000) * sizeof(float))) {
SDL_ClearAudioStream(_mic.speaker);
}
for (i = 0; i < count; i++) {
scaled[i] = block[i] * _mic.monitorVolume;
}
SDL_PutAudioStreamData(_mic.speaker, scaled, count * (int32_t)sizeof(float));
}
SDL_UnlockMutex(_mic.lock);
}
}
bool micCalibrate(void) {
if ((_mic.capture == NULL) || !_micSpeakerOpen()) {
return false;
}
SDL_LockMutex(_mic.lock);
_mic.calibration = MIC_CALIBRATION_RUNNING;
_mic.clicksPlayed = 0;
_mic.resultCount = 0;
_mic.listening = false;
_mic.heardAtNs = 0;
_mic.floorPeak = 0.0f;
_mic.measuringFloor = true;
_mic.floorUntilNs = (int64_t)SDL_GetTicksNS() + MIC_FLOOR_NS;
SDL_UnlockMutex(_mic.lock);
return true;
}
MicCalibrationE micCalibrationState(void) {
return _mic.calibration;
}
static int32_t _micCompareInt(const void *a, const void *b) {
return *(const int32_t *)a - *(const int32_t *)b;
}
void micClose(void) {
// The capture stream goes first, so the callback is finished with everything below.
if (_mic.capture != NULL) {
SDL_DestroyAudioStream(_mic.capture);
_mic.capture = NULL;
}
micRecordStop();
if (_mic.speaker != NULL) {
SDL_DestroyAudioStream(_mic.speaker);
_mic.speaker = NULL;
}
free(_mic.ring);
_mic.ring = NULL;
_mic.written = 0;
_mic.readAt = 0;
_mic.rms = 0.0f;
_mic.peak = 0.0f;
_mic.monitoring = false;
_mic.listening = false;
if (_mic.calibration == MIC_CALIBRATION_RUNNING) {
_mic.calibration = MIC_CALIBRATION_FAILED;
}
}
int32_t micDeviceCount(void) {
int count = 0;
SDL_free(_mic.devices);
_mic.devices = SDL_GetAudioRecordingDevices(&count);
_mic.deviceCount = (_mic.devices != NULL) ? count : 0;
return _mic.deviceCount;
}
const char *micDeviceName(int32_t index) {
if ((index < 0) || (index >= _mic.deviceCount)) {
return NULL;
}
return SDL_GetAudioDeviceName(_mic.devices[index]);
}
bool micIsOpen(void) {
return (_mic.capture != NULL);
}
int32_t micLatency(void) {
return _mic.latency;
}
void micLevel(float *rms, float *peak) {
if (_mic.capture == NULL) {
*rms = 0.0f;
*peak = 0.0f;
return;
}
SDL_LockMutex(_mic.lock);
*rms = _mic.rms;
*peak = _mic.peak;
SDL_UnlockMutex(_mic.lock);
}
bool micMonitor(bool on, float volume) {
if (_mic.capture == NULL) {
return false;
}
if (on && !_micSpeakerOpen()) {
return false;
}
SDL_LockMutex(_mic.lock);
_mic.monitoring = on;
_mic.monitorVolume = (volume < 0.0f) ? 0.0f : ((volume > 1.0f) ? 1.0f : volume);
SDL_UnlockMutex(_mic.lock);
if (!on && (_mic.speaker != NULL)) {
SDL_ClearAudioStream(_mic.speaker);
}
return true;
}
bool micOpen(const char *name) {
SDL_AudioSpec spec;
SDL_AudioDeviceID device = SDL_AUDIO_DEVICE_DEFAULT_RECORDING;
int32_t i = 0;
micClose();
if (_mic.lock == NULL) {
_mic.lock = SDL_CreateMutex();
if (_mic.lock == NULL) {
return false;
}
}
if (name != NULL) {
device = 0;
for (i = 0; i < micDeviceCount(); i++) {
if ((micDeviceName(i) != NULL) && (strcmp(micDeviceName(i), name) == 0)) {
device = _mic.devices[i];
}
}
if (device == 0) {
return false;
}
}
_mic.ring = (float *)calloc(MIC_RING_SAMPLES, sizeof(float));
if (_mic.ring == NULL) {
utilDie("Out of memory opening the microphone.");
}
spec.format = SDL_AUDIO_F32;
spec.channels = 1;
spec.freq = MIC_RATE;
_mic.capture = SDL_OpenAudioDeviceStream(device, &spec, _micCaptured, NULL);
if (_mic.capture == NULL) {
free(_mic.ring);
_mic.ring = NULL;
return false;
}
SDL_ResumeAudioStreamDevice(_mic.capture);
return true;
}
// YIN (de Cheveigne and Kawahara, 2002) over the newest MIC_PITCH_WINDOW samples, averaged in pairs
// to 24 kHz first: half the window is compared with itself shifted by every period from the highest
// pitch to the lowest, and the first shift that matches well enough, refined by a parabola through
// its neighbours, is the period.
void micPitch(float *hz, float *clarity) {
float window[MIC_PITCH_WINDOW];
float halved[MIC_PITCH_WINDOW / MIC_PITCH_DECIMATE];
float difference[MIC_PITCH_WINDOW / MIC_PITCH_DECIMATE / 2];
int32_t count = MIC_PITCH_WINDOW / MIC_PITCH_DECIMATE;
int32_t half = count / 2;
float rate = (float)MIC_RATE / (float)MIC_PITCH_DECIMATE;
int32_t minTau = (int32_t)(rate / MIC_PITCH_MAX_HZ);
int32_t maxTau = (int32_t)(rate / MIC_PITCH_MIN_HZ);
int32_t tau = 0;
int32_t j = 0;
int32_t found = 0;
int64_t start = 0;
double running = 0.0;
double squares = 0.0;
float delta = 0.0f;
float shift = 0.0f;
float a = 0.0f;
float b = 0.0f;
float c = 0.0f;
*hz = 0.0f;
*clarity = 0.0f;
if (maxTau >= half) {
maxTau = half - 1;
}
if (_mic.capture == NULL) {
return;
}
SDL_LockMutex(_mic.lock);
if (_mic.written < MIC_PITCH_WINDOW) {
SDL_UnlockMutex(_mic.lock);
return;
}
start = _mic.written - MIC_PITCH_WINDOW;
for (j = 0; j < MIC_PITCH_WINDOW; j++) {
window[j] = _mic.ring[(start + j) % MIC_RING_SAMPLES];
}
SDL_UnlockMutex(_mic.lock);
for (j = 0; j < count; j++) {
halved[j] = (window[j * 2] + window[j * 2 + 1]) * 0.5f;
squares += (double)halved[j] * (double)halved[j];
}
if (sqrt(squares / (double)count) < MIC_PITCH_SILENCE) {
return;
}
// The cumulative mean normalised difference, d'(tau).
difference[0] = 1.0f;
running = 0.0;
for (tau = 1; tau <= maxTau; tau++) {
double sum = 0.0;
for (j = 0; j < half; j++) {
delta = halved[j] - halved[j + tau];
sum += (double)delta * (double)delta;
}
running += sum;
difference[tau] = (running > 0.0) ? (float)(sum * (double)tau / running) : 1.0f;
}
for (tau = (minTau > 2) ? minTau : 2; tau < maxTau; tau++) {
if (difference[tau] < MIC_PITCH_THRESHOLD) {
while ((tau + 1 < maxTau) && (difference[tau + 1] < difference[tau])) {
tau++;
}
found = tau;
break;
}
}
if (found == 0) {
return;
}
a = difference[found - 1];
b = difference[found];
c = difference[found + 1];
shift = ((a - 2.0f * b + c) != 0.0f) ? 0.5f * (a - c) / (a - 2.0f * b + c) : 0.0f;
*hz = rate / ((float)found + shift);
*clarity = 1.0f - b;
}
void micQuit(void) {
micClose();
SDL_free(_mic.devices);
_mic.devices = NULL;
_mic.deviceCount = 0;
if (_mic.lock != NULL) {
SDL_DestroyMutex(_mic.lock);
_mic.lock = NULL;
}
}
int32_t micRead(int16_t *out, int32_t max) {
int32_t count = 0;
int32_t i = 0;
if (_mic.capture == NULL) {
return 0;
}
SDL_LockMutex(_mic.lock);
if (_mic.written - _mic.readAt > MIC_RING_SAMPLES) {
_mic.readAt = _mic.written - MIC_RING_SAMPLES;
}
count = (int32_t)(_mic.written - _mic.readAt);
if (count > max) {
count = max;
}
for (i = 0; i < count; i++) {
out[i] = _micToInt16(_mic.ring[(_mic.readAt + i) % MIC_RING_SAMPLES]);
}
_mic.readAt += count;
SDL_UnlockMutex(_mic.lock);
return count;
}
bool micRecordStart(const char *path) {
if (_mic.capture == NULL) {
return false;
}
micRecordStop();
_mic.record = fopen(path, "wb");
if (_mic.record == NULL) {
return false;
}
_micWavHeader(_mic.record, 0);
_mic.recordBytes = 0;
SDL_LockMutex(_mic.lock);
_mic.recordAt = _mic.written;
SDL_UnlockMutex(_mic.lock);
return true;
}
void micRecordStop(void) {
if (_mic.record == NULL) {
return;
}
// The ring outlives the capture stream in micClose, so the last of it is written either way.
if (_mic.ring != NULL) {
_micRecordWrite();
}
fseek(_mic.record, 0, SEEK_SET);
_micWavHeader(_mic.record, _mic.recordBytes);
fclose(_mic.record);
_mic.record = NULL;
}
// What has been heard since the recording last caught up, onto the end of the file.
static void _micRecordWrite(void) {
int16_t samples[MIC_RECORD_SAMPLES];
int32_t count = 0;
int32_t i = 0;
for (;;) {
SDL_LockMutex(_mic.lock);
if (_mic.written - _mic.recordAt > MIC_RING_SAMPLES) {
_mic.recordAt = _mic.written - MIC_RING_SAMPLES;
}
count = (int32_t)(_mic.written - _mic.recordAt);
if (count > MIC_RECORD_SAMPLES) {
count = MIC_RECORD_SAMPLES;
}
for (i = 0; i < count; i++) {
samples[i] = _micToInt16(_mic.ring[(_mic.recordAt + i) % MIC_RING_SAMPLES]);
}
_mic.recordAt += count;
SDL_UnlockMutex(_mic.lock);
if (count == 0) {
break;
}
// WAV is little endian; so is every machine Singe runs on, which SDL would refuse to hide.
fwrite(samples, sizeof(int16_t), (size_t)count, _mic.record);
_mic.recordBytes += (int64_t)count * (int64_t)sizeof(int16_t);
}
}
void micSetLatency(int32_t milliseconds) {
_mic.latency = milliseconds;
}
// The second playback stream: the monitor and the calibration clicks. Opened when first needed.
static bool _micSpeakerOpen(void) {
SDL_AudioSpec spec;
SDL_AudioStream *speaker = NULL;
if (_mic.speaker != NULL) {
return true;
}
spec.format = SDL_AUDIO_F32;
spec.channels = 1;
spec.freq = MIC_RATE;
speaker = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, NULL, NULL);
if (speaker == NULL) {
return false;
}
SDL_ResumeAudioStreamDevice(speaker);
// Published under the lock: the capture callback reads it there.
SDL_LockMutex(_mic.lock);
_mic.speaker = speaker;
SDL_UnlockMutex(_mic.lock);
return true;
}
static int16_t _micToInt16(float sample) {
if (sample >= 1.0f) {
return INT16_MAX;
}
if (sample <= -1.0f) {
return -INT16_MAX;
}
return (int16_t)lrintf(sample * (float)INT16_MAX);
}
// The engine's thread, once a frame: the recording caught up, and the calibration stepped -- the
// quiet measured, a click played when it is time, its return or its timeout taken, the answer made.
bool micUpdate(void) {
float click[MIC_CLICK_SAMPLES];
int64_t now = 0;
int64_t queued = 0;
int64_t heard = 0;
int32_t i = 0;
bool finished = false;
if (_mic.capture == NULL) {
return false;
}
if (_mic.record != NULL) {
_micRecordWrite();
}
if (_mic.calibration != MIC_CALIBRATION_RUNNING) {
return false;
}
now = (int64_t)SDL_GetTicksNS();
SDL_LockMutex(_mic.lock);
if (_mic.measuringFloor) {
if (now >= _mic.floorUntilNs) {
_mic.measuringFloor = false;
_mic.threshold = _mic.floorPeak * MIC_FLOOR_FACTOR;
if (_mic.threshold < MIC_THRESHOLD_MIN) {
_mic.threshold = MIC_THRESHOLD_MIN;
}
_mic.nextClickNs = now;
}
SDL_UnlockMutex(_mic.lock);
return false;
}
if (_mic.listening) {
heard = _mic.heardAtNs;
if ((heard != 0) || (now >= _mic.timeoutNs)) {
if ((heard != 0) && ((heard - _mic.clickAtNs) / NS_PER_MS <= MIC_LATENCY_MAX)) {
_mic.results[_mic.resultCount++] = (int32_t)((heard - _mic.clickAtNs) / NS_PER_MS);
}
_mic.listening = false;
_mic.nextClickNs = now + MIC_CLICK_GAP_NS;
}
} else if (_mic.clicksPlayed < MIC_CLICKS) {
if (now >= _mic.nextClickNs) {
// The click leaves the speaker after whatever is already queued in front of it.
for (i = 0; i < MIC_CLICK_SAMPLES; i++) {
click[i] = ((i / 12) % 2 == 0) ? MIC_CLICK_LEVEL : -MIC_CLICK_LEVEL;
}
queued = (int64_t)SDL_GetAudioStreamQueued(_mic.speaker) / (int64_t)sizeof(float);
_mic.clickAtNs = now + (queued * NS_PER_SECOND) / MIC_RATE;
_mic.heardAtNs = 0;
_mic.timeoutNs = _mic.clickAtNs + MIC_CLICK_TIMEOUT_NS;
_mic.listening = true;
_mic.clicksPlayed++;
SDL_PutAudioStreamData(_mic.speaker, click, (int32_t)sizeof(click));
}
} else {
if (_mic.resultCount >= MIC_CLICKS_NEEDED) {
qsort(_mic.results, (size_t)_mic.resultCount, sizeof(int32_t), _micCompareInt);
_mic.latency = _mic.results[_mic.resultCount / 2];
_mic.calibration = MIC_CALIBRATION_DONE;
finished = true;
} else {
_mic.calibration = MIC_CALIBRATION_FAILED;
}
}
SDL_UnlockMutex(_mic.lock);
return finished;
}
static void _micWavHeader(FILE *file, int64_t dataBytes) {
fwrite("RIFF", 1, 4, file);
_micWrite32(file, (uint32_t)(WAV_HEADER_BYTES - 8 + dataBytes));
fwrite("WAVEfmt ", 1, 8, file);
_micWrite32(file, 16); // The format chunk's size
_micWrite16(file, 1); // PCM
_micWrite16(file, 1); // Mono
_micWrite32(file, MIC_RATE);
_micWrite32(file, MIC_RATE * (uint32_t)sizeof(int16_t)); // Bytes a second
_micWrite16(file, (uint16_t)sizeof(int16_t)); // Bytes a frame
_micWrite16(file, 16); // Bits a sample
fwrite("data", 1, 4, file);
_micWrite32(file, (uint32_t)dataBytes);
}
static void _micWrite16(FILE *file, uint16_t value) {
uint8_t bytes[2];
bytes[0] = (uint8_t)(value & 0xff);
bytes[1] = (uint8_t)(value >> 8);
fwrite(bytes, 1, 2, file);
}
static void _micWrite32(FILE *file, uint32_t value) {
uint8_t bytes[4];
bytes[0] = (uint8_t)(value & 0xff);
bytes[1] = (uint8_t)((value >> 8) & 0xff);
bytes[2] = (uint8_t)((value >> 16) & 0xff);
bytes[3] = (uint8_t)(value >> 24);
fwrite(bytes, 1, 4, file);
}

98
src/mic.h Normal file
View file

@ -0,0 +1,98 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef MIC_H
#define MIC_H
#include <stdbool.h>
#include <stdint.h>
// The microphone: one recording device at a time, heard as mono float samples at MIC_RATE. SDL's
// stream callback keeps the newest of them in a ring, with the level of each block; everything a
// game asks is answered from that ring on the engine's thread (PLAN.md section 62).
#define MIC_RATE 48000 // Opus's native rate, so a voice frame needs no resampling
#define MIC_LATENCY_UNKNOWN -1 // micLatency before any calibration has finished
#define MIC_LATENCY_MAX 1000 // A measured latency longer than this is not believed
#define MIC_RING_SECONDS 8 // The newest this much is kept; a slower reader loses the rest
typedef enum MicCalibrationE {
MIC_CALIBRATION_IDLE = 0, // Nothing asked, or the last one finished
MIC_CALIBRATION_RUNNING = 1,
MIC_CALIBRATION_DONE = 2, // micLatency holds the answer
MIC_CALIBRATION_FAILED = 3 // The clicks were never heard back
} MicCalibrationE;
// Starts a latency measurement: clicks played through the speakers and timed coming back through
// the microphone. False when no microphone is open or the speakers cannot be opened.
bool micCalibrate(void);
MicCalibrationE micCalibrationState(void);
void micClose(void);
// The recording devices SDL can see, and each one's name (NULL when the index names none). The
// list is taken afresh by micDeviceCount.
int32_t micDeviceCount(void);
const char *micDeviceName(int32_t index);
bool micIsOpen(void);
// The milliseconds between a sound being made and the game hearing it, or MIC_LATENCY_UNKNOWN.
int32_t micLatency(void);
// RMS and peak of the last block heard, 0 to 1.
void micLevel(float *rms, float *peak);
// Plays what the microphone hears through the speakers, at volume 0 to 1. Off by default.
bool micMonitor(bool on, float volume);
// Opens a recording device by name, or the default for NULL. Closes one already open.
bool micOpen(const char *name);
// The fundamental of what was heard most recently, in Hz, and how clearly periodic it is, 0 to 1;
// 0 Hz when nothing voiced is there.
void micPitch(float *hz, float *clarity);
void micQuit(void);
// Up to max samples heard since the last read, as 16-bit, oldest first. Returns how many. A reader
// that falls a whole ring behind skips to the oldest still kept.
int32_t micRead(int16_t *out, int32_t max);
// Writes everything heard from now on to a 16-bit mono WAV file at path, until micRecordStop.
bool micRecordStart(const char *path);
void micRecordStop(void);
// Sets the latency a game already knows (from the machine settings), MIC_LATENCY_UNKNOWN to forget.
void micSetLatency(int32_t milliseconds);
// Once a frame on the engine's thread: the recording written, the calibration stepped. True on the
// frame a calibration finishes with an answer, so the caller can keep it.
bool micUpdate(void);
#endif // MIC_H

File diff suppressed because it is too large Load diff

359
src/vfs.c
View file

@ -136,9 +136,65 @@ static char *_overlayFor(const char *dataDirBase, const char *dataDir, con
static uint8_t *_readAsset(DatabaseT *db, const char *key, size_t *bytes, bool sdlMemory);
static bool _readChunk(DatabaseT *db, const char *key, int64_t index, uint8_t *buffer, int64_t capacity, int64_t *length);
static bool _resolve(const char *name, TargetT *target);
static int _sqliteAccess(sqlite3_vfs *vfs, const char *name, int flags, int *result);
static int _sqliteCheckReservedLock(sqlite3_file *file, int *result);
static int _sqliteClose(sqlite3_file *file);
static int _sqliteCurrentTime(sqlite3_vfs *vfs, double *now);
static int _sqliteDeviceCharacteristics(sqlite3_file *file);
static void _sqliteDlClose(sqlite3_vfs *vfs, void *handle);
static void _sqliteDlError(sqlite3_vfs *vfs, int bytes, char *out);
static void *_sqliteDlOpen(sqlite3_vfs *vfs, const char *name);
static void (*_sqliteDlSym(sqlite3_vfs *vfs, void *handle, const char *symbol))(void);
static int _sqliteFileControl(sqlite3_file *file, int op, void *arg);
static int _sqliteFileSize(sqlite3_file *file, sqlite3_int64 *size);
static int _sqliteFullPathname(sqlite3_vfs *vfs, const char *name, int bytes, char *out);
static int _sqliteGetLastError(sqlite3_vfs *vfs, int bytes, char *out);
static int _sqliteLock(sqlite3_file *file, int level);
static int _sqliteOpen(sqlite3_vfs *vfs, sqlite3_filename name, sqlite3_file *file, int flags, int *outFlags);
static int _sqliteRandomness(sqlite3_vfs *vfs, int bytes, char *out);
static int _sqliteRead(sqlite3_file *file, void *buffer, int amount, sqlite3_int64 offset);
static int _sqliteSectorSize(sqlite3_file *file);
static int _sqliteSleep(sqlite3_vfs *vfs, int microseconds);
static int _sqliteSync(sqlite3_file *file, int flags);
static int _sqliteTruncate(sqlite3_file *file, sqlite3_int64 size);
static int _sqliteUnlock(sqlite3_file *file, int level);
static int _sqliteWrite(sqlite3_file *file, const void *buffer, int amount, sqlite3_int64 offset);
static void _targetFree(TargetT *target);
static char *_uriEncode(const char *text);
// A database packed inside the game, opened by a script through sqlite3.open, is read where it lies
// through this read-only SQLite VFS: each file is a VfsStreamT over the packed asset. Registered
// the first time a script asks (vfsSqliteUri); what it does not do itself goes to the default VFS.
#define VFS_SQLITE_NAME "singePacked"
#define VFS_SQLITE_SECTOR_BYTES 4096
#define VFS_EXTRACT_BYTES (1024 * 1024)
typedef struct PackedFileS {
sqlite3_file base; // SQLite's part, first, as a VFS file must begin
VfsStreamT *stream;
} PackedFileT;
static const sqlite3_io_methods _sqliteFileMethods = {
1,
_sqliteClose,
_sqliteRead,
_sqliteWrite,
_sqliteTruncate,
_sqliteSync,
_sqliteFileSize,
_sqliteLock,
_sqliteUnlock,
_sqliteCheckReservedLock,
_sqliteFileControl,
_sqliteSectorSize,
_sqliteDeviceCharacteristics,
NULL, NULL, NULL, NULL, NULL, NULL
};
static sqlite3_vfs _sqliteVfs;
static sqlite3_vfs *_sqliteDefault = NULL;
static DatabaseT *_databases = NULL;
static DatabaseT *_container = NULL;
static char *_dataDirBase = NULL;
@ -253,8 +309,6 @@ static DatabaseT *_databaseOpen(const char *path) {
char *value = NULL;
char *encoded = NULL;
char *stem = NULL;
size_t i = 0;
size_t o = 0;
int64_t version = 0;
for (db = _databases; db != NULL; db = db->next) {
@ -266,19 +320,7 @@ static DatabaseT *_databaseOpen(const char *path) {
return NULL;
}
// Percent-encode the three characters a URI would misread.
encoded = (char *)calloc(strlen(path) * 3 + 1, 1);
if (encoded == NULL) {
utilDie("Out of memory opening %s.", path);
}
for (i = 0; path[i] != 0; i++) {
if ((path[i] == '?') || (path[i] == '#') || (path[i] == '%')) {
sprintf(encoded + o, "%%%02X", (unsigned char)path[i]);
o += 3;
} else {
encoded[o++] = path[i];
}
}
encoded = _uriEncode(path);
uri = utilCreateString("file:%s?immutable=1", encoded);
free(encoded);
@ -755,6 +797,197 @@ static bool _resolve(const char *name, TargetT *target) {
}
// The packed-database VFS (see _sqliteVfs). Nothing is ever written, so there is never a journal
// to look for.
static int _sqliteAccess(sqlite3_vfs *vfs, const char *name, int flags, int *result) {
(void)vfs;
(void)name;
(void)flags;
*result = 0;
return SQLITE_OK;
}
static int _sqliteCheckReservedLock(sqlite3_file *file, int *result) {
(void)file;
*result = 0;
return SQLITE_OK;
}
static int _sqliteClose(sqlite3_file *file) {
PackedFileT *packed = (PackedFileT *)file;
vfsStreamClose(packed->stream);
packed->stream = NULL;
return SQLITE_OK;
}
static int _sqliteCurrentTime(sqlite3_vfs *vfs, double *now) {
(void)vfs;
return _sqliteDefault->xCurrentTime(_sqliteDefault, now);
}
static int _sqliteDeviceCharacteristics(sqlite3_file *file) {
(void)file;
return SQLITE_IOCAP_IMMUTABLE;
}
// Loading an extension works as it would on any other database: the default VFS does it.
static void _sqliteDlClose(sqlite3_vfs *vfs, void *handle) {
(void)vfs;
_sqliteDefault->xDlClose(_sqliteDefault, handle);
}
static void _sqliteDlError(sqlite3_vfs *vfs, int bytes, char *out) {
(void)vfs;
_sqliteDefault->xDlError(_sqliteDefault, bytes, out);
}
static void *_sqliteDlOpen(sqlite3_vfs *vfs, const char *name) {
(void)vfs;
return _sqliteDefault->xDlOpen(_sqliteDefault, name);
}
static void (*_sqliteDlSym(sqlite3_vfs *vfs, void *handle, const char *symbol))(void) {
(void)vfs;
return _sqliteDefault->xDlSym(_sqliteDefault, handle, symbol);
}
static int _sqliteFileControl(sqlite3_file *file, int op, void *arg) {
(void)file;
(void)op;
(void)arg;
return SQLITE_NOTFOUND;
}
static int _sqliteFileSize(sqlite3_file *file, sqlite3_int64 *size) {
*size = vfsStreamSize(((PackedFileT *)file)->stream);
return SQLITE_OK;
}
// The name is the game's own name for the file, not a path on disk, and is kept as it was given.
static int _sqliteFullPathname(sqlite3_vfs *vfs, const char *name, int bytes, char *out) {
(void)vfs;
if ((int)strlen(name) >= bytes) {
return SQLITE_CANTOPEN;
}
strcpy(out, name);
return SQLITE_OK;
}
static int _sqliteGetLastError(sqlite3_vfs *vfs, int bytes, char *out) {
(void)vfs;
return _sqliteDefault->xGetLastError(_sqliteDefault, bytes, out);
}
static int _sqliteLock(sqlite3_file *file, int level) {
(void)file;
(void)level;
return SQLITE_OK;
}
// Only the main database is opened, and always read only: a packed file cannot be changed.
static int _sqliteOpen(sqlite3_vfs *vfs, sqlite3_filename name, sqlite3_file *file, int flags, int *outFlags) {
PackedFileT *packed = (PackedFileT *)file;
(void)vfs;
packed->base.pMethods = NULL;
if ((name == NULL) || ((flags & SQLITE_OPEN_MAIN_DB) == 0)) {
return SQLITE_CANTOPEN;
}
packed->stream = vfsStreamOpen(name);
if (packed->stream == NULL) {
return SQLITE_CANTOPEN;
}
packed->base.pMethods = &_sqliteFileMethods;
if (outFlags != NULL) {
*outFlags = SQLITE_OPEN_READONLY;
}
return SQLITE_OK;
}
static int _sqliteRandomness(sqlite3_vfs *vfs, int bytes, char *out) {
(void)vfs;
return _sqliteDefault->xRandomness(_sqliteDefault, bytes, out);
}
// A read that runs past the end comes back short and zero filled, which is how SQLite expects a
// file to answer it.
static int _sqliteRead(sqlite3_file *file, void *buffer, int amount, sqlite3_int64 offset) {
VfsStreamT *stream = ((PackedFileT *)file)->stream;
int64_t got = 0;
if (offset < vfsStreamSize(stream)) {
if (vfsStreamSeek(stream, offset, SEEK_SET) < 0) {
return SQLITE_IOERR_READ;
}
got = vfsStreamRead(stream, buffer, amount);
}
if (got < amount) {
memset((uint8_t *)buffer + got, 0, (size_t)(amount - got));
return SQLITE_IOERR_SHORT_READ;
}
return SQLITE_OK;
}
static int _sqliteSectorSize(sqlite3_file *file) {
(void)file;
return VFS_SQLITE_SECTOR_BYTES;
}
static int _sqliteSleep(sqlite3_vfs *vfs, int microseconds) {
(void)vfs;
return _sqliteDefault->xSleep(_sqliteDefault, microseconds);
}
static int _sqliteSync(sqlite3_file *file, int flags) {
(void)file;
(void)flags;
return SQLITE_OK;
}
static int _sqliteTruncate(sqlite3_file *file, sqlite3_int64 size) {
(void)file;
(void)size;
return SQLITE_READONLY;
}
static int _sqliteUnlock(sqlite3_file *file, int level) {
(void)file;
(void)level;
return SQLITE_OK;
}
static int _sqliteWrite(sqlite3_file *file, const void *buffer, int amount, sqlite3_int64 offset) {
(void)file;
(void)buffer;
(void)amount;
(void)offset;
return SQLITE_READONLY;
}
static void _targetFree(TargetT *target) {
free(target->path);
free(target->key);
@ -763,6 +996,29 @@ static void _targetFree(TargetT *target) {
}
// Percent-encodes the three characters a SQLite URI would misread. Free the result.
static char *_uriEncode(const char *text) {
char *encoded = NULL;
size_t i = 0;
size_t o = 0;
encoded = (char *)calloc(strlen(text) * 3 + 1, 1);
if (encoded == NULL) {
utilDie("Out of memory encoding %s.", text);
}
for (i = 0; text[i] != 0; i++) {
if ((text[i] == '?') || (text[i] == '#') || (text[i] == '%')) {
sprintf(encoded + o, "%%%02X", (unsigned char)text[i]);
o += 3;
} else {
encoded[o++] = text[i];
}
}
return encoded;
}
// The path without its database extension, as a new string; unchanged when it has none.
char *vfsDatabaseStem(const char *path) {
if (_hasDatabaseExtension(path)) {
@ -798,6 +1054,44 @@ bool vfsExists(const char *name) {
// anything could read a database row as a stream; everything can now -- vfsStreamOpen and
// vfsOpenIO, and the Lua file object built on the first of them -- so a packed name being read
// yields the loose candidate, which fails to open like any missing file. Ask for a stream instead.
// Copies a file the game names -- packed or not -- to path on disk, through a temporary name so an
// interrupted copy never leaves a partial file where path is. True when the whole file was written.
bool vfsExtract(const char *name, const char *path) {
VfsStreamT *stream = NULL;
FILE *out = NULL;
uint8_t *buffer = NULL;
char *temporary = NULL;
int64_t got = 0;
int64_t total = 0;
bool good = false;
stream = vfsStreamOpen(name);
if (stream == NULL) {
return false;
}
temporary = utilCreateString("%s.part", path);
buffer = (uint8_t *)malloc(VFS_EXTRACT_BYTES);
out = (buffer != NULL) ? fopen(temporary, "wb") : NULL;
if (out != NULL) {
good = true;
while (good && ((got = vfsStreamRead(stream, buffer, VFS_EXTRACT_BYTES)) > 0)) {
good = (fwrite(buffer, 1, (size_t)got, out) == (size_t)got);
total += got;
}
good = (fclose(out) == 0) && good && (total == vfsStreamSize(stream));
good = good && (remove(path) == 0 || !utilFileExists(path)) && (rename(temporary, path) == 0);
if (!good) {
remove(temporary);
}
}
free(buffer);
free(temporary);
vfsStreamClose(stream);
return good;
}
char *vfsFilePath(const char *name, bool forWriting) {
TargetT target;
char *path = NULL;
@ -1062,6 +1356,41 @@ char *vfsReadMeta(struct sqlite3 *db, const char *schema, const char *key) {
// Size and modification time, the latter being the database's own for a packed asset.
// The SQLite URI that opens a database packed inside the game where it lies, read only: give it to
// sqlite3_open_v2 with SQLITE_OPEN_READONLY | SQLITE_OPEN_URI. Registers the VFS that reads it the
// first time. Free the result.
char *vfsSqliteUri(const char *name) {
char *encoded = NULL;
char *uri = NULL;
if (_sqliteDefault == NULL) {
_sqliteDefault = sqlite3_vfs_find(NULL);
memset(&_sqliteVfs, 0, sizeof(_sqliteVfs));
_sqliteVfs.iVersion = 1;
_sqliteVfs.szOsFile = (int)sizeof(PackedFileT);
_sqliteVfs.mxPathname = _sqliteDefault->mxPathname;
_sqliteVfs.zName = VFS_SQLITE_NAME;
_sqliteVfs.xOpen = _sqliteOpen;
_sqliteVfs.xAccess = _sqliteAccess;
_sqliteVfs.xFullPathname = _sqliteFullPathname;
_sqliteVfs.xDlOpen = _sqliteDlOpen;
_sqliteVfs.xDlError = _sqliteDlError;
_sqliteVfs.xDlSym = _sqliteDlSym;
_sqliteVfs.xDlClose = _sqliteDlClose;
_sqliteVfs.xRandomness = _sqliteRandomness;
_sqliteVfs.xSleep = _sqliteSleep;
_sqliteVfs.xCurrentTime = _sqliteCurrentTime;
_sqliteVfs.xGetLastError = _sqliteGetLastError;
sqlite3_vfs_register(&_sqliteVfs, 0);
}
encoded = _uriEncode(name);
uri = utilCreateString("file:%s?vfs=%s&immutable=1", encoded, VFS_SQLITE_NAME);
free(encoded);
return uri;
}
bool vfsStat(const char *name, int64_t *size, int64_t *modified) {
TargetT target;
bool found = false;

View file

@ -63,6 +63,7 @@ typedef struct VfsStreamS VfsStreamT;
char *vfsDatabaseStem(const char *path);
bool vfsExists(const char *name);
bool vfsExtract(const char *name, const char *path);
char *vfsFilePath(const char *name, bool forWriting);
void vfsInit(const char *container, const char *dataDirBase, const char *dataDir);
bool vfsIsDatabase(const char *path);
@ -75,6 +76,7 @@ SDL_IOStream *vfsOpenIO(const char *name);
void vfsQuit(void);
char *vfsRead(const char *name, size_t *bytes); // Whole file, NUL terminated, in malloc memory: free() it, not SDL_free()
char *vfsReadMeta(struct sqlite3 *db, const char *schema, const char *key);
char *vfsSqliteUri(const char *name); // Read-only URI for a database packed in the game: free() it
bool vfsStat(const char *name, int64_t *size, int64_t *modified);
void vfsStreamClose(VfsStreamT *stream);
VfsStreamT *vfsStreamOpen(const char *name);

381
src/voice.c Normal file
View file

@ -0,0 +1,381 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
// Singe: Opus and SpeexDSP behind handles. A handle is an index into one table of objects, each
// marked with its kind; a freed slot is reused. Everything here runs on the engine's thread.
#include <stdlib.h>
#include <string.h>
#include <opus.h>
#include <speex/speex_echo.h>
#include <speex/speex_jitter.h>
#include <speex/speex_preprocess.h>
#include "util.h"
#include "voice.h"
#define VOICE_TABLE_GROW 16
typedef struct VoiceObjectS {
VoiceKindE kind; // 0 for a free slot
OpusEncoder *encoder;
OpusDecoder *decoder;
SpeexEchoState *echo;
SpeexPreprocessState *preprocess;
JitterBuffer *jitter;
int16_t *cleaned; // The echo canceller's output, a frame long
int16_t *silence; // The speaker reference when nothing was played
int32_t frame; // A processor's frame, or a jitter buffer's step
int32_t channels;
} VoiceObjectT;
static VoiceObjectT *_voiceGet(int32_t handle, VoiceKindE kind);
static int32_t _voiceNew(VoiceKindE kind);
static bool _voiceRateValid(int32_t rate);
static void _voiceRelease(VoiceObjectT *object);
static VoiceObjectT *_voiceTable = NULL;
static int32_t _voiceCapacity = 0;
int32_t voiceChannels(int32_t handle) {
if ((handle < 1) || (handle > _voiceCapacity)) {
return 0;
}
return _voiceTable[handle - 1].channels;
}
int32_t voiceDecode(int32_t handle, const uint8_t *packet, int32_t bytes, int16_t *out, int32_t maxSamples) {
VoiceObjectT *object = _voiceGet(handle, VOICE_KIND_DECODER);
opus_int32 samples = 0;
if (object == NULL) {
return OPUS_BAD_ARG;
}
if (packet == NULL) {
// Concealment fills exactly the length it is given, so it is given one packet's worth: the
// last one heard, or a usual voice frame before any has been.
opus_decoder_ctl(object->decoder, OPUS_GET_LAST_PACKET_DURATION(&samples));
if (samples <= 0) {
opus_decoder_ctl(object->decoder, OPUS_GET_SAMPLE_RATE(&samples));
samples = samples * VOICE_DEFAULT_FRAME_MS / 1000;
}
return opus_decode(object->decoder, NULL, 0, out, (samples < maxSamples) ? samples : maxSamples, 0);
}
return opus_decode(object->decoder, packet, bytes, out, maxSamples, 0);
}
int32_t voiceDecoderNew(int32_t rate, int32_t channels) {
VoiceObjectT *object = NULL;
int32_t handle = 0;
int error = OPUS_OK;
if (!_voiceRateValid(rate) || (channels < 1) || (channels > 2)) {
return OPUS_BAD_ARG;
}
handle = _voiceNew(VOICE_KIND_DECODER);
object = &_voiceTable[handle - 1];
object->decoder = opus_decoder_create(rate, channels, &error);
if (object->decoder == NULL) {
_voiceRelease(object);
return (error != OPUS_OK) ? error : OPUS_ALLOC_FAIL;
}
object->channels = channels;
return handle;
}
int32_t voiceEncode(int32_t handle, const int16_t *pcm, int32_t samples, uint8_t *packet, int32_t maxBytes) {
VoiceObjectT *object = _voiceGet(handle, VOICE_KIND_ENCODER);
if (object == NULL) {
return OPUS_BAD_ARG;
}
return opus_encode(object->encoder, pcm, samples, packet, maxBytes);
}
int32_t voiceEncoderNew(int32_t rate, int32_t channels, VoiceApplicationE application) {
static const int32_t applications[] = { OPUS_APPLICATION_VOIP, OPUS_APPLICATION_AUDIO, OPUS_APPLICATION_RESTRICTED_LOWDELAY };
VoiceObjectT *object = NULL;
int32_t handle = 0;
int error = OPUS_OK;
if (!_voiceRateValid(rate) || (channels < 1) || (channels > 2) || ((int32_t)application < 0) || ((int32_t)application > VOICE_APPLICATION_LOW)) {
return OPUS_BAD_ARG;
}
handle = _voiceNew(VOICE_KIND_ENCODER);
object = &_voiceTable[handle - 1];
object->encoder = opus_encoder_create(rate, channels, applications[application], &error);
if (object->encoder == NULL) {
_voiceRelease(object);
return (error != OPUS_OK) ? error : OPUS_ALLOC_FAIL;
}
object->channels = channels;
return handle;
}
// bitrate in bits a second (0 leaves it), complexity 0 to 10 (negative leaves it).
bool voiceEncoderSet(int32_t handle, int32_t bitrate, int32_t complexity) {
VoiceObjectT *object = _voiceGet(handle, VOICE_KIND_ENCODER);
if (object == NULL) {
return false;
}
if ((bitrate > 0) && (opus_encoder_ctl(object->encoder, OPUS_SET_BITRATE(bitrate)) != OPUS_OK)) {
return false;
}
if ((complexity >= 0) && (opus_encoder_ctl(object->encoder, OPUS_SET_COMPLEXITY(complexity)) != OPUS_OK)) {
return false;
}
return true;
}
const char *voiceError(int32_t code) {
return opus_strerror(code);
}
int32_t voiceFrame(int32_t handle) {
if ((handle < 1) || (handle > _voiceCapacity)) {
return 0;
}
return _voiceTable[handle - 1].frame;
}
bool voiceFree(int32_t handle) {
if ((handle < 1) || (handle > _voiceCapacity) || (_voiceTable[handle - 1].kind == 0)) {
return false;
}
_voiceRelease(&_voiceTable[handle - 1]);
return true;
}
// Everything a script made, when the script goes.
void voiceFreeAll(void) {
int32_t i = 0;
for (i = 0; i < _voiceCapacity; i++) {
if (_voiceTable[i].kind != 0) {
_voiceRelease(&_voiceTable[i]);
}
}
free(_voiceTable);
_voiceTable = NULL;
_voiceCapacity = 0;
}
static VoiceObjectT *_voiceGet(int32_t handle, VoiceKindE kind) {
if ((handle < 1) || (handle > _voiceCapacity) || (_voiceTable[handle - 1].kind != kind)) {
return NULL;
}
return &_voiceTable[handle - 1];
}
int32_t voiceJitterGet(int32_t handle, uint8_t *packet, int32_t maxBytes, int32_t span, VoiceJitterE *status) {
VoiceObjectT *object = _voiceGet(handle, VOICE_KIND_JITTER);
JitterBufferPacket out;
int result = 0;
*status = VOICE_JITTER_MISSING;
if (object == NULL) {
return 0;
}
memset(&out, 0, sizeof(out));
out.data = (char *)packet;
out.len = (spx_uint32_t)maxBytes;
result = jitter_buffer_get(object->jitter, &out, span, NULL);
jitter_buffer_tick(object->jitter);
if (result == JITTER_BUFFER_OK) {
*status = VOICE_JITTER_OK;
return (int32_t)out.len;
}
if (result == JITTER_BUFFER_INSERTION) {
*status = VOICE_JITTER_INSERTION;
}
return 0;
}
// step is the span the buffer moves by each get, in the same units as the packets' timestamps.
int32_t voiceJitterNew(int32_t step) {
VoiceObjectT *object = NULL;
int32_t handle = 0;
if (step < 1) {
return OPUS_BAD_ARG;
}
handle = _voiceNew(VOICE_KIND_JITTER);
object = &_voiceTable[handle - 1];
object->frame = step;
object->jitter = jitter_buffer_init(step);
if (object->jitter == NULL) {
_voiceRelease(object);
return OPUS_ALLOC_FAIL;
}
return handle;
}
bool voiceJitterPut(int32_t handle, const uint8_t *packet, int32_t bytes, int32_t timestamp, int32_t span) {
VoiceObjectT *object = _voiceGet(handle, VOICE_KIND_JITTER);
JitterBufferPacket in;
if ((object == NULL) || (bytes <= 0)) {
return false;
}
memset(&in, 0, sizeof(in));
// The buffer keeps its own copy, so the packet may be reused once this returns.
in.data = (char *)packet;
in.len = (spx_uint32_t)bytes;
in.timestamp = (spx_uint32_t)timestamp;
in.span = (spx_uint32_t)span;
jitter_buffer_put(object->jitter, &in);
return true;
}
bool voiceKind(int32_t handle, VoiceKindE kind) {
return (_voiceGet(handle, kind) != NULL);
}
// A free slot, or a new one at the end, marked with its kind. Returns its handle.
static int32_t _voiceNew(VoiceKindE kind) {
VoiceObjectT *grown = NULL;
int32_t i = 0;
for (i = 0; i < _voiceCapacity; i++) {
if (_voiceTable[i].kind == 0) {
break;
}
}
if (i == _voiceCapacity) {
grown = (VoiceObjectT *)realloc(_voiceTable, (size_t)(_voiceCapacity + VOICE_TABLE_GROW) * sizeof(VoiceObjectT));
if (grown == NULL) {
utilDie("Out of memory making a voice object.");
}
memset(grown + _voiceCapacity, 0, VOICE_TABLE_GROW * sizeof(VoiceObjectT));
_voiceTable = grown;
_voiceCapacity += VOICE_TABLE_GROW;
}
_voiceTable[i].kind = kind;
return i + 1;
}
bool voiceProcess(int32_t handle, int16_t *mic, const int16_t *speaker, bool *voiced) {
VoiceObjectT *object = _voiceGet(handle, VOICE_KIND_PROCESSOR);
*voiced = false;
if (object == NULL) {
return false;
}
// The canceller always runs, on silence when nothing was played, so it keeps tracking the room
// and the preprocessor's residual echo estimate stays meaningful.
speex_echo_cancellation(object->echo, mic, (speaker != NULL) ? speaker : object->silence, object->cleaned);
memcpy(mic, object->cleaned, (size_t)object->frame * sizeof(int16_t));
*voiced = (speex_preprocess_run(object->preprocess, mic) != 0);
return true;
}
// frame samples a call (10 to 20 ms is usual: 480 to 960 at 48 kHz), at rate, cancelling echoes up to
// tailMs long (100 to 200 for a room).
int32_t voiceProcessorNew(int32_t frame, int32_t rate, int32_t tailMs) {
VoiceObjectT *object = NULL;
int32_t handle = 0;
int on = 1;
int sample = rate;
if ((frame < 1) || (frame > VOICE_FRAME_MAX) || !_voiceRateValid(rate) || (tailMs < 1)) {
return OPUS_BAD_ARG;
}
handle = _voiceNew(VOICE_KIND_PROCESSOR);
object = &_voiceTable[handle - 1];
object->frame = frame;
object->echo = speex_echo_state_init(frame, rate * tailMs / 1000);
object->preprocess = speex_preprocess_state_init(frame, rate);
object->cleaned = (int16_t *)calloc((size_t)frame, sizeof(int16_t));
object->silence = (int16_t *)calloc((size_t)frame, sizeof(int16_t));
if ((object->echo == NULL) || (object->preprocess == NULL) || (object->cleaned == NULL) || (object->silence == NULL)) {
_voiceRelease(object);
return OPUS_ALLOC_FAIL;
}
speex_echo_ctl(object->echo, SPEEX_ECHO_SET_SAMPLING_RATE, &sample);
speex_preprocess_ctl(object->preprocess, SPEEX_PREPROCESS_SET_DENOISE, &on);
speex_preprocess_ctl(object->preprocess, SPEEX_PREPROCESS_SET_AGC, &on);
speex_preprocess_ctl(object->preprocess, SPEEX_PREPROCESS_SET_VAD, &on);
speex_preprocess_ctl(object->preprocess, SPEEX_PREPROCESS_SET_ECHO_STATE, object->echo);
return handle;
}
// The rates Opus and the processor both take.
static bool _voiceRateValid(int32_t rate) {
return (rate == 8000) || (rate == 12000) || (rate == 16000) || (rate == 24000) || (rate == 48000);
}
static void _voiceRelease(VoiceObjectT *object) {
if (object->encoder != NULL) {
opus_encoder_destroy(object->encoder);
}
if (object->decoder != NULL) {
opus_decoder_destroy(object->decoder);
}
if (object->preprocess != NULL) {
speex_preprocess_state_destroy(object->preprocess);
}
if (object->echo != NULL) {
speex_echo_state_destroy(object->echo);
}
if (object->jitter != NULL) {
jitter_buffer_destroy(object->jitter);
}
free(object->cleaned);
free(object->silence);
memset(object, 0, sizeof(VoiceObjectT));
}

103
src/voice.h Normal file
View file

@ -0,0 +1,103 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef VOICE_H
#define VOICE_H
#include <stdbool.h>
#include <stdint.h>
// The pieces a voice chat stands on, without the network (PLAN.md section 62): an Opus encoder and
// decoder, a voice processor (SpeexDSP's echo cancellation, noise suppression, gain, and voice
// activity), and a jitter buffer. Each lives behind a handle; every call names the kind it wants,
// so a handle of another kind is refused rather than used.
#define VOICE_PACKET_MAX 4000 // Opus's own ceiling for one packet
#define VOICE_FRAME_MAX 5760 // 120 ms at 48 kHz, the longest frame Opus takes
#define VOICE_DEFAULT_FRAME_MS 20 // The usual voice frame...
#define VOICE_DEFAULT_FRAME 960 // ...in samples at 48 kHz
#define VOICE_DEFAULT_TAIL_MS 200 // The longest echo a room is expected to have
typedef enum VoiceKindE {
VOICE_KIND_ENCODER = 1,
VOICE_KIND_DECODER = 2,
VOICE_KIND_PROCESSOR = 3,
VOICE_KIND_JITTER = 4
} VoiceKindE;
typedef enum VoiceApplicationE {
VOICE_APPLICATION_VOIP = 0, // Speech, tuned for intelligibility
VOICE_APPLICATION_AUDIO = 1, // Music and anything else
VOICE_APPLICATION_LOW = 2 // Lowest delay, at some cost in quality
} VoiceApplicationE;
typedef enum VoiceJitterE {
VOICE_JITTER_OK = 0, // A packet came out
VOICE_JITTER_MISSING = 1, // None due: conceal the gap
VOICE_JITTER_INSERTION = 2 // The buffer is growing: play a little silence
} VoiceJitterE;
// An encoder's or decoder's channels, 0 for anything else.
int32_t voiceChannels(int32_t handle);
// Decodes a packet into at most maxSamples samples a channel; a NULL packet asks Opus to conceal a
// lost one, as long as the last packet heard. Returns samples a channel, or negative on failure.
int32_t voiceDecode(int32_t handle, const uint8_t *packet, int32_t bytes, int16_t *out, int32_t maxSamples);
int32_t voiceDecoderNew(int32_t rate, int32_t channels);
// Encodes one frame -- 2.5, 5, 10, 20, 40, or 60 ms of samples a channel -- into a packet. Returns its
// bytes, or negative on failure.
int32_t voiceEncode(int32_t handle, const int16_t *pcm, int32_t samples, uint8_t *packet, int32_t maxBytes);
int32_t voiceEncoderNew(int32_t rate, int32_t channels, VoiceApplicationE application);
bool voiceEncoderSet(int32_t handle, int32_t bitrate, int32_t complexity);
// Opus's own words for an error code, or SpeexDSP's handle errors.
const char *voiceError(int32_t code);
// A processor's frame, or a jitter buffer's step, in samples; 0 for anything else.
int32_t voiceFrame(int32_t handle);
bool voiceFree(int32_t handle);
void voiceFreeAll(void);
// A packet into the jitter buffer: its timestamp and its span, in samples.
bool voiceJitterPut(int32_t handle, const uint8_t *packet, int32_t bytes, int32_t timestamp, int32_t span);
// The packet due for the next span samples, if any, and the buffer moved on by one span. Returns
// its bytes, with the status saying whether one came out at all.
int32_t voiceJitterGet(int32_t handle, uint8_t *packet, int32_t maxBytes, int32_t span, VoiceJitterE *status);
int32_t voiceJitterNew(int32_t step);
// Whether a handle is live and of that kind.
bool voiceKind(int32_t handle, VoiceKindE kind);
// One frame of the microphone cleaned in place: the echo of speaker (what was played, NULL when
// nothing was) taken out, then noise suppressed and the level evened. Answers whether it is voice.
bool voiceProcess(int32_t handle, int16_t *mic, const int16_t *speaker, bool *voiced);
int32_t voiceProcessorNew(int32_t frame, int32_t rate, int32_t tailMs);
#endif // VOICE_H

View file

@ -0,0 +1,218 @@
-- The microphone and the voice pieces (PLAN.md section 62), with no microphone: SDL's disk audio
-- driver reads its "microphone" from a file, which this script writes first -- two seconds of
-- silence, three of a 220 Hz tone at half scale, three of 440 Hz at quarter scale, and silence after.
-- Run it headless from a scratch directory holding this folder:
--
-- SDL_AUDIO_DRIVER=disk SDL_AUDIO_DISK_INPUT_FILE=micIn.raw SDL_AUDIO_DISK_OUTPUT_FILE=micOut.raw \
-- SDL_AUDIO_FORMAT=F32 SDL_AUDIO_FREQUENCY=48000 SDL_AUDIO_CHANNELS=1 SDL_VIDEODRIVER=offscreen \
-- Singe -w micVoice/micVoice.singe
--
-- It prints one line: MICTEST pass=N fail=0. Every failure prints what it wanted first. What it
-- cannot prove here, and says so: that a real microphone sounds right, monitoring's delay, and a
-- calibration that succeeds -- nothing plays the disk driver's speaker into its microphone, so the
-- calibration must report that it never heard its clicks.
dofile("Singe/Framework.singe")
local RATE = 48000
local FRAME = 960
local INPUT = os.getenv("SDL_AUDIO_DISK_INPUT_FILE")
local pass = 0
local fail = 0
local function check(what, got, want)
if got == want then
pass = pass + 1
else
fail = fail + 1
debugPrint(string.format("FAIL %s: got %s, wanted %s", what, tostring(got), tostring(want)))
end
end
local function near(what, got, want, within)
check(what .. string.format(" (%.3f, wanted %.3f within %.3f)", got, want, within), math.abs(got - want) <= within, true)
end
-- 16-bit samples in a string, as micRead and opusDecode give them, and their RMS from 0 to 1.
local function rmsOf(samples)
local sum = 0
local count = #samples // 2
for i = 1, count do
local value = string.unpack("<i2", samples, i * 2 - 1)
sum = sum + value * value
end
return math.sqrt(sum / math.max(count, 1)) / 32767
end
-- The "microphone": float samples, one channel, as the disk driver will read them.
local function writeInput()
local parts = {}
local function tone(seconds, hz, level)
for i = 0, seconds * RATE - 1 do
parts[#parts + 1] = string.pack("<f", level * math.sin(2 * math.pi * hz * i / RATE))
end
end
tone(2, 0, 0)
tone(3, 220, 0.5)
tone(3, 440, 0.25)
tone(12, 0, 0)
local file = io.open(INPUT, "wb")
file:write(table.concat(parts))
file:close()
end
if not INPUT then
debugPrint("MICTEST needs SDL_AUDIO_DISK_INPUT_FILE; see the top of this script")
singeQuit()
return
end
writeInput()
check("a device is listed", #micList() >= 1, true)
check("micOpen", micOpen(), true)
check("micIsOpen", micIsOpen(), true)
local started = singeGetTicks()
local steps = {}
local function at(seconds, fn)
steps[#steps + 1] = { at = seconds * 1000, fn = fn, done = false }
end
-- Silence first.
at(1.0, function()
local hz = micPitch()
local rms = micLevel()
check("no pitch in silence", hz, 0)
check("silence is quiet", rms < 0.01, true)
end)
-- The 220 Hz stretch: a recording of two seconds, a read of one, and the pitch and level.
at(2.5, function()
check("micRecordStart", micRecordStart("micRecording.wav"), true)
micRead()
end)
at(3.5, function()
local samples = micRead()
local hz, clarity = micPitch()
local rms = micLevel()
near("one second read", #samples / 2, RATE, RATE * 0.1)
near("the read's level", rmsOf(samples), 0.5 / math.sqrt(2), 0.03)
near("220 Hz found", hz, 220, 1.0)
check("220 Hz is clear", clarity > 0.9, true)
near("220 Hz level", rms, 0.5 / math.sqrt(2), 0.03)
-- Opus round trip on a frame of it, and the decoded frame's level against the original's.
local frame = samples:sub(1, FRAME * 2)
local encoder = opusEncoderNew(RATE, 1, "audio")
local decoder = opusDecoderNew(RATE, 1)
local decoded = nil
opusEncoderSet(encoder, 64000, 10)
for _ = 1, 5 do
decoded = opusDecode(decoder, opusEncode(encoder, frame))
end
check("a decoded frame is a frame", #decoded, FRAME * 2)
near("a decoded frame keeps its level", rmsOf(decoded), rmsOf(frame), rmsOf(frame) * 0.25)
check("a lost packet is concealed to a frame", #opusDecode(decoder, nil), FRAME * 2)
voiceFree(encoder)
voiceFree(decoder)
-- The voice processor on a run of frames of it.
local processor = voiceProcessorNew(FRAME, RATE, 100)
local cleaned = nil
local voiced = nil
for i = 0, 40 do
local piece = samples:sub((i % 40) * FRAME * 2 + 1, (i % 40 + 1) * FRAME * 2)
cleaned, voiced = voiceProcess(processor, piece)
end
check("a processed frame is a frame", #cleaned, FRAME * 2)
check("a processed tone is not silenced", rmsOf(cleaned) > 0, true)
check("voiced is a boolean", type(voiced), "boolean")
voiceFree(processor)
end)
at(4.5, function()
micRecordStop()
local file = io.open(singeGetDataPath() .. "files/micRecording.wav", "rb") or io.open("micRecording.wav", "rb")
local data = file and file:read("a") or ""
if file then
file:close()
end
check("the recording is a WAV", data:sub(1, 4) .. data:sub(9, 12), "RIFFWAVE")
check("the recording is 48 kHz", #data >= 28 and string.unpack("<I4", data, 25) or 0, RATE)
near("the recording is two seconds", (#data - 44) / 2, RATE * 2, RATE * 0.1)
end)
-- The 440 Hz stretch.
at(6.5, function()
local hz = micPitch()
local rms = micLevel()
near("440 Hz found", hz, 440, 2.0)
near("440 Hz level", rms, 0.25 / math.sqrt(2), 0.02)
check("monitoring on", micMonitor(true, 0.5), true)
check("monitoring off", micMonitor(false), true)
end)
-- The jitter buffer: three packets put in out of order come out in order.
at(7.0, function()
local jitter = jitterNew(FRAME)
local order = {}
jitterPut(jitter, "third", FRAME * 2)
jitterPut(jitter, "first", 0)
jitterPut(jitter, "second", FRAME)
for _ = 1, 8 do
local packet, status = jitterGet(jitter)
if packet then
order[#order + 1] = packet
end
check("a jitter status is one of its three", status == "ok" or status == "missing" or status == "insertion", true)
end
check("the jitter buffer puts packets back in order", table.concat(order, ","), "first,second,third")
voiceFree(jitter)
end)
-- Silence again, then a calibration nobody can hear.
at(9.0, function()
check("no pitch after the tones", micPitch(), 0)
check("micCalibrate starts", micCalibrate(), true)
check("it is running", (micCalibration()), "running")
end)
at(17.0, function()
check("a calibration never heard says so", (micCalibration()), "failed")
micClose()
check("micClose", micIsOpen(), false)
debugPrint(string.format("MICTEST pass=%d fail=%d", pass, fail))
singeQuit()
end)
function onOverlayUpdate()
local now = singeGetTicks() - started
for _, step in ipairs(steps) do
if not step.done and now >= step.at then
step.done = true
step.fn()
end
end
return OVERLAY_UPDATED
end

BIN
testScripts/openRoad/art/arch.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
testScripts/openRoad/art/bush.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
testScripts/openRoad/art/car.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
testScripts/openRoad/art/hills.png (Stored with Git LFS) Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more