Some test games, microphone input support, and the start of some rolling demos.
This commit is contained in:
parent
59741f25e8
commit
6f5792eede
131 changed files with 12161 additions and 74 deletions
66
CHANGELOG
66
CHANGELOG
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
LICENSES
3
LICENSES
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
114
demos/README.md
Normal 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
demos/insertCoin/art/turret.png
(Stored with Git LFS)
Normal file
Binary file not shown.
12
demos/insertCoin/games.dat
Normal file
12
demos/insertCoin/games.dat
Normal 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" }
|
||||
}
|
||||
}
|
||||
1207
demos/insertCoin/insertCoin.singe
Normal file
1207
demos/insertCoin/insertCoin.singe
Normal file
File diff suppressed because it is too large
Load diff
BIN
demos/insertCoin/music.mp3
(Stored with Git LFS)
Normal file
BIN
demos/insertCoin/music.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
337
demos/insertCoin/sync.lua
Normal file
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
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
BIN
demos/insertCoin/video/tunnel.mp4
(Stored with Git LFS)
Normal file
Binary file not shown.
657
demos/kit.lua
Normal file
657
demos/kit.lua
Normal 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
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
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
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
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
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
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
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
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
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
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
BIN
demos/mirrorball/art/twister.png
(Stored with Git LFS)
Normal file
Binary file not shown.
12
demos/mirrorball/games.dat
Normal file
12
demos/mirrorball/games.dat
Normal 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" }
|
||||
}
|
||||
}
|
||||
949
demos/mirrorball/mirrorball.singe
Normal file
949
demos/mirrorball/mirrorball.singe
Normal 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
BIN
demos/mirrorball/music.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
301
demos/mirrorball/sync.lua
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
demos/neonCoast/art/skyline.png
(Stored with Git LFS)
Normal file
Binary file not shown.
12
demos/neonCoast/games.dat
Normal file
12
demos/neonCoast/games.dat
Normal 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
BIN
demos/neonCoast/music.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
1030
demos/neonCoast/neonCoast.singe
Normal file
1030
demos/neonCoast/neonCoast.singe
Normal file
File diff suppressed because it is too large
Load diff
273
demos/neonCoast/sync.lua
Normal file
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
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
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
BIN
demos/neonCoast/video/tunnel.mp4
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
BIN
docs/images/learn/29-databases.png
(Stored with Git LFS)
Normal file
Binary file not shown.
133
docs/learn/29-databases.singe
Normal file
133
docs/learn/29-databases.singe
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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
BIN
docs/learn/art/stages.db
Normal file
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
488
docs/lessons/29-databases.adoc
Normal file
488
docs/lessons/29-databases.adoc
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
671
src/mic.c
Normal 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
98
src/mic.h
Normal 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
|
||||
874
src/singe.c
874
src/singe.c
File diff suppressed because it is too large
Load diff
359
src/vfs.c
359
src/vfs.c
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
381
src/voice.c
Normal 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
103
src/voice.h
Normal 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
|
||||
218
testScripts/micVoice/micVoice.singe
Normal file
218
testScripts/micVoice/micVoice.singe
Normal 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
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
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
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
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
Loading…
Add table
Reference in a new issue