Commit Graph
94 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 2764919bb8 Stream long sounds from an Opus copy instead of decoding them (#349)
A sound was decoded whole as soon as the project opened, in the editor
and in a released game: five minutes of stereo held some 115 MB for as
long as the page was open. A sound with Stream on now plays from a copy
as Opus in Ogg, fetched a piece at a time and decoded a few seconds
ahead by WebCodecs, so a game holds a few megabytes for it.

- The Stream switch is a row of the sound, off by default. The Sound
  tab suggests it from a minute on and says how far the copy has got.
- StreamKeeper makes the copies in the browser, a moment after the
  last change, one at a time: the sound's own and one per clip of its
  cut, fades and Reverse baked in. A copy that is no longer wanted is
  taken off and deleted once the project is saved without it.
- streamTable says which played names stream from which copy. A clip
  without a fitting copy, a Sample and a Reverb impulse stay decoded.
  A sound with Stream on is never decoded ahead.
- The page fetches by Range and takes the whole file from a server
  that ignores it, as the CI's PHP server does. It keeps the bytes
  and the first two seconds decoded, the rest goes four seconds ahead.
- A jump decodes 32 packets before the place first. Measured in
  Chrome, what follows is then the same to the bit as decoding from
  the start. With 4 packets it was 0.06 off.
- The stream-player on the FX worklet reads the samples at the rate
  of the context along a Catmull-Rom curve. What comes late is silent
  and skipped once it is there, so a clip keeps its beat.
- The opening stretch goes with the player as it is made. Sent by
  message it came a few milliseconds late, and 557 samples of the
  attack were lost.
- Pitch on a streamed sound is a shifter after the player, as on a
  bus: the length stays and it is heard 42.7 ms late.
- Bakes of patterns and songs (#340) always stream now.
- The song timeline draws a streamed sound from its copy decoded at
  8 kHz, keeping only the peaks.

Tests:
- Gate: CHECK EXIT 0, 280 files, 4312 tests (31 new: ogg +3,
  stream-queue 6, streams 11, streaming 6, sound-library +5).
- e2e 34: ALL PASSED, 106 checks (7 new). Five minutes of mono,
  4.8 MB as WAV, 3.6 MB as its copy: streamed the renderer grows by
  5.8 MB, decoded by 69.6 MB. Offline at 48 kHz, start, loop seam and
  a reversed clip are the copy decoded whole to the sample (0).
- Counter-checks red as they should be: no pre-roll, no catch-up of
  late samples at the end, no opening stretch, a streamed sound
  decoded ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:27:32 +02:00
adminandClaude Opus 5.5 84cd36f902 Bake a pattern or song to Opus, played in its place by a game (#340)
game-maker / gate (push) Successful in 3m49s
game-maker / suite (push) Successful in 15m6s
A pattern or song played by a game runs every note through the
scheduler, the instruments and their FX, live. Bake works that out
once, keeps it as a file on the asset, and Play pattern and Play song
play the file instead, as one voice, for as long as nothing it is made
of has changed. The tabs always play live.

- Bake is an icon in the header of the Pattern and the Song tab, lit
  while the bake holds. A click bakes, cancels a bake that runs, or
  offers Bake again and Remove bake. The toolbar shows the progress.
  The info of the tab says "baked" or "bake outdated".
- The bake holds everything up to the output of the pattern or song.
  The sound FX of the node, fx params, volume, fade in, bus, Master
  routing and stopping with a fade stay live.
- A print of the worked-out plan decides whether it holds: notes and
  clips, tempo, length, loop, tracks, the build of each instrument for
  each note, sound FX, files, cuts and levels. A rename keeps it, an
  undo back to the baked state makes it hold again.
- The file holds the straight run and then one more round of the loop
  as heard the second time, tails included. Looped, it plays to the
  loop end and then round that round, to the sample. Once, it fades
  out over its last 10 ms. Let ring fades it out over 0.3 s.
- It is worked out by a second SoundLibrary on an OfflineAudioContext,
  its scheduler woken by suspends of the offline clock, and what
  reaches the master is taken. A worklet probe tells whether that was
  mono, so a mono bake goes on exactly as the notes did.
- Opus in Ogg, 160 kbit/s stereo or 96 kbit/s mono, encoded with
  WebCodecs, the Ogg pages written by hand. Normalized to full scale,
  the peak kept and played back.
- The file goes up into the sounds of the account as "[bake] name",
  which the sound picker leaves out. An old bake file is deleted once
  the project is saved without it. Only a bake that holds is loaded.
- Ogg files are decoded at 48 kHz: resampled to 44.1 kHz, the loop of
  a bake lost a sample a round.
- Baking and removing a bake are no undo steps, like the EQ's zoom.

Findings:
- Chrome's AudioEncoder refuses opus.format 'ogg' (NotSupportedError)
  but hands out the OpusHead with a pre-skip of 312 for 'opus'.
- The Song tab's header is full at 1280 px: a captioned Bake pushed
  Fit off the tab. Hence the icon.

Tests:
- Unit: tests/core/ogg.test.ts (6), tests/core/bake.test.ts (16),
  four new in sound-library.test.ts, one in song-play.test.ts. One
  test cuts, reverses and fades a clip on a song's track: each change
  outdates the bake, each undo makes it hold again.
- e2e 64 (melektaus-atx): ALL PASSED, 36 checks (6 new). Live and baked
  rendered offline differ by at most 0.06 dB over 114 windows of
  20 ms, two loop ends among them. e2e 65 had no red check with the
  icon in its header.
- Gate (melektaus-atx): CHECK EXIT 0, 277 files, 4281 tests.
- Counter-checks: print without instrument builds, print taking the
  sound's file instead of the clip's cut, round taken from the first
  pass, bake voice without its looping source, Play pattern or Play
  song ignoring the bake: one unit test red each. Pre-skip set to 0:
  the e2e comparison 4.8 dB off, only that check red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:13:11 +02:00
adminandClaude Opus 5.5 687377682c Draw the EQ's curve in its node and a spectrum behind it (#375, #374)
Both were asked for in #367 "for later". The node of a Parametric EQ
now shows its curve in small, and its tab shows what passes the node
while it is seen.

#375, the curve in the node:

- NodeDef has an optional `picture`: a height, and shapes made from
  the settings as plain point lists (guide, area, line). It is
  general, the Parametric EQ is its first user.
- NodeView draws it in a box below the rows, again only when the box
  or the settings change. A drag in the EQ tab changes them, so the
  node follows it live.
- Without a band the box stays, flat on its 0 dB line, so the node
  does not grow at the first band.
- The axes of the curve, `eqCurvePoints` and `fillShapes` move from
  editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node
  draw the sum from the same function.

#374, the spectrum:

- A node marked `tapped` gets a `tap` stage in front of its own
  stages and one behind them when compiled, in a sound FX and in an
  instrument alike. Its id is asset and node, since a copied asset
  keeps its node ids.
- A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts)
  holds the tap of every voice and bus while it plays.
- While the EQ tab is seen, in the tab row or in a window, the taps
  of that node feed one analyser per side (8192 points). Nothing is
  made before a tap of the node plays, so no AudioContext is opened
  early.
- The tab draws both behind the curve, on a scale of 0 to -96 dBFS:
  the sound coming in faint and grey, the sound going on in the
  colour of a sound port. The analyser smooths it, and once the
  sound has gone the sum still hands it silence, so it falls away.
- Once the tab is not seen, Editor.tick lets the analysers go, like
  the meters of the Song tab.
- A tap does nothing to the sound, so `stats().last.stages` leaves it
  out, and a bypassed EQ keeps its taps.

Decided on my own:

- Every sound passing the node counts: Play of the sound FX tab, an
  instrument's keys, a song, a run. The issue proposed that.
- The spectrum has no labels of its own. Its scale is in the wiki.

Tests:

  Gate (melektaus-atx)     275 files, 4247 tests (11 new), EXIT 0
  e2e 34 (melektaus-atx)   ALL PASSED, 98 checks (2 new), twice

Counter-checks, each put back by copy and compared with cmp:

- The node's curve drawn without its bands: 2 unit tests fail.
- The spectrum without the loudest bin between two points: 1 fails.
- SpectrumTaps.stop leaving the taps linked: 1 fails.
- Taps that never feed the analyser: e2e 34 fails in exactly the
  spectrum check (frames 0). Run in the container only.

The screenshots of the second run were looked at: the blip as one
peak behind the curve, and the dip at 4 kHz in the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:58:53 +02:00
adminandClaude Opus 5.5 25084f8576 Refactoring round 10: split SoundLibrary, fold sound duplicates (#85)
game-maker / gate (push) Successful in 4m3s
game-maker / suite (push) Successful in 14m52s
Refactoring round 10 covers everything since round 9 (808e1ee): 110
commits with about 47 000 new lines, nearly all of them sound, sound
FX, instruments, patterns and songs. Behaviour is unchanged.

Duplicate code (jscpd over src, 50 tokens, TypeScript only, measured
on 808e1ee and here alike: 47 clones / 426 lines before, 33 / 245
after; round 9 itself ended at 22 / 160 by this count):

- PreviewParams (new, editor/soundfx) is the header's parameter bar
  of the Sound FX and the Instrument tab: values, defaults, fields
  and the voices a moved field reaches. Both tabs had it line for
  line.
- fxWorklet: an Effect base runs the bit crusher, phaser, flanger,
  limiter, LFO and bus shifter, a Source base the pitch player and
  the Chip. Start is the one wait for a voice's first sample, which
  four processors wrote out each.
- nodes/shared has checkPort, detachedPort, fxParamsPort and
  letRingPort. The Play and Stop nodes of sounds, notes, patterns and
  songs spelled each port out, up to five times.
- parameterValues builds both Sound FX parameters and Instrument
  parameters, which differed in name and text only.
- SetLoopCommand sets the ruler loop of a pattern or a song. It
  replaces SetPatternLoopCommand and SetSongLoopCommand.
- addLink in music/routing is the one rule for a link that both the
  song routing and the Master routing keep.
- eqCurves works out each band and their sum for the EQ tab, which
  did that in its drawing code. It replaces eqCurveDb, which only a
  test called.
- Smaller ones: the eight neighbour directions of a tile cell, and
  drawFloatingBox for the shadowed box of Popup and FloatWindow.
- Left on purpose: Play pattern and Play song stay two parallel node
  definitions, and the smaller clones under ten lines stay.

Architecture:

- SoundLibrary.ts goes from 2897 to 2188 lines. What a stage of a
  sound FX becomes as Web Audio nodes, and how the knobs of a playing
  voice move, is now viewport/fxNodes.ts. None of it keeps state of
  the library.

Dead code (TS language service, references per export and member):

- soundRefIds, LFO_SHAPE_INDEX, TREMOLO_ROWS, VIBRATO_ROWS and
  REVERB_ROWS had no reader. The four routing node defs are
  registered without an unused exported name.
- soundFileOf and portTypeOf were called by tests only. The tests
  now ask findSound and portOf, which the editor uses. DRY moves
  from src into the one test that uses it.
- 51 values used only in their own file lose their `export`. Types
  and the node catalogues stay exported.
- Kept on purpose: neighboursOf is the cell by cell reading the
  indexed neighboursIn is checked against, and PitchShifter.latency
  is the test's one view of how late the shifter is.

Comments and docs:

- Seven comments told history ("no longer", "any more", "as
  before", "of before #354"). Nine comment blocks ran far past the
  line width. Proof that this changed no code: the bundle was md5
  identical, and the touched tests and e2e modules were identical
  after esbuild.
- README: sound FX, instrument, pattern, song and bus in the asset
  list, the pages Instruments, Patterns and Songs, 1919 checks.
- Wiki: see its own commit.

Tests:

  Gate (melektaus-atx)    274 files, 4236 tests, CHECK EXIT 0
  e2e 34, 64, 65          ALL PASSED, 180 checks
  Unit, touched areas     16 files, 804 tests

No check was added, the e2e sum stays at 1919. Counter-checks, each
put back by copy and compared with cmp:

- addLink without the loop rule: 2 tests fail, one per routing.
- eqCurves with the sum of the last band only: 1 test fails.
- Start in the worklet never waiting: e2e 34 fails in exactly the
  four checks that hang on a voice's first sample (Flanger, Tremolo,
  Chip, a pitched note planned ahead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:31:33 +02:00
adminandClaude Opus 5.5 db15422372 Keep a bus's level when a tab plays the first sound of a page (#369)
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 15m14s
A bus of the mix played at full level, without its sound FX and
straight into the master, until something on it changed. It happened
when the first sound after loading came from the Song or Pattern tab.

- `bus()` looked a bus up before it asked for the audio context. The
  context made then sets up the whole mix, this bus too, and `bus()`
  overwrote it with a fresh one at gain 1.
- `setMix` does nothing for the same mix again, so the wrong bus
  stayed until the mix changed.
- Since #368 the tabs play into the master, so it was the master's
  volume and FX that went missing on the first preview.
- `bus()` now asks for the context first and looks the bus up after.

Tests:
- Unit sound-library, buses, master-routing: 139 green, 1 new.
- The new test was red before the fix: mix:b at gain 1 into the
  master instead of 0.25 into mix:a.
- Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4231 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:09:41 +02:00
adminandClaude Opus 5.5 0e73fd85b9 Route sounds, songs and buses in one Master routing graph (#351)
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:46 +02:00
adminandClaude Opus 5.5 490fcba88f Meter each track and the song, and limit the output at -1 dBFS (#359)
game-maker / gate (push) Successful in 3m43s
game-maker / suite (push) Failing after 25m11s
A song could clip at the output without anyone noticing: a track and a
sound item at +12 dB each add up to +24 dB. The Song tab now shows how
loud each track and the whole song are, and a fixed limiter behind the
master keeps every sample under -1 dBFS in the editor and in the game.

- Each track head has a stereo meter up its right edge. The head is
  190 px wide instead of 176.
- Out sits in the corner above the heads, with the loudest peak since
  Play in dB. In the tab header it made the row too wide for a 1280 px
  window, and the inspector lost its fields.
- The meters read AnalyserNodes beside the buses, one per side after a
  channel splitter, so a peak panned hard is not halved. They are tapped
  only while the song plays and its tab is shown.
- A clip lamp lights past 0 dBFS and stays through Stop. The next Play
  or a click on the meter puts it out.
- The meter of a track that mute or solo keeps from being heard is grey.
- Music always plays through a bus of its own, with or without a sound
  FX. That bus is what Out measures.
- The limiter is the one from #326 and needs the FX worklet. Until that
  is loaded the master goes straight out.
- It is 5 ms late. latency() counts that in, so the playhead and the
  alignment of a recorded take follow it.
- Unit tests build SoundLibrary without the limiter, as their wiring
  checks follow every node of a voice.
- Out shows "-inf" while nothing was loud. The UI font has no infinity
  sign, which the font test found.

Tests:
- Unit meter-view 8 new, sound-library 112 (2 new), 3 counter-tests
  each red
- e2e 65 (melektaus-atx): ALL PASSED, 55 checks (3 new). Out wrote
  +16.6 dB, behind the limiter the loudest sample was 0.8913
- Gate (melektaus-atx): 272 files, 4209 tests, one red in the font
  test. Fixed after it, then the font and meter tests ran green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:08:39 +02:00
adminandClaude Opus 5.5 25c1d02b06 Keep the audio thread free of ended voices and too many vocoders (#356)
game-maker / gate (push) Successful in 3m45s
game-maker / suite (push) Successful in 14m44s
A song that had been played a few times in a long session stuttered
at regular intervals until the page was reloaded. A quick run up the
keys of a sampled instrument made every sound drop out for a second
or two. Both came from work the audio thread should not have had.

- Every worklet node of a voice listened on its port. Chrome never
  collects a port that is listened to, and through its listener not
  the voice either. Every node of the voice stayed on its bus and was
  worked on in every block. A way or stage on the worklet now closes
  its port once it ends or is stopped, and the processor closes its
  own side when it is told to stop.
- A voice without echoes now leaves its bus as soon as its sound and
  what its stages hold back are through. Before, only a voice with
  echoes did.
- At most 12 ways play through the pitch worklet at once. One more
  cuts the oldest in 10 ms, a note already let go before one still
  held. Each runs a phase vocoder, whose first block alone took
  2.7 ms of the 2.67 ms a block has on stein2.
- Play for the game ends everything the tabs play: the piece holding
  the transport and every key or preview (`stopPreviews`).
- Closing a Sound, Sound FX, Instrument, Pattern or Song tab stops
  what it plays of that asset (`AssetTabEditor.closed`).
- A preset in the Instrument tab plays nothing any more. With
  Audition on it is heard like any other change.
- Test hooks: `__gm.instrument.play`, `__gm.sound.playing`, and
  `vocoders` in the stats of the sound library.

Measured:
- A copy of the project, its Song 2 played for 12 s and stopped, six
  times (Chrome on stein2, CDP WebAudio): before, 746 nodes were left
  after the first time and 4469 after the sixth, the load at rest
  grew from 0.01 to 0.06. After, 7 nodes are left every time (the
  master and one bus) and the load at rest stays at 0.01 to 0.02.
- Pitch-player voices live in Chrome on melektaus-atx: 12 take 0.50
  of the audio thread, 16 take 0.71, 24 take 0.99. 30 struck 40 ms
  apart held it at 0.99 from the first second on.

Tests:
- Unit sound-library: 110 green (3 new), 4 counter-probes each red
- e2e 34 (melektaus-atx): ALL PASSED, 86 checks (3 new, 2 changed)
- e2e counter-probe with game start and preset undone: their 3
  checks red, and the old check of Play note in the game as well,
  which now counts on the preview being gone
- e2e counter-probe with tab close undone: exactly its check red. A
  first form of it held a key and stayed green, since the keys let go
  of a held key by themselves once the tab shows no asset
- Gate (melektaus-atx): 270 files, 4181 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:57:18 +02:00
adminandClaude Opus 5.5 7a17030472 Give every sound FX effect a Bypass and a Wet (#354)
A node could only be taken out of a sound FX by unplugging it, and
only six of them could be blended with the sound they get. Every
effect now has both, in the sound FX graph and in the instrument
graph alike. The Mix, the Input and the Output have neither.

- Bypass is a checkbox setting. A bypassed node is left out when
  the graph is compiled, so it costs nothing and holds nothing back.
  The graph draws it faded, and its body says "Bypass: true".
- Wet is a number input like the others. It can be a parameter, be
  moved by Set sound FX parameters and, where the node allows it for
  its other numbers, follow an Envelope.
- A blend is a stage of its own (`wet`) around what the node adds.
  The sound as it came in is held back as far as the node is late,
  so a Compressor, a Limiter or a Vibrato do not comb it out.
- The six nodes that had a Mix keep their key `mix` and only call
  it Wet. Saved values, parameters and edges stay as they were.
- The Delay crossfades now: the sound alone at 0, the sound and its
  echoes in full at 0.5, the echoes alone at 1. Before, the sound
  was always in full and `mix` was the echoes.
- The Pitch takes its semitones by Wet instead of blending, through
  a share `times` on the amount that an instrument binds as well.

Findings:

- A Delay saved before this sounds different. It sounds the same at
  half its old value. The two chip presets with a Delay (Echo Lead,
  Bell) are halved to keep their sound.
- Project cbbf160bb7e8 has two Delays (mix 1 and 0.5). They are
  left as they are.

Tests:

- Unit: soundfx 65 (8 new), sound-library 107 (3 new), instrument
  35 (1 new), envelope 13. Two delay tests and the filter ports test
  follow the new numbers.
- Counter-checks: 8, each turns its test red.
- e2e 34 (melektaus-atx): ALL PASSED, 83 checks (2 new). The
  ping-pong check at wet 0.25 measures what it did at mix 0.5.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4154 tests.
- After the gate the derived ports of a blend are asked for once
  instead of twice: typecheck, lint and the three unit files green
  again (113 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:20:52 +02:00
adminandClaude Opus 5.5 d349251a01 Put a sound in a room, a hall or a cave with a Reverb node (#328)
game-maker / gate (push) Successful in 4m0s
game-maker / suite (push) Failing after 13m51s
A sound FX had no space around it. The Reverb node folds the sound
with an impulse response: one it makes itself, or a sound asset as the
answer of a real space. It works on a voice and on a bus.

- Type: Room, Hall, Cave and Plate have fixed numbers. Custom shows
  Decay (up to 10 s), Damping and Pre-delay. Sound shows the pick of a
  sound asset and Pre-delay. The rows are derived ports, the sound pick
  an idle param, so they only appear for their type.
- The made impulse response is seeded noise per side, 60 dB down at
  Decay, through a low-pass closing from 20 kHz toward 500 Hz as far
  as Damping says. It is made once per rate, decay and damping, the
  last 16 are kept.
- A sound as impulse response: the sound FX compilers now get the
  project, so a node can turn a sound asset into its file. The build
  key holds that file, so a cut or swapped sound builds again. The
  instrument compiler does the same for a Reverb inside an instrument.
- The library loads that file with the voice, a sequence or before a
  bus builds its chain. A missing file leaves the reverb silent.
- Mix blends dry and reverb and is the only number that moves while
  the sound plays, as the issue said. The voice rings for pre-delay
  plus decay, or as long as its sound.
- The open point from #325 about sends on project buses is #351, not
  part of this.

Tests:
- Unit reverb (new) 4, soundfx 57 (4 new), sound-library 104 (2 new),
  green.
- Counter-checks, each turns one test red: the impulse file not loaded
  with the voice, the tail without the length of the sound.
- e2e 34 (melektaus-atx): ALL PASSED, 81 checks (1 new). Chrome
  offline: silent during the pre-delay, 35.9 dB fall over 0.6 s of a
  1 s decay (36 expected), the dry half at 0.25 for mix 0.5. A sound
  with one sample at 0.2 s gives the impulse back 0.25 s later and
  nowhere else.
- Gate (melektaus-atx) over the end of this series #318 to #328:
  CHECK EXIT 0, 267 files, 4134 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:27:22 +02:00
adminandClaude Opus 5.5 071f194326 Swing the volume or the pitch with Tremolo and Vibrato (#320)
Alarms, sirens and wobbly retro sounds need a volume or a pitch that
swings. Two new nodes do that, on a voice and on a bus alike.

- Tremolo: Rate (0 to 20 Hz), Depth (0 to 100 %), Shape (Sine,
  Triangle, Square, Saw). The volume swings between full and
  1 - depth.
- Vibrato: Rate (0.5 to 20 Hz), Depth (0 to 4 semitones), Shape. The
  shape is the curve of the pitch, a Square jumps between two pitches.
- As the issue decided: two nodes, phase 0 on the first sample of the
  voice, no fade-in time. Both run on the FX worklet, the classes are
  in lfo.ts and tested in Node.
- Deviation from the issue text, as in the plan: the Vibrato is a delay
  that swings, not playbackRate. A bus has no playbackRate, and the
  sound keeps its length.
- The delay swings around a center taken from the start values. The
  center counts as the latency of the node, so a voice starts that
  much earlier and a Mix and a bus make up for it.
- Known limit, in the node text and the wiki: a depth turned up or a
  rate turned down after the start asks for a delay below 0. The lower
  half of the swing then flattens.

Finding: the first e2e run was red at the edge of a window. The phase
is summed up, so the square flips one sample later than 2400 steps of
10/48000 suggest. The check keeps 10 samples away from the edges.

Tests:
- Unit lfo (new) 5, soundfx 53 (2 new), sound-library 102 (1 new),
  green.
- Counter-checks, each turns one test red: the swing turned over (up
  and down swapped), the start time not passed to the worklet.
- e2e 34 (melektaus-atx): ALL PASSED, 80 checks (1 new). Chrome
  offline: Tremolo square silent before its voice, then 0.5 and 0 for
  50 ms each. Vibrato square at 5 Hz, 1 semitone: 1059.5 Hz and
  940.5 Hz.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:13:18 +02:00
adminandClaude Opus 5.5 d3a4cb7d0e Warm up or distort a sound with a Saturation node (#323)
A sound FX had no way to bend a sound. The Saturation node pushes it
into a curve, from warmth to a hard distortion, on a voice or a bus.

- Eight types, each a fixed curve made once: Soft (tanh), Cubic, Hard
  clip, Tube, Fuzz, Foldback, Sine fold and Rectify. The issue asked
  for as many as useful.
- Drive (0 to 48 dB) is a gain in front of the curve. The curve spans
  256 times full scale, so the drive glides like any gain.
- Output and Auto-gain blend as on the Compressor. Auto-gain brings a
  sine at -12 dBFS back to the RMS it came in with.
- Tone is a low-pass after the curve, 1 kHz at 0 to 20 kHz at 1, as
  recommended in the plan. A 10 Hz high-pass takes out the DC the
  lopsided types make.
- Mix blends with the dry sound. The 4x oversampling is 192 samples
  late in Chrome, so the dry way is held back as far. The node counts
  as late by that much for voices, Mixes and buses.
- A test hook gives e2e the auto-gain at a drive.

Tests:
- Unit saturation (new) 5, soundfx 51 (2 new), sound-library 101
  (1 new), green.
- Counter-checks, each turns tests red: no hold-back of the dry way
  (1), the level without auto-gain (2).
- e2e 34 (melektaus-atx): ALL PASSED, 79 checks (1 new). Chrome
  offline: 192 samples late at 48 and at 44.1 kHz. Half dry at 1125 Hz
  0.00 dB. A sine at -12 dBFS driven 24 dB into Soft comes out at
  0.00 dB with full auto-gain.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:04:16 +02:00
adminandClaude Opus 5.5 a645026568 Tame the loudness with a Compressor and a Limiter node (#326)
A sound FX could not control dynamics. A Compressor squeezes the
loudness together, and a Limiter keeps every peak below a line. Both
work on a voice and on a bus, where they tame everything at once.

- Compressor: DynamicsCompressorNode with Threshold, Ratio, Knee,
  Attack and Release, then Makeup and Auto-gain from 0 to 1. After the
  node the sound gets (1 - w) * makeup + w * A dB. A is what the curve
  takes off at 0 dBFS.
- The node adds 0.6 of A by itself, as the spec says. That gain is
  taken back out, so 0 really means no makeup.
- The knee shape is left to the browser by the spec. dynamics.ts ports
  Chromium's curve. It gives the gain Chrome was measured to add at
  four settings to three places.
- Limiter: a class on the FX worklet with Threshold, Ceiling, Release
  and Auto-gain. It looks 5 ms ahead: the smallest gain of the window
  ahead, averaged over the window, then a release. No sample passes the
  lower of threshold and ceiling. Auto-gain raises it toward the
  ceiling.
- Deviation from the plan in the issue: the Limiter has a Threshold as
  well. Without it Auto-gain has nothing to measure against.
- Latency is made up on voices now too. A voice starts as much earlier
  as its stages are late (Compressor 6 ms, Limiter 5 ms). The other
  way of a Mix and the other layers of a note are held back to the
  latest, and a bus counts it into its latency.
- A voice with a Limiter counts as ringing, so the worklet is stopped
  only after what it still holds is out.

Tests:
- Unit dynamics (new) 8, soundfx 49 (3 new), sound-library 100 (4 new),
  green.
- Counter-checks, each turns tests red: no look-ahead in the Limiter
  (3), no taking back of the node's gain (1), a voice not started
  earlier (1), no stage latency on a bus (1), no hold-back in a Mix (1).
- e2e 34 (melektaus-atx): ALL PASSED, 78 checks (1 new). Chrome
  offline: Compressor 288 samples late, Limiter 240. Full scale through
  -12 dB 4:1 hard knee: +0.19 dB with full auto-gain, -8.81 dB without.
  A sine at 2 through the Limiter at -6 dB peaks at -6.00 dB, with
  auto-gain 1 at -1.00 dB.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:57:22 +02:00
adminandClaude Opus 5.5 c99bda6489 Move a sound left or right with a Pan node (#319)
A sound FX had no way to place a sound in the stereo field. The new
Pan node does that on a voice, a bus and a track alike.

- One input, Pan from -1 (left) to 1 (right), 0 by default. Behind it
  is a StereoPannerNode. A typed-in 0 leaves the node out, a pan fed
  from a parameter stays at any value.
- It moves with Set sound FX parameters, and an Envelope can move it
  in an instrument.
- As the issue decided, no pan from the position in Play sound and no
  width control for stereo sounds.

Finding: the issue and the plan said a stereo sound gets a balance.
Web Audio pans stereo differently: the side it goes to stays and the
other side moves over into it. The node description and the wiki say
so, the e2e check measures it.

Tests:
- Unit soundfx 46 (1 new), sound-library 97 (1 new), green.
- Counter-check: a pan from a parameter at 0 left out, 1 test red.
- e2e 34 (melektaus-atx): ALL PASSED, 77 checks (1 new). Chrome
  offline, a mono sine at 0.5 gives 0.5 / 0 at -1 and 0.354 / 0.354
  at 0. A stereo one at 0.5 panned 0.5 gives 0.354 / 0.854.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:47:53 +02:00
adminandClaude Opus 5.5 e1ca6a5f5a Shape a sound with every filter type, a Q and a slope (#318)
The Filter node from #335 only knew low-pass, high-pass and band-pass
and had a Resonance from 0 to 1. It now has every type Web Audio
offers, a Q as a plain factor and a slope up to 48 dB per octave.

- Types: low-pass, high-pass, band-pass, notch, low shelf, high shelf,
  peaking and all-pass. The cutoff row is now labelled Frequency, its
  key stays `cutoff`.
- Q from 0.1 to 30, 0.707 by default, replaces Resonance. Web Audio
  takes the Q of a low- or high-pass in dB, so it is converted when the
  voice is wired. No project used the node.
- Gain in dB is an input only for the shelves and the peak. It is a
  derived port, so the sound FX compiler now resolves derived ports.
- Slope 12, 24 or 48 dB/oct runs 1, 2 or 4 filters in a row. They
  share one cutoff carried by a ConstantSource, only the last has the
  Q. Shelves, peak and all-pass hide the row.
- The Chip Presets get the Q their resonance gave before. Mutate moves
  Q in octaves and gain by up to a dB.
- bindStage in the instrument compiler binds every amount of a stage
  generically, so new stages need no case of their own there.

Finding: FLAT_Q was in dB and the delay damping used it. It is a
factor now, the damping takes it through passQ and is unchanged.

Tests:
- Unit envelope 13, sound-library 96 (2 new), chip-presets 14,
  soundfx 45, all green.
- Counter-checks: the Q on every filter in a row, the gain row for
  every type. Each turns one test red.
- e2e 34 (melektaus-atx): ALL PASSED, 76 checks (1 new). Chrome
  offline, 2 kHz through a low-pass at 1 kHz: -12.37 dB at 12 dB/oct,
  -49.5 dB at 48. A +6 dB peak gives 6.00 dB, a notch -124 dB.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:43:49 +02:00
adminandClaude Opus 5.5 daeffe0f9d Route the tracks of a song through a graph (#350)
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s
Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:18:09 +02:00
adminandClaude Opus 5.5 3a52f2290f Mix sounds through buses and a master (#325)
game-maker / gate (push) Successful in 3m36s
game-maker / suite (push) Failing after 14m30s
Until now every sound went straight to the speakers. There was no
point where all of them came together, so nothing could turn the
music down against the effects, and a compressor or a shared echo
over everything had nowhere to sit. The bus technique of #346 was
there already, only the end of the chain was missing.

- Bus asset (Audio): Volume, Sound FX and Into. Into is empty for
  the master and never offers the bus itself nor a bus that already
  goes into it. The list is worked out when the menu opens, so a
  change made meanwhile counts. A loop that is in a project anyway
  sends the buses on it into the master.
- The master is no asset. Its volume and sound FX are two new rows
  under Master in Project settings, one undo step each. A project
  written before has 1 and none, which is how it sounded.
- Sound, instrument, pattern and song have a row Bus, empty for the
  master. Play sound, Play note, Play pattern and Play song have an
  input bus: From the asset, Master or a bus. Inside a song or a
  pattern only the bus of the song or pattern counts.
- Set bus volume (bus, volume, time, easing) and Bus volume. An
  empty bus is the master. The volume set holds for the whole run,
  across scenes, over that of the asset. GameRun resets it at start
  and end. Set sound FX parameters without a voice now reaches the
  sound FX of the bus picked in its new bus input.
- SoundLibrary: the master is the bus "mix", each bus "mix:<id>".
  Every bus, the playing masters of #346 included, ends there, and
  only the master is heard. setMix sets them up once a context
  exists, so an editor nobody listened to opens no audio device. The
  same mix again changes nothing, which lets the editor hand it over
  on every change of the project.
- The tabs play as the game does: Sound, Sound FX, Instrument,
  Pattern and Song tab through the bus of their asset and the master.
- Pause game does nothing to the buses (the user's answer 4b). A
  game that wants quiet in its pause menu sets a bus volume itself.
- Streaming long music files is #349, split off as asked.

Finding on the way: the Into menu first worked from a list made when
the inspector row was built. After another bus was sent into the
open one, e2e 34 saw it still offered. It is now asked on open.

The SoundLibrary tests expected voices to end at the destination.
They now end in the master, which adds its input, level and pan to
every wiring and two gains to every list. Those expectations are
updated, nothing else in them changed.

Tests:
- Unit buses (new): 15 green. Ten counter-checks, each red on its
  own: loop check, bus picked, reset at run start, bus of Set sound
  FX parameters, master volume in the settings, and in SoundLibrary
  default into the master, voices into the master, run volume over
  the mix, no context before a play, a playing on its bus.
- Unit sound-library: 90 green (5 new). Node catalog: 416 green
  (9 new cases).
- e2e 34 (melektaus-atx): ALL PASSED, 75 checks (3 new). The voice
  of the Sound tab was heard on mix:<bus>, that bus into the other,
  that one at 0.5 into the master. Master volume 0.6 via the dialog,
  undo back to 1.
- Gate (melektaus-atx): CHECK EXIT 0, 262 files, 4072 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:13:32 +02:00
adminandClaude Opus 5.5 4c7861b5b8 Keep the frame rate up while painting a big tilemap (#347)
Painting on the 60x60 map of a project with fifty objects dropped the
editor to 20 to 39 fps. A profile of a brush stroke showed four causes,
none of them the brush itself.

- Each frame of a stroke previews the model. The asset and hierarchy
  panels then built every row anew, so every label became a new text
  and a new upload to the GPU. One run spent 46 % of the main thread
  in those uploads. ListView.setItems now keeps a row whose item draws
  the same (sameListItem) and builds only the rows that changed.
- The brush drew the whole scene on every pointer event, on top of the
  preview. Now the preview draws what the stroke changed, once a frame.
  The rectangle tool asks for one drawing a frame for its box.
- An animated tile made the view draw in the tick and the preview drew
  it again in the same frame. animate now asks just before the frame
  is drawn and stays out when the view was drawn since the tick began.
- layoutSymbols ran on every drawing and costs up to the cube of the
  symbols. SymbolLayoutMemo keeps the spots while no anchor moves.
- A painted cell made the map read every "x,y" key again, and once
  more for each of the eight neighbours of every cell. layerCells reads
  a layer once per content into an index by place. layerTiles and the
  blocking cells take their neighbours from there (neighboursIn).
  parseCellKey reads a key without split, with the same results.

The test hook sceneRedraws counts how often the scene view was drawn.

Not done: a painted cell still works out the tiles and blocking cells
of the whole map again, so that part grows with the map.

Tests:
- Brush stroke on a copy of the project (stein2, Chrome): 20 to 39 fps
  before, 47.5 after, 55 to 57 without painting. Labels drawn again
  during the stroke 2012 before, none after. Scene drawings per frame
  1.32 before, 1 after.
- One painted cell, headless: 28 to 16 ms on 60x60, 109 to 49 ms on
  the same map doubled to 120x120.
- frame-preview: 2 new, red with the old pointer redraw.
- tilemap: 3 new (key reading, index, refresh), list-ping 1 new,
  symbol-layout 1 new. 5 single reversals turned each red.
- e2e 39,40 (melektaus-atx): ALL PASSED, 107 checks (1 new, a fast
  stroke: 6 drawings in 6 frames, no label drawn again). With the row
  reuse and the pointer redraw reverted it fails: 24 drawings in
  3 frames, and the panels draw their labels again.
- Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4038 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:54:59 +02:00
adminandClaude Opus 5.5 101b12504e Play a sound backwards with a Reverse switch on the asset (#321)
A sound asset can now play its stretch backwards. The switch sits on
the asset, so every way the sound is heard plays it reversed: Play
sound, a song item, an instrument's Sample and a Pitch in a sound FX.
There is no Reverse node in the sound FX graph. The user dropped it,
because playing backwards needs the whole sound in advance, which a
bus or a Chip does not have.

- The cut gets a `reverse` flag, stored as the asset property
  `reverse` (default off). The cut name ends in `~reverse` when it is
  on, so a reversed sound over the whole file is a cut of its own.
- The handles keep marking the same part of the file. The reversed
  copy is made once, like any cut, and the fades follow what is heard:
  Fade in ramps at the end handle, Fade out at the start handle.
- The Sound tab has a toggle with a left-pointing play triangle. Its
  playhead runs from right to left, and the wave draws the ramps at
  the edges they now belong to. The inspector has a Reverse row.
- The icon is a tab header icon (`HEADER_ICON_SVG`, next to the snap
  magnet), not a top bar icon. The first gate run had it in the top
  bar set, and the test that fixes that set went red.
- A song item draws the waveform of a reversed sound backwards.
  Lengths stay as they are.
- The header label reads "Fade in" instead of "Fade in (ms)" and the
  fade fields are 8 px narrower. With the new button the readout was
  cut to "0.03 s of 0..." at 1280 px, now it shows in full. The
  inspector keeps the unit.

Tests:
- Unit sounds 22, sound-library 85 (1 new each). Counter-checks:
  without turning the samples round, without moving the fade in to
  the end, and without mirroring the peaks, all red.
- e2e 34: ALL PASSED, 72 checks (2 new). The reversed stretch starts
  with the last samples of the forward one, and the playhead ran
  0.75, 0.60, 0.45, 0.30, 0.15 over the whole file.
- Gate: first run red, 1 of 4031 (the top bar icon list). After
  moving the icon, CHECK EXIT 0, 260 files, 4031 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:08:37 +02:00
adminandClaude Opus 5.5 b519cd192f Send a track, a pattern item or a whole song through a sound FX (#346)
A sound FX could only be put on a single sound. Now a track, a pattern
item, a Play pattern and a Play song send everything they play through
one chain of it, as an insert in a DAW. A Play note gets one of its own.

- Track: an FX box in the head next to M and S opens a menu of the
  project's sound FX, and the track's inspector has the row too.
  Order: items, track FX, volume, pan.
- Pattern item: Sound FX in its inspector. Its notes play on a bus of
  their own into the track's bus.
- Play pattern and Play song take fx and fx params. The chain sits on
  the master of the playing, after the tracks and before the level and
  the fades. Set sound FX parameters reaches it by the playback.
- Play note takes fx and fx params next to the instrument's params.
  The FX parameters go by "fx:" so the two sets stay apart. Set sound
  FX parameters on a note changes a name both have in both.
- A Pitch on a bus is a new PitchShifter: the phase vocoder of the
  PitchPlayer, fed while it plays. It cannot look ahead and is exactly
  one frame late at every pitch, 2048 samples.
- That latency is made up. The other way of a Mix is held back by a
  delay of the same length. Every note and clip starts as much earlier
  as the buses it passes are late. The scheduler plans that much
  further ahead, and the first note waits that much after Play.
- The Song tab waits for the chains before it plays and lets its buses
  go a moment after Stop. A playing in a game lets them go after its
  echoes and what the shifters still hold.
- songBuses moved to its own file. Kept in song.ts it pulled the FX
  compiler into the node registry through an import cycle.

Decisions of the user: one chain for the whole (1a), FX everywhere
asked for (2a), Set sound FX parameters on playbacks (3a), and a
pitch shifter of its own with exact latency compensation for a Pitch
on a bus.

Tests:
- Unit pitch-shifter (new): 11 green. At 0 semitones the output is
  the input 2048 samples later, off by at most 1.2e-7.
- Unit sound-library: 84 green, 8 new. Counter-checks (no early
  start, no Mix delay, chain made every time, tune misses the master)
  each turned their test red.
- Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1
  new, its counter-check red.
- e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run.
  Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started
  exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz
  and passed a click 2048 samples later.
- Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:52 +02:00
adminandClaude Opus 5.5 01fd6e6464 Swirl and comb a sound with a Phaser and a Flanger node (#341)
sfxr's "phaser" is a comb filter, the sound plus a copy delayed by up
to 2.9 ms. That is a flanger, not the phaser a guitarist knows. Both
effects come in as nodes of their own, so each name fits its sound and
sfxr is covered in full. Both run on the FX worklet like the
Bitcrusher, because a feedback loop in Web Audio costs a render quantum
per round.

- Phaser: first-order allpasses (Stages 2, 4, 6, 8, 12) around
  Frequency, moved by a sine LFO Depth octaves wide at Rate Hz, with
  Feedback and Mix. An Envelope can move Frequency and Mix.
- Flanger: a delay line of Offset ms. Sweep moves it on in ms/s from
  the start of the voice, an LFO adds up to Depth ms. As in sfxr the
  delay is mirrored at 0, and it never goes past 20 ms. Feedback comes
  back exactly one delay later. An Envelope can move Offset and Mix.
- Mix 0 leaves the sound as it is, 1 is half sound and half copy. The
  notches are deepest there and the peaks stay at the sound's level.
  sfxr adds both at full, so its sound comes out 6 dB louder.
- The Flanger's processor gets the start of its voice and begins the
  sweep on that sample. stageNodes takes it as a new argument `at`.
- Explosion gets a Flanger in one draw out of two, with sfxr's offset
  and sweep and no LFO. Offset is offset^2 * 2.89 ms, Sweep is
  ramp^2 * 125 ms/s, signed so the direction holds.
- Randomize draws a Flanger's offset and sweep like sfxr. Its LFO and
  feedback and all of a Phaser are drawn within bounds of their own.
  Mix and Stages stay, as a Bitcrusher's Mix does. Mutate moves them
  too, and a 0 stays 0.
- New instrument preset Phased Pad: saw, Envelope, low-pass, Phaser of
  six stages. The other 14 are unchanged.
- The Bitcrusher's worklet setup is now shared with both nodes
  (workletNodes), its behaviour is unchanged.

Tests:
- Unit phaser (new): 8 green. Counter-check with clamping at 0 instead
  of mirroring and a flipped allpass coefficient: 3 red.
- Unit soundfx, sound-library, chip-presets: 45 / 76 / 14 green.
  Counter-checks: start time not passed on, phaser frequency eased
  linearly, sweep sign dropped, Phaser left out of Mutate, each red.
- Unit core, viewport, graph: 179 files, 2979 tests green.
- e2e 34 (melektaus-atx): ALL PASSED, 70 checks (1 new), first run.
  Chrome offline: 1 kHz notch RMS 0.0000 with two stages, 0.5657
  with four. Flanger echo after 48 samples, swept echo at 533/534.
  About 47 Phasers (12 stages) or 63 Flangers fill the audio thread.
- Gate (melektaus-atx): CHECK EXIT 0, 257 files, 3991 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:11:54 +02:00
adminandClaude Opus 5.5 29b604dc07 Fade a sound's start and end apart, each along its own curve (#345)
A cut sound had one edge fade for both ends, always a straight ramp.
Now its start and its end each have a fade of their own, and each
fade has a curve of its own.

- Fade in (ms) and Fade out (ms) replace Edge fade (ms), 5 ms each by
  default. Like before they apply only to an edge inside the file.
- Fade in curve and Fade out curve each offer Linear (the default),
  Equal power, S-curve, Exponential and Logarithmic. A fade out runs
  its curve backwards.
- The Sound tab's header has a field and a curve dropdown per edge.
  Each dropdown shows its curve as a small picture, rising or falling,
  and names it in its tooltip. With two named dropdowns the header was
  too wide at 1280 px and cut off the readout on the right.
- The inspector shows the same four rows, the curves by name.
- The wave draws each ramp in its curve rather than as a line.
- A cut plays by
  <file>#<start>~<end>~<fadeIn>~<fadeOut>~<curveIn>~<curveOut>.
- No migration, as asked in the issue: the old fade key is no longer
  read, so saved sounds start with 5 ms each and Linear on both.

Tests:
- Unit sounds, sound-library: 96 tests
- Counter-check (fade out along the fade in's curve): 1 red
- e2e 34 (melektaus-atx): ALL PASSED - 69 checks
- Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3978 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:48:59 +02:00
adminandClaude Opus 5.5 f46b2316f2 Make 8-bit sounds without a file with a Chip node in instruments (#329)
game-maker / suite (push) Failing after 36s
game-maker / gate (push) Successful in 3m33s
An instrument could only play sound files so far. The Chip makes the
sound of a note itself, the way old game consoles and sfxr did: a raw
wave and what its frequency does over the note. A laser, a jump or a
pickup is now an instrument and ends by itself.

- Chip node with wave (square, triangle, saw, sine, noise and the
  periodic noise of the NES), follow note, frequency, duty, duty
  sweep, slide, delta slide, min frequency, vibrato, arpeggio and
  repeat. It counts in Hz, semitones and seconds, not sfxr's 0 to 1.
- It runs as a third processor in the FX worklet. The math is a plain
  class, ChipPlayer, tested in Node. Every sample is worked out 8
  times and averaged, as sfxr does.
- Frequency and duty are AudioParams, so an Envelope can move them
  and a parameter can tune them while the note plays. A Pitch behind
  a Chip moves its frequency directly, without the phase vocoder.
- Below its min frequency a Chip ends and tells the page. Without the
  worklet it is silent and its note ends at once.
- An Envelope with sustain 0 now ends its note once the decay is over,
  for every source under it, Samples too. Before, a note with
  duration 0 ran on silently until a Stop sound.
- A note made of Chips alone needs no file. playNote and the wait for
  the bytes no longer drop it for having none.
- Inputs that do nothing as things stand are grey in the inspector.
  idleParams is told which inputs are connected, so a connected one
  always counts.

The credit names sfxr (Tomas Pettersson, MIT) and jsfxr, which turned
out to be public domain, not MIT as the plan in #329 said.

Tests:
- Unit chip (new) 12, sound-library +4. All of tests/core,
  tests/viewport, tests/editor/graph and tests/editor/inspector:
  178 files, 2953 green.
- Counter-checks: semitones read as /24 turn 4 chip tests red. Without
  the sustain rule and the file check 4 library tests fail.
- e2e 34 (melektaus-atx): ALL PASSED, 60 checks (2 new). Offline in
  Chrome a square at 220 Hz plus 12 semitones and duty 0.25 gave 329
  periods in 0.75 s, up 0.249. 64 noise voices for 2 s rendered in
  2.0 s, about 63 at once. Live a 0.2 s note with sustain 0 was gone
  after 0.26 s.
- Gate (melektaus-atx): CHECK EXIT 0, 255 files, 3962 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:54:44 +02:00
adminandClaude Opus 5.5 165f022fc0 Crush a sound to fewer bits and a lower rate with a Bitcrusher (#322)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Successful in 18m49s
A new effect for sound FX and instruments makes a sound rough like an
old console. It rounds the sound to fewer steps and holds it at a lower
sample rate, and all three of its numbers move while the sound plays.

- Bitcrusher node with bits (1 to 16, not only whole), rate (100 Hz to
  48 kHz, sample-and-hold) and mix. A mix typed in at 0 leaves it out.
- It runs on the audio thread as a second processor next to the pitch
  player. The module is now fxWorklet.ts and the test hook is called
  fxWorkletUrl.
- A play that needs the worklet waits for it, for a Pitch or a
  Bitcrusher alike. Without the worklet the sound goes through as is.
- Bits and mix move in what they hold, the rate in octaves. An
  Envelope can move all three in an instrument.
- A stage that runs on the worklet is stopped with its voice, like the
  drivers of an Envelope or a Mix.
- The math is a plain class, Crusher, so it is tested in Node.

The cleanup check of e2e 64 from af9b202 turned suite 553 red. It
asked that no editor run was left at all. In CI a step before 64 still
leaves one of its own running. The check now asks only about its own
behavior and prints what is still running, so the next suite names it.

Tests:
- Unit crusher (new) 4, soundfx +2, sound-library +3. All of tests/core,
  tests/viewport and tests/editor/graph: 176 files, 2934 green.
- Counter-check: without the rounding 2 of 4 crusher tests fail.
- e2e 34 (melektaus-atx): ALL PASSED, 58 checks (1 new). Offline in
  Chrome 2 bits at 4 kHz leave five levels held for 12 samples each.
  64 stereo voices for 2 s render in 1.9 s, about 68 at once.
- e2e 64 (melektaus-atx): ALL PASSED, 30 checks.
- Gate (melektaus-atx): CHECK EXIT 0, 254 files, 3946 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:22:54 +02:00
adminandClaude Opus 5.5 f1f4a57589 Cut a sound to a stretch of its file in its own Sound tab (#343)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 18m20s
A sound asset now opens in a tab that shows its file as a waveform.
Two handles set where the sound starts and ends in the file, and that
stretch is the sample: Play sound, a song item and an instrument's
Sample all play it and nothing else. The file itself is never changed.

- The asset has three new rows: Start (s), End (s) and Edge fade (ms).
  End 0 is the end of the file, the fade is 10 ms by default. Older
  assets have no values and play the whole file as before.
- Every way of playing asks playedSoundOf() for a name instead of the
  file: the file, or <file>#<start>~<end>~<fade> for a cut.
- SoundLibrary makes the cut from the decoded file the first time that
  name plays and ramps each edge inside the file over the fade. Length
  and peaks of a cut are read from the file without a copy.
- Cut buffers can be made again at any time, so the library lets the
  oldest go past 64 Mi samples. A voice holds its buffers itself.
- The tab draws min/max per pixel from 256-sample block peaks, and the
  samples as a line when zoomed in far. Wheel zooms, the middle button
  pans, as in the music tabs. Wave, cut and ruler sit under a mask,
  so a handle panned out of view does not draw over the panels beside.
- Dragging a handle moves that edge, dragging elsewhere spans a new
  stretch. One drag is one undo step for both edges. Whole file puts
  the handles back, Play or Space plays the stretch with a playhead.
- The inspector preview and the Sound FX preview play the cut too, and
  the song lanes draw and repeat it.

Tests:
- Unit: sounds 20, wave-view 8 (new), sound-library 68, plus 258 in
  the touched areas (instrument, song, pattern, inspector, tabs)
- e2e 34 on melektaus-atx: ALL PASSED, 57 checks (7 new)
- Gate on melektaus-atx: CHECK EXIT 0, 253 files, 3936 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:42:21 +02:00
adminandClaude Opus 5.5 aa097bd076 Zoom with the wheel and set the loop on its own strip in music tabs
The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.

- The ruler has a loop strip along its top. A drag there sets the
  loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
  rows over the keys, the tracks over their heads. Alt and Shift no
  longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
  only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
  deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
  item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
  cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
  Only with it on does a note sound when drawn, grabbed or moved.
  The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
  from its own start, and each column shows the loudest part of the
  sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
  fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.

Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.

Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
  Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
  (+1), transitions and piano-roll 30 tests, typecheck and ESLint
  clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
  and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
  checks (+1). A probe shot of a looped pop sound: all six rounds
  pixel for pixel alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:25:49 +02:00
adminandClaude Opus 5.5 f97961c8ff Play and stop a song from a behavior in the game #336
game-maker / gate (push) Successful in 3m35s
game-maker / suite (push) Failing after 16m36s
A song could only be heard in its tab. Now a game plays it through
Play song and ends it through Stop song, with its tracks as they are
set. A playing pattern or song also no longer undoes the pause of the
toolbar.

- Play song plays from the start of the song and then round the loop
  of its ruler, so an intro is heard once. Without loop it plays once
  to its end. Inputs are song, volume, fade in, loop (on by default)
  and detached (off by default). It gives out playback.
- Stop song stops one song, every song for none, or one playback by
  its number, over its fade out. Stop sound with no sound named stops
  songs too. Stop pattern with no pattern named stops only patterns.
- Songs play side by side, as patterns do. They are owned like a
  sound, so leaving the scene ends one unless it is detached. Pause
  game and the time scale leave the music alone.
- Each playing is heard through a master of its own, silent until its
  first note. The fades move it, and a song's tracks sit inside it on
  buses of their own. They go once the playing and its voices have.
- Notes and clips are struck the same way in the editor and the game
  (strikeNote, playClip). What a sequence needs comes from one place
  (core/music/needs).

Findings:
- Reading the audio clock resumed a suspended context. A playing
  pattern read it every 25 ms and so undid the toolbar's pause. The
  library now leaves a context alone while the toolbar holds it.
- A pattern played once to its end asked the list of playings about
  its voices, and that list asked the pattern again. Once its last
  voice was gone this ran until the stack overflowed. The scheduler of
  a playing now asks only after voices.
- e2e 64 reads the playings through __gm.sound.playbacks() now.

Tests:
- song-play.test.ts (new): 8 tests, node catalog +8 cases
- sound-library.test.ts: 66 tests, 4 new
- e2e 64 (melektaus-atx): 23 of 23 checks passed
- e2e 65 (melektaus-atx): ALL PASSED, 22 checks (+4)
- Gate (melektaus-atx): CHECK EXIT 0, 251 files, 3903 tests
- Counter-checks: the old way of asking after voices, a clock read
  that wakes a held context, and a start after a fade out began each
  fail 1 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:38:44 +02:00
adminandClaude Opus 5.5 8f9c250eb6 Lay patterns and sounds out on tracks and play them as a song #334
game-maker / gate (push) Successful in 3m29s
game-maker / suite (push) Successful in 16m29s
Patterns could only be heard one at a time, in their own tab or from
a behavior. A song lays patterns and sounds out on tracks along one
time axis and plays them through the scheduler of #332, the third
piece of the music tools of #330.

- New asset type song under the heading Songs, with four empty tracks,
  120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows
  in the inspector. Tracks, items and the loop are the asset's data,
  held to their rules on load and in every command.
- A track has a name, volume, pan, mute and solo. Each is heard on a
  bus of its own, so a change reaches what is sounding at once.
- A pattern item repeats its pattern for as long as it is, with a
  transpose and another instrument if wanted. A sound item plays its
  sound as a clip at its own speed, round and round, through a sound
  FX if wanted. Both have a volume on top of their track's.
- Cut back at the left end or split, an item starts as far into what
  it plays as the cut is: ticks into a pattern, seconds into a sound.
- The tab shows the heads with M, S, volume and pan, the ruler of the
  Pattern tab and the items, a pattern with its notes, a sound with
  its waveform, a missing asset red. Patterns and sounds are dragged
  in from the Assets panel. Body, right and left end move, lengthen
  and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits
  where playing starts, Delete, arrows. A double-click opens a
  pattern item in its tab.
- What is picked is a new selection of its own, so the inspector
  shows the item or the track, with a button to delete it.
- The scheduler plans clips beside notes. Where the playhead starts
  or jumps back inside a clip, it is taken up that far into its
  sound. A clip fades out at the end of its item or of the loop.
- The editor plays one piece at a time: a pattern started stops the
  song and the other way round.
- The rules are pure functions in core/music/song, editor/song/
  songView and songEdit.

Findings:
- The suites of 4ea752f and 2a3b90b were red at e2e 54: the Pattern
  pushed Struct below the fold of the + menu. The module now scrolls
  to it, as e2e 47 does for Vector art. It also left the menu open,
  which made e2e 55 fail behind it.
- The Assets panel only dragged assets a scene can place. Patterns
  and sounds are dragged now as well, and a scene says why it does
  not take one instead of calling every asset a scene.

Tests:
- song.test.ts (new, 14) and song-edit.test.ts (new, 9): model,
  round trip, sequence of patterns and clips, mute and solo, cuts,
  clips on the scheduler, moving, sizing, trimming, splitting,
  duplicating, hits, head parts. sound-library.test.ts +5: clip
  offset, end fade, pitched clip, track bus, peaks.
- e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and
  55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset
  types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests.
- Counter-checks: no taking up of a clip, no pattern offset, no clip
  end each fail 3, 1 and 1 tests.
- Probe on stein2: the tab with four tracks, before and while
  playing, looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:28:25 +02:00
adminandClaude Opus 5.5 9c71eb1a0d Shape notes with an Envelope and move numbers with its curve #335
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Successful in 15m58s
Without an envelope a note set in hard and broke off 10 ms after its
end. An instrument now has an Envelope node with attack, decay,
sustain and release. On the way of the sound it is the volume. Its
output "value" moves a number of the note with the same curve, from
"From" to "To", for a filter sweep among others. A new Filter node
gives that sweep something to move.

- Envelope (instrument only): attack a straight line, decay and
  release ease out and are 60 dB of the way at their time. A release
  takes 10 ms at least. Defaults 0.005 / 0.1 / 1 / 0.1.
- The end of a note goes to the Envelope instead of the 10 ms fade. A
  Gate or Loop Sample under it plays on through the release, "Loop,
  then release" leaves its loop too, and the voice ends with the
  release. A One-shot under an Envelope of its own plays through.
- A note ending before attack and decay releases from the level it
  has reached. Chrome holds it with cancelAndHoldAtTime, Firefox gets
  the level worked out from the shape.
- New port type "envelope" (coral). It fits only a number input that
  says it can follow one (PortDef.followsEnvelope): Filter cutoff and
  resonance, Pitch, Gain, the EQ bands and Mix. Not the Delay, not the
  Envelope's own numbers. portsMeet() decides it for ConnectCommand,
  the drag and the drawn edges.
- On the audio thread every Envelope of a note is one
  ConstantSourceNode. A number it moves gets the curve through a
  WaveShaper, in equal ratios for a cutoff and a gain. A number an
  Envelope moves is no longer reached by a parameter.
- Filter (sound FX and instrument): low-, high- or band-pass, cutoff
  in Hz, resonance 0 to 1 as a Q. A low- or high-pass is flat at 0
  and rises 20 dB at 1, a band-pass goes from Q 0.7 to 20.
- Stop sound with "let ring" lets a note with an Envelope fall away
  in its release. Without it, and with Stop in the tab, it is cut.
- The same note again during a release is a voice of its own.
- The Envelope's numbers are set when the note starts. They can come
  from velocity or a parameter, later changes do not bend the note.

Tests:
- envelope.test.ts (new, 12): curve, ratios, Filter Q, compiling an
  Envelope, its value into each number, a Delay refused
- sound-library +7: attack and decay on the clock, release from the
  level reached, One-shot held, Stop sound with and without let ring,
  a cutoff and a pitch moved by the curve
- e2e 34: ALL PASSED, 50 checks (+2). Offline in Chrome the curve
  rendered 0.5 / 1 / 0.5005 at 0.05 / 0.1 / 0.3 s, released at 0.5 s
  it was 0.0158 at 0.65 s and 0.0005 at 0.8 s, released mid-attack it
  fell from 0.5 to 0.05 in 0.1 s. A Gain moved 0.25 to 1 was 0.5
  halfway. A looped note of 0.2 s with a release of 1 s was gone
  after 1.32 s.
- Five counter-checks (release end, One-shot hold, the follows check,
  ratios, pitch sum) failed 3, 1, 1, 2 and 2 tests.
- Gate on melektaus-atx: CHECK EXIT 0, 245 files, 3812 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:54:46 +02:00
adminandClaude Opus 5.5 fb4d65a2ab Keep both sounds full at the middle of a Mix with a curve #331
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Successful in 16m2s
A Mix crossfaded: at 0.5 each sound was at half. Layering two sounds
in an instrument now goes through a Mix (076c564), so the user asked
for a second curve where each side stays full up to the middle and
only fades on the other half. At 0.25 a is full and b half, at 0.5
both are full, at 0.75 a is half and b full.

- The Mix has a setting Curve with Crossfade (default) and Both at
  center. A plan and a chain only say `curve` where it is `both`, so
  old projects and their compiled plans stay as they were.
- A share is not a straight line of `mix` with that curve. Two ramps
  of their own would dip to half each when `mix` glides across the
  middle. So a ConstantSourceNode carries `mix` on the audio thread,
  and two WaveShaperNodes make the shares from it. The knob of such a
  Mix is that one source.
- The source is started with the voice and stopped when it is cut or
  ends by itself, so it does not run on after the voice.
- A sound FX node in an instrument keeps the curve of a Mix inside it.
- At 0.5 both sounds are added in full, up to +6 dB where both are
  loud. That is what several edges into the Output did before. The
  wiki says so.

Found on the way: the suite of 23bdc3f (task 529) failed the Step
check in e2e 34. It timed the jump from the start of the voice, but a
timed change runs from when it arrives. In CI that was 80 ms later,
and the pitch rightly held to 2.08 s.

- stats() says `tunedAt`, the second of the audio clock the last
  change moved a voice. A change that moves nothing, as On update
  sends every frame, leaves it.
- The Step check times its readings from there.

Tests:
- sound-library.test.ts: both shares shaped from one source, a glide
  moves only the source, the source stops with a cut and with an end
  by itself (50 tests, 2 new)
- soundfx.test.ts: curve only where set, one sound at 0 (41 tests)
- instrument.test.ts: the curve through a sound FX node (34 tests)
- Counter-checks: dropping the curve in throughFx and not stopping
  the source in done() each turn their new test red
- e2e 34 on melektaus-atx: ALL PASSED, 48 checks (+1). Rendered
  offline in Chrome, a and b come to 1/0, 1/0.5, 1/1, 0.5/1 and 0/1
  at mix 0 to 1, and a glide from 0 to 1 reads the same at 0.25, 0.5
  and 0.75 s. Step jumped 2.04 s after the change.
- Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3793 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:12:56 +02:00
adminandClaude Opus 5.5 23bdc3f775 Play notes in time on the audio clock with a scheduler #332
game-maker / gate (push) Successful in 3m34s
game-maker / suite (push) Failing after 16m7s
Patterns and songs (#333, #334) have to play sample-accurate in time,
whatever the frame rate does. What is counted in game time runs away
from the sound. This lays the ground: musical time, a scheduler that
plans notes ahead on the audio clock, and a player that starts and ends
a note at a given second. Nothing in the editor plays a sequence yet,
the check scripts drive it through `__gm.music`.

- Ticks, 480 to the quarter as in MIDI files (decided in #332).
  Triplets and quintuplets land on whole ticks. Any meter, a beat being
  the note value under the line. The tempo always counts quarters.
- `core/music/sequence.ts` is a pure rule: the notes starting in a
  window of playing time. Across the end of a loop it looks into the
  next round, so the seam has no gap. A note past the loop end ends
  there. Windows are whole ticks, and the seam is stepped exactly, so
  no rounding can drop the first note of a round.
- `core/music/Scheduler.ts` plans the next 100 ms every 25 ms through a
  host, so it is tested with a clock of its own. The first note sounds
  50 ms after the start. Changes to the sequence are heard from the
  next window on. Stop and seek fade what it started. A new tempo or
  loop counts from what is planned, and the playhead keeps its old
  pace until the clock gets there.
- The position is the audio clock less `outputLatency`, as bar, beat
  and tick.
- The beat comes from a Web Worker (`viewport/musicTimer.ts`), which
  keeps its pace in a hidden tab.
- The sound library starts a note at `timing.at`: a buffer source by
  `start(when)`, the pitch worklet by counting down inside the render
  block it falls in. A note's end is set on the clock at once, its
  fade to the sample. Leaving a loop still waits for the page.
- Delay synced to beats (decided in #332, noted from #327): a setting
  `Sync` (Seconds / Beats), `time` then in quarter notes, held to the
  2 s of the line. Play sound and Play note have an input `tempo`,
  120 BPM where nothing is connected. A scheduled note carries the
  tempo of its sequence.
- Swing is left for #333/#334, as decided.

Also fixes the red suite of 6f1f315 (task 526). Three checks in e2e 34
waited on the page's clock for things that run on the audio clock. In
CI the voice was already 0.7 s old when first seen, and the 0.3 s note
of Play note was over. `stats().last` now says when a voice started on
the audio clock, and the Quad, Step and Play note checks hold every
reading against it.

Tests:
- music.test.ts (new): 17 tests, time, windows, loop seam, scheduler
  with a jittery timer, stop, seek, tempo change
- sound-library +4, soundfx +1, node-catalog +2 (tempo inputs)
- Counter-checks: an unclamped note length, a window that never moves
  on, a buffer source ignoring its start time and a Delay ignoring
  Beats each turn 1, 6, 1 and 2 tests red
- e2e 34 on melektaus-atx: ALL PASSED, 47 checks (+2). Nine notes of a
  looped bar at 150 BPM exactly 0.4 s apart, each planned 89 to 91 ms
  ahead. The position matched the clock less 32 ms latency to the
  tick. Offline the worklet started on samples 590, 12000 and 15200.
- Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3786 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:29:31 +02:00
adminandClaude Opus 5.5 144dee0a97 Let a looped sample play out past its loop when the note ends #331
With "Mode" and "Loop" as two fields, One-shot plus Loop ran until
Stop was pressed: letting go of the key did nothing. The two fields
are now one, "Play", with four entries that each say what happens
while the note is held and when it is let go.

- Gate and One-shot play the sound once, as before. Loop repeats it
  and fades out with the note.
- "Loop, then release" is new: the sampler's sustain loop. Let go,
  it finishes the round it is in and plays what comes after "Loop
  end" to the end of the sound.
- A buffer source stops looping and plays on by itself. The pitch
  worklet gets a "leave" message. The phase vocoder then lets the
  round finish that its newest frame has read into, so nothing read
  already changes under the frames still sounding.
- Samples saved with "Mode" and "Loop" are turned into "Play" on
  load. Gate and One-shot stay, either with Loop becomes Loop, since
  playing until stopped is gone.
- The keys of the Instrument tab stay half lit while a note let go
  is still heard. Esc on the keys stops everything, like Stop.
- Striking a ringing key again plays a new note beside it rather
  than stopping the old one, so a drum can be hit quickly. This
  differs from what was proposed in the issue discussion.

Tests:
- instrument + pitch-player + sound-library + node-catalog:
  477 tests, green (12 new).
- e2e 34 (melektaus-atx): ALL PASSED, 45 checks (+1). Let go after
  a loop of 0.05 s, the note played out for 0.31 s on the audio
  clock, the key half lit meanwhile.
- Counter-check: a release that does not leave the loop fails the
  2 new player tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
adminandClaude Opus 5.5 8a4e4a6762 Play sounds as notes of an instrument asset #331
game-maker / suite (push) Failing after 2m40s
game-maker / gate (push) Successful in 3m14s
A sound could only be played as it was, one at a time. An instrument
is a node graph that makes a note a sound: Notes nodes bring the note
in, Samples play sound assets for it, the effects of a sound FX shape
it and the Output sums the layers. Its tab plays it from a keyboard,
a behavior with Play note. It is what patterns and songs (#330) will
play.

- New asset type instrument, under the heading Music, not placeable.
  A new one starts as Notes into a Sample into the Output.
- Notes are counted as MIDI does: 60 is C4, names like C#4, sharps
  only, the lowest C-1. Notes has From and To, outputs any, note,
  frequency and velocity, and with Single notes a trigger per note,
  each with an optional name for a drum kit.
- New port type trigger, only into the trigger of a source. Inputs
  can take any number of edges now (PortDef.many): a trigger, and
  the Output, which plays everything into it side by side.
- Sample plays a sound asset. Follow note shifts it by note - root
  through the pitch worklet of #317, so it keeps its length. Gate
  fades it out at the end of the note, One-shot plays through. Loop
  runs between Loop start and Loop end, also in the worklet.
- Parameters declares the instrument's own parameters with a
  default. Sound FX sends the sound through a sound FX asset, its
  parameters as inputs, the sound set in where its Input was.
- compileInstrument walks the graph once per note. A trigger is on
  only for the notes it stands for, a silent source silences what it
  alone feeds, a Mix with a silent side is the other side. velocity
  and the parameters stay open for the voice. Builds are cached per
  note until the graph or a sound or FX it names changes.
- The player takes a note as one voice of several layers, each way
  with its own file and volume. release fades the gated ways out,
  one-shots play on. playNote can release after a duration.
- Play note plays one note with velocity, volume, duration and
  detached, and hands out voice for Set sound FX parameters and
  Stop sound. Velocity is always loudness. Instrument parameters
  picks values for the instrument's parameters like Sound FX
  parameters does for an FX, renames follow.
- The tab has the graph, a header field per parameter, Stop, and
  128 keys below. Keys without sound are grey. A key plays while it
  is held. Focused, the computer keyboard plays as in a tracker by
  KeyboardEvent.code, the arrows move the octave.

Findings:
- A label of the keys set fill on the shared small text style and
  darkened every small label of the editor. Caught in a screenshot
  before the commit, the keys have a style of their own now.
- Suite task 523 on cf6799a failed two checks of e2e 34 that timed
  audio by the page's clock: a 2.95 s echo tail was gone after 1 s,
  and Quad in was further along than 0.7 s allow. Both time on the
  audio clock now through a new hook __gm.sound.clock().

Tests:
- instrument.test.ts (new, 22): notes, asset, palette, round trip,
  compile per note, drum kit, zones, Mix, velocity and parameters,
  Sound FX node with rename, problems, cache, Play note, the keys
- sound-library +7 (layers, release, duration, early release, loop
  region in buffer and worklet), pitch-player +2 (loop region),
  node-catalog +4, graph adjusted. 582 tests in the touched files.
- e2e 4 and 34 on melektaus-atx: ALL PASSED, 34 with 44 checks (+7)
- Gate on melektaus-atx: CHECK EXIT 0, 243 test files, 3750 tests
- Counter-checks: any always on, release ending one-shots, Sound FX
  inputs ignored, loop region ignored. 3, 2, 1 and 1 tests failed.
- Browser probe on stein2: keys, held key, Single notes names and
  the header looked at in screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:07:40 +02:00
adminandClaude Opus 5.5 cf6799a784 Let echoes ring on with a Delay node in sound FX #324 #327
game-maker / gate (push) Successful in 3m20s
game-maker / suite (push) Failing after 15m4s
A voice used to end the moment its sources did, and Stop sound cut it
dead. An echo has to outlive its sound, and a stop must not click.

- Delay node: a delay line with a damped feedback loop, added at mix
  next to the untouched sound. Time 0.01 to 2 s, feedback up to 0.95,
  damping 20 kHz to 500 Hz evenly in octaves, all fed by parameters
  and moved by Set sound FX parameters like any other number.
- Ping-pong is a setting of the node. It takes the sound into the left
  of the line and swaps the sides every round.
- A voice with an echo rings on for its tail: until the last echo is
  60 dB below the sound, from time, feedback and mix (fxTailOf). It
  stays reachable meanwhile, and a change counts the tail again.
- The end of a tail is timed on the audio clock with a silent
  ConstantSourceNode, so a paused run holds it. Then the voice is
  disconnected, since an echo is a loop of nodes.
- Stop sound fades a voice out in 10 ms instead of cutting it. Let
  ring fades only its sources and keeps the voice for its echoes.
  Where the context is suspended it cuts at once.
- Stop sound takes a voice. Connected, it stops only that play, and a
  0 from a Play sound that played nothing stops nothing.
- Play sound hands out tail: length plus the echoes at its start
  values.
- Destroying a behavior and ending a run still cut, echoes included.
- No limit on voices. The emit of a sound FX node now also gets the
  node's settings.

Finding: in Chrome every round of a feedback loop comes one render
quantum late (128 samples, 2.7 ms at 48 kHz), measured as 0, 128, 256,
384 for the first four echoes. Left so, since a browser without it
would then hear the echoes early. Written in the wiki.

Tests:
- sound-library, soundfx, node-catalog, sounds, keep-on-scene-load:
  489 tests green. New: fade on stop, cut while suspended, echo and
  ping-pong wiring, ringing until the tail, tail counted again, let
  ring, stop by voice, echo knobs, delay compile, tail sums.
- Counter-checks: no tail, a stop without fade, a connected voice 0
  read as unconnected. Each fails its tests, files back by cmp.
- e2e 34 (melektaus-atx): ALL PASSED, 37 checks. A blip through a
  ping-pong Delay (0.3 s, 0.5, 0.5) rang 2.97 s, 2.95 s worked out.
  Let ring kept the voice, a stop after it cut it. Rendered offline
  with the player's own stageNodes the echoes are 0.25 left, 0.125
  right, 0.063 left. 64 stereo voices of it took about 0.3 s for 2 s,
  so about 400 play at once.
- Gate (melektaus-atx): CHECK EXIT 0, 242 test files, 3713 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:05:05 +02:00
adminandClaude Opus 5.5 6e70421b92 Keep the length of a sound whatever its pitch does #317
game-maker / gate (push) Successful in 3m13s
game-maker / suite (push) Successful in 15m36s
A Pitch used to keep the length only for the pitch a voice started at.
Set sound FX parameters changed it by speed, so a sweep made the sound
longer or shorter, and length into time no longer spanned the sound.
Now a way with a Pitch is played by an AudioWorklet, a phase vocoder
whose pitch is an AudioParam in semitones. The length never changes,
and a pitch glides, ramps and eases like a Gain.

- PitchPlayer (core/soundfx) is the vocoder as a plain class, so it
  runs in a unit test. Frames of about 43 ms, four overlapping, a real
  FFT done as a complex one of half the size.
- Peaks are moved with the bins around them (Laroche and Dolson's
  phase locking), and frames are turned by half their size so a phase
  is the one at the middle. Without both, a tone beat against itself
  and dipped to 0.54 of its loudness while the pitch moved.
- It reads the sound itself rather than being fed it. It looks ahead
  as far as a frame reaches, so a voice starts with its first sample
  and every way of a Mix stays in step. A change of pitch is heard
  about half a frame (20 ms) late.
- At exactly 0 a frame is the sound itself and costs next to nothing.
  Coming back to 0, the phases are pulled round to the sound's for
  about half a second before it switches, so the switch does not dip.
- pitchWorklet (viewport) is the processor, bundled by Vite through
  ?worker&url. It is loaded with the first play that has a Pitch, which
  waits for it like for its bytes. A file's samples are sent with its
  first voice only and kept in the worklet's scope.
- A way has the worklet when its plan has a Pitch, a typed-in 0
  excepted (FxChain.shifted). A way without one stays a plain buffer
  source. Without AudioWorklet a Pitch is not heard, and the console
  says so.
- The pitched copies (WSOLA, pitchShift.ts) and their cache are gone.
- Decisions of the user: the player at the source rather than an
  effect in the chain, a phase vocoder rather than WSOLA, no limit on
  voices, no range tighter than the node's -64 to +64.
- stats() says whether the worklet is there, levels() gives a pitch in
  semitones, and __gm.sound.pitchWorkletUrl() lets a check run it.

Tests:
- pitch-player.test.ts (new, replaces pitch-shift.test.ts): 11 tests.
  Pitches from -30.5 to +12 within 2 %, the length to the sample, 0
  exact to 1e-5, a sweep half a frame behind, 8 kHz sounds, loops,
  ramps there and back without a dip.
- sound-library.test.ts: 23 tests (+4), soundfx.test.ts: 31 (+1).
- Counter-checks: no worklet in the player, shifted taken from the
  value, no pull back to 0. 15 tests fail, files restored by cmp.
- e2e 34 on melektaus-atx: ALL PASSED, 34 checks (+1). In real Chrome
  the worklet is there and the pitch param glides to 3.0007.
  Offline: 880.0 Hz for 440 an octave up, 48000 of 48000 samples, a
  sweep 0 to -12 at 392 then 248 Hz, still 48000 samples.
- Gate on melektaus-atx: 242 test files, 3690 tests, CHECK EXIT 0.
- Cost: 53 ms per second of a stereo voice at +5 in Node on stein2,
  5 ms at 0. In headless Chrome 16 voices for 2 s took 1.3 s, about 24
  fill the audio thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:00:13 +02:00
adminandClaude Opus 5.5 e7a360db73 Offer the easings of an animation key on Lerp and sound FX ramps
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 15m36s
A timed Set sound FX parameters ran only linearly, and a Lerp had four
quadratic eases of its own. An animation key already had the full set.
Now all three offer the same choice: Linear, Step, the ten curves from
Sine to Bounce with In, Out or In-out, and a drawn Curve asset.

- easingParams in src/core/graph/nodes/easing.ts gives a node the
  fields Easing, Direction and Curve from the key's own lists, and
  easingOfParams reads them back as a key interpolation.
- There is no migration. A Lerp saved with the old in, out or inOut
  runs linear until its easing is picked again.
- Set sound FX parameters hands the easing to the player. Linear and
  Step stay ramps of the audio clock, every other easing goes to
  setValueCurveAtTime as 200 samples a second, in semitones and dB.
- Step now reaches its end at t 1. A key never asked for that, it
  reads the next key itself, so animations play as before.
- The curve-in-use count and the warning for a gone curve cover the
  sound node too.

Tests:
- node-catalog: Lerp with cubic in, quad out and in-out, a curve
  without a direction, step, an unknown easing as linear. The sound
  node hands on a curve, step and a gone curve as linear.
- sound-library: an eased change becomes a curve in semitones and
  decibels, a step a jump at the end.
- node-catalog and curve after dropping the old values: 399, green.
- e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a
  2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the
  end, a 1 s Step held 1 and then jumped to 0.5.
- Counter-check: with curves sent as linear ramps, the curve test of
  the sound library fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:05:00 +02:00
adminandClaude Opus 5.5 501a1524ed Ramp sound FX parameters over a time on the audio clock
game-maker / gate (push) Successful in 3m21s
game-maker / suite (push) Successful in 15m26s
A pitch sweep counted down in On update or On fixed update runs in
game time, while the sound runs on the clock of the audio device. A
short sound magnifies any offset between the two, so the sweep came
out different on every play and most of all on the first.

- Set sound FX parameters has a new input time in seconds. The values
  run evenly to their target in that time, scheduled on the audio
  clock with linearRampToValueAtTime or exponentialRampToValueAtTime.
- Evenly in their own unit: a pitch in semitones and a gain in
  decibels, an EQ band and a Mix share linearly in their value.
- A time of 0 or below keeps the quick glide of about 0.1 s.
- Each change starts from where the voice is now. Whatever was still
  planned is dropped, so a change never queues behind an older one.
  Firefox lacks cancelAndHoldAtTime, there the value is held by hand.
- A voice still waiting on its bytes starts a timed change with the
  sound, so the whole sweep is heard.

Tests:
- node-catalog: time is handed on, a time below 0 is a quick glide.
- sound-library: semitones and decibels ramp in their own steps, a
  timed change on a waiting voice starts with the sound.
- sound-library + node-catalog + soundfx: 419 tests, green.
- e2e 34 on melektaus-atx: ALL PASSED, 31 checks. A 2 s ramp from
  depth 7 to -5 read speed 0.979, 0.926 and 0.778 half a second on.
- Counter-check: folding a timed change on a waiting voice into its
  start values fails the new test.
- The gate did not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:47:13 +02:00
adminandClaude Opus 5.5 ed2b58b3c0 Load the sounds of a project ahead of their first play
A sound was fetched and decoded on its first Play sound. That play
started late, its beginning was not heard, and a graph that tuned the
voice in the meantime had moved on by the time it started. Images and
fonts were already loaded when a project opens. Sounds now are too.

- projectFiles lists the sounds of a project, and preloadProjectFiles
  hands them to the new soundLibrary.preload. That covers the editor
  and the published game.
- Preloading opens no audio device. It decodes with an
  OfflineAudioContext at 48 kHz. The real AudioContext is still made
  on the first play, and a source resamples where the device runs at
  another rate.
- A run started right after opening a project can still find a sound
  loading. It then starts late as before.

Tests:
- sound-library: a preload makes only the offline context, and a
  play after it starts at once, opening the device only then.
- sound-library + project-files: 18 tests, green.
- e2e 34 on melektaus-atx: ALL PASSED, 30 checks.
- Counter-check: decoding with the real AudioContext again fails the
  new test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:47:00 +02:00
adminandClaude Opus 5.5 c7e9769f90 Change the sound FX parameters of a sound while it plays #315
game-maker / gate (push) Successful in 3m14s
game-maker / suite (push) Successful in 15m39s
Until now a value handed to a sound FX counted from the start of the
voice only. An engine whose pitch follows the speed, or a loop that
gets quieter, could not be built. Every play of Play sound now gives
out the number of its voice, and a new node lets the knobs of that one
voice glide to new values while it plays.

- Play sound has a new output voice: the number of this one play, or 0
  where nothing was played. Kept in a Number variable it reaches the
  voice from another event. Two plays of one sound are two voices.
- New action node Set sound FX parameters (exec, voice, fx params).
  It reaches exactly the voice it is given, a detached one too. A
  voice that has ended, or voice 0, is not reached.
- AudioSink.play returns the voice number and takes the plan and the
  values (FxVoice) instead of a finished chain. AudioSink.tune takes a
  voice number and new values. A name the voice's FX does not have is
  left out, one not given keeps its value.
- SoundLibrary hands out the number at once, before the bytes are
  there, and never twice. A voice still waiting for its bytes starts
  with the values handed in meanwhile.
- Every knob of a voice moves with setTargetAtTime (30 ms time
  constant). A knob whose value did not change is not sent again, so a
  tune every frame costs next to nothing.
- A pitch on a playing voice glides by playbackRate. A new WSOLA copy
  in the middle of a sound is not worked out, so the voice gets shorter
  or longer while it is moved. On a loop that is not heard.
- fxChainOf now gives a chain whose shape depends on the plan alone.
  A stage fed by a parameter stays at 0 dB, and a Mix fed by a
  parameter keeps both ways. A typed-in 0 or Mix at 0/1 is still left
  out by the compiler.
- Every way of a voice has a source of its own, also at the same
  pitch, so each can glide on its own. The pitched copy is shared.
- The tab keeps the numbers of what it plays, so moving a parameter
  field is heard at once.
- Test hook __gm.sound.levels(file) reads the knobs of every voice of
  a file, oldest first, and stats() counts the voices tuned.

Tests:
- soundfx.test.ts: 29 tests (+2 for the node in a running world: the
  voice of the first of two plays of one sound, and a play that played
  nothing)
- sound-library.test.ts: 15 tests (+3: glide, reach by number,
  pending voice)
- node-catalog.test.ts: 370 tests (+2, Play sound checks its voice)
- sounds + keep-on-scene-load: 31 tests, unchanged
- Counter-test: a tune that reaches every voice of the same file makes
  the reach test fail.
- e2e 34 (melektaus-atx): ALL PASSED - 30 checks (+1). In Chrome two
  looping voices of one sound start at depth 7. The one whose number
  sits in a variable glides to 0.7938 (2^(-4/12) = 0.7937), the other
  stays at 1. Both gains stay at 0.5012.
- The gate ran before voices got a number (CHECK EXIT 0, 241 files,
  3658 tests), not after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:53:26 +02:00
adminandClaude Opus 5.5 072537b2e8 Blend two ways of a sound FX with a Mix node #316
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Successful in 15m30s
A sound FX was a single line from Input to Output, so two versions
of one sound could not be heard together. The new Mix node joins two
ways again, and each way can carry its own effects, its own Pitch
included.

- Mix has the audio inputs a and b and a number input mix (0 to 1,
  default 0.5). At 0 only a is heard, at 1 only b. Like every number
  it can come from a parameter of the Input and is held to its range.
- The compiled plan and the chain a voice gets are now a tree. A
  source carries a pitch and stages, a mix carries a, b and the
  stages after it. A Mix that ends at exactly 0 or 1 collapses to the
  one way heard, so the silent way is never worked out.
- A Pitch is still worked out on the sound as it starts (#312). Behind
  a Mix it goes to both of its sounds. An effect before a Pitch
  therefore acts as if it came after it, the node now says so.
- The player starts one source per pitch, all at once and all of the
  same length, and two ways at the same pitch share one. A mix becomes
  two gain nodes with 1 - mix and mix into a sum. A voice leaves the
  count when all of its sources have ended.
- stats().last reports pitches (one per source) instead of pitch.

Tests:
- soundfx.test.ts: 27 tests (+5), sound-library.test.ts: 12 (+2),
  node-catalog.test.ts: 368, all green. Typecheck, ESLint, Prettier
  clean.
- Counter-check: a Pitch behind the Mix given to a only, and b mixed
  at 1 - mix, each fails its new test.
- e2e 34 on melektaus-atx: ALL PASSED, 29 checks (+1). The new check
  plays the pitched way mixed with the dry Input in the browser: two
  sources at 5 and 0 semitones, as long as the sound itself.
- The full gate did not run before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:09:30 +02:00
adminandClaude Opus 5.5 790e483198 Pitch a sound FX without changing its length #312 #313 #314
game-maker / gate (push) Successful in 3m9s
game-maker / suite (push) Successful in 15m19s
The Pitch node changed the playback speed, so a sound pitched up was
also shorter and one pitched down longer. A pitch now keeps the
length, takes fractions and reaches further. Gain and the EQ bands
reach further down, and the EQ is renamed so a parametric one can
come later without a mix-up.

- Pitch shifting is the pure function pitchShift in
  core/soundfx/pitchShift.ts. It reads the sound faster or slower and
  stretches it back by WSOLA: 40 ms frames under a Hann window, each
  moved within 12.5 ms to where it continues the frame before it.
  A looping sound reads on across its end.
- SoundLibrary plays a pitched copy of the decoded buffer instead of
  setting playbackRate. Copies are kept per file, pitch and loop, the
  last 32 of them.
- stats().last tells the duration of what was played, so a check can
  see that the length is kept.
- Pitch: -64 to +64 semitones, scrubbed in steps of 0.1 (#312).
- Gain: -120 to +24 dB (#313).
- EQ is now labelled "5-Band-EQ", its bands go -60 to +24 dB (#314).
  The node type stays SoundFxEq, so saved graphs load unchanged.

Findings:
- A plain granular shifter was tried first and missed the pitch: at
  +0.5 semitones it measured 438 Hz instead of 452.9 Hz, because every
  grain restarts at the phase of the original. WSOLA measures 452 Hz.
- Far ends: +64 semitones from 440 Hz lands above half the sample rate
  and folds back, -64 is below what a 40 ms frame can follow. Nobody
  has listened to either.
- Speed: 3 s of a 48 kHz channel take 47 ms at +3 and +64, 171 ms
  at -64 (measured in Node).

Tests:
- pitch-shift (new): 7 tests. A 440 Hz sine at +12, -12, +7, +0.5 and
  -30.5 semitones is within 2 % of the target and keeps its length.
  Level stays at most 1, a loop does not fade at its seam.
- sound-library: 10 tests (+1), soundfx 22, node-catalog 368.
- Counter-check: with the pitched copy switched off the new
  sound-library test fails.
- e2e 34 (melektaus-atx): ALL PASSED - 28 checks. Its octave-down
  voice is now also checked for the length of the sound.
- Typecheck, ESLint and Prettier clean. The gate did not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:32:00 +02:00
adminandClaude Opus 5.5 de14f0f9e2 Play a sound through a graph of gain, pitch and EQ effects #311
A sound could only be played as it was uploaded. A Sound FX asset is
a node graph like a shader: Input, effects, Output. Play sound has a
new input fx that sends the voice through it.

- New asset type soundfx ("Sound FX"), not placeable, with a graph
  and its own tab. The heading in the asset panel is "Sound effects",
  so it does not take the click meant for the + menu entry.
- Nodes in core/soundfx/nodes.ts, category sound, port type audio
  that only fits audio: Input, Output, Gain (dB), Pitch (semitones),
  EQ (80 Hz and 12 kHz shelves, peaks at 250 Hz, 1 kHz and 4 kHz).
- compileSoundFx walks back from the Output with the shared graph
  emitter. An input takes one edge, so the result is one line: a
  pitch and a list of gain and filter stages. A node without input
  or a graph without Output reports a problem on the node.
- Pitch is the playback rate of the source. It applies to the whole
  sound and also changes its length, the node description says so.
- The BehaviorSystem resolves fx and hands the line to the AudioSink.
  An fx that is missing or does not compile plays the sound dry.
- SoundLibrary wires source, stages, volume, destination in that
  order. stats().last now also names pitch and stage count.
- The tab header picks one of the project's sounds and plays it
  through the graph as it stands. Stop there ends only its own voice.

Tests:
- soundfx.test.ts (new, 11), sound-library (9, +2 for the wiring),
  node-catalog (+1 for fx), graph and asset-groups adjusted
- e2e 34: ALL PASSED - 26 checks (+5: + menu opens the tab, palette,
  2 effects, tab Play and game Play through the FX at -12 st)
- Gate on melektaus-atx: typecheck, lint, prettier clean, 239 of 240
  test files green. The red one was sound-library (its fake
  AudioContext had no playbackRate), fixed afterwards, gate not
  re-run in full.
- Counter-check: without the audio rule in portsCompatible the port
  test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:45:42 +02:00
adminandClaude Opus 5.5 1665576fb9 Let a pixel perfect camera draw on the game view's pixel grid
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 16m1s
A camera that follows smoothly stands between pixels most of the time,
and pixel art then wanders between them. The new camera switch draws
what the camera shows on the game view's own grid, while positions in
the model keep their fractions.

- `Pixel perfect` is a camera row, off by default. It works at every
  fixed game scale and at Auto, but not at Fit and not in the Scene
  tab, where one works with the fractions.
- The grid has one step per device pixel of a world unit. At 100% that
  is whole units, at 500% fifths. The view carries it (`View.grid`),
  so no view draws with the grid of another.
- The view looks from a point that puts the world's origin on a whole
  device pixel (`lookFrom`). This also covers odd camera sizes and
  views that cut the picture to an odd size.
- Sprites, texts and tilemaps snap their top left corner when they are
  unturned, so an odd size lands on whole pixels too. A turned object
  snaps its pivot (`snapPlacement` in `pixelGrid.ts`).
- Under a camera shader the picture is taken the same way, and the
  objects in it use the picture's grid. With Camera pixels that is one
  step per camera pixel.
- An overlay camera has its own switch and snaps only while the main
  picture is at a fixed scale.
- Particles are drawn where they are.
- New test hook `__gm.gameDrawnRect(id)` reads where a sprite's picture
  is drawn, which `gameRect` (the model's) cannot tell.

Found: the e2e fixture at 300% drew the crate at y 205.5 device pixels
without moving the camera, because the view cut to an odd height puts
its centre on half a pixel. With the switch on it is at 206.

Tests:
- Gate: 237 files, 3587 tests (+10 in pixel-grid.test.ts)
- e2e 2,3: ALL PASSED, 75 checks (+1, 3j)
- Counter-checks: without the origin snap 3 unit tests fail, with the
  centre snapped instead of the corner 2 fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:30:31 +02:00
adminandClaude Opus 5.5 3f57cf731d Keep a pixel frame worn through a shader alive while it is drawn
Changing a palette colour of pixel art flooded the console with Pixi
warnings: "[BindGroup] a 'textureSource' was destroyed while still
bound to a shader", and the same for its sampler. #293 had stopped
this for a drawing, but a frame of pixel art is worn as a cut of the
sheet. The cut has a source of its own, so the sheet looked unbound,
was freed at once and took the cut with it under the shader.

- The check whether a replaced picture is still in use gets its key
  too. TextureLibrary then asks the picture and every cut taken from
  it.
- A sheet with a bound cut waits like a bound picture did since #293,
  and both go together once the shader has taken the new frame.

Found on the way:

- The project the report came from has a sprite wearing a frame of
  an eleven-frame pixel art through a shader asset.
- A nine-sliced sprite wearing the frame showed no warning, even
  without the change. The first try of the e2e check used it.

Tests:
- Gate on melektaus-atx: 236 files, 3577 tests (+1)
- e2e 48: 45 checks (+1, sum 1741). 48l-iii puts a shader on the
  object wearing the second frame, changes a palette colour three
  times and counts the warnings.
- Counter-checks bite: without the cuts in the check the unit test
  fails, and 48l-iii counts 6 warnings for 3 changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:30:00 +02:00
adminandClaude Opus 5.5 8452a99c64 Let a graph read and change the z of an object
Until now a behavior could not move an object in front of another
while the game runs. Set position writes only x and y, and z lives on
the running object rather than in its props, so Properties cannot
reach it either. Drawing already reads the z every frame, so only the
nodes were missing.

- Get Z (data): object in, z and world z out. World z is summed up
  with the parents, the way drawing does it (worldTransformOf).
- Set Z (action): object and z in. It sets the object's own z, local
  to its parent like Set position. Higher is drawn in front from the
  next frame on.
- Two nodes of their own rather than a z port on Set position and
  Get position, as asked.

Tests:
- node-catalog: Get Z for a child (2 below 5 is world z 7) and for no
  object, Set Z writes only z and leaves x/y.
- set-z.test.ts: in a running world a key sets the lower of two
  sprites to z 2, drawSlots then draws it in front, Get Z logs 2 and 2.
- Two counter-checks bite: Set Z without the write and Get Z without
  the parent fail 3 tests.
- Gate: 233 files, 3548 tests (3542 + 6), CHECK EXIT 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:08:35 +02:00
adminandClaude Opus 5.5 3e05cbbed5 Let pixel art and vector art be nine-sliced and sorted (#308)
Nine-slice and the sort line hung on the texture frame alone. Drawing
and sorting already read both off the TextureRef every picture hands
out, but the generated pictures always said "no slice, no line". So
the feature moves from the frame to the picture, without a second
copy of any rule.

- PictureCut (border, fill, sortLine) is one type. TextureFrame
  extends it, PixelArtAsset and VectorArtAsset carry it as a whole.
  The texture file format does not change, and no migration is needed.
- The rules in texture.ts take a cut and a size: cutSortLine,
  setCutSlice, rawCutSlice and the rest replace the frame* versions.
  serialize.ts reads all three through one readCut.
- cutTarget.ts resolves a target to cut and size: a texture frame, a
  pixel frame's size or the vector canvas. It lives apart from
  texture.ts because vectorart.ts reaches texture.ts itself.
- SetSliceCommand and SetSortLineCommand take that target instead of
  (assetId, frameId). They refuse an asset that draws no picture.
- generatedRef fills slice and sortLine from the drawing. The whole
  strip of a pixel art asset with several frames gets none, it is no
  picture of that size. With one frame asset and frame are the same.
- Nine-slice scale is one shared schema row, now also on pixel art and
  vector art.
- cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame
  inspector and the drawing inspector both build them.
- SheetCanvas draws a drawing's lines that are set, dashed as in the
  Texture tab. They are not dragged there, the painting tools own the
  canvas (as proposed in the issue).
- drawOrder falls back to the picture's height when a ref has no
  rect, or a sort line on vector art would have been ignored.

Tests:
- Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector
  art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a
  drawing, a cut on the whole strip, no height fallback.
- e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where
  12-pixel borders at scale 2 put them. Without borders both spots
  read green.
- Looked at: the Pixel art tab with the lines, and the sliced sprite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:53:51 +02:00
adminandClaude Opus 5.5 d9c6bbfa4d Add a node that names the render layer of an object (#305)
game-maker / gate (push) Successful in 3m14s
game-maker / suite (push) Failing after 14m18s
The issue asked for Set render layer, Set sees and Render layer of. As
with #211, a probe showed that writing already works. A Properties
node of the object's type writes Render layer during a run, Inherit
included, and one of type Camera writes Sees. The next picture follows
both. As agreed, only Render layer of is built, and writing stays with
Properties.

- Render layer of is a data node with the input object and the outputs
  name and number. It answers the layer that holds, so an object
  without a choice of its own names its parent's.
- NodeContext.renderLayerOf answers the number with the same rule the
  drawing uses (renderLayerAlong). The new field renderLayers on
  GraphLookup brings the names to the node.
- hasRenderLayer in PropertySchema says which types are drawn.
- Decided here: for an object that is not drawn (a camera, a
  behavior, a collider) the name is empty and the number -1, as Layer
  of does without a collider.

Changing the render layer or Sees during a run had no test so far.
Three unit tests now hold the proof from the issue.

Tests:
- Gate: 231 files, 3534 tests (3526 + 8), exit 0
- e2e 6: 149 checks passed, e2e 63: 13 checks passed (+1)
- Unit: the coin moves into the overlay with Set and back with
  Inherit, a HUD container takes its sprite along, the main camera sees
  a second layer once its Sees is set, and Render layer of names the
  inherited layer, and none for a behavior
- Node catalog: four cases for Render layer of
- 63c3 moves a sprite onto UI in a Play run: the main camera no longer
  draws it, and the log reads "UI" and "1"
- Three counter-probes fail 1, 1 and 2 tests: without inheriting in
  the node, without the check for a drawn type, and without
  inheriting in the drawing
- "layer of" in the palette still finds Layer of first (6z-x)
- The e2e sum is 1731 (1730 plus 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:25:47 +02:00
adminandClaude Opus 5.5 70f3608733 Let Mouse position and Is hovered see through any camera (#304)
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Successful in 16m23s
An overlay camera from #302 can stand elsewhere and zoom differently.
A HUD button only it shows lay somewhere else in the world than the
mouse of the main camera, so Is hovered never found it. Both nodes now
take a camera and see the mouse through that camera's picture.

- New input `camera` on Mouse position and Is hovered. Left open, both
  read the main camera exactly as before.
- `pointerThrough` in core/properties/camera is the rule as a pure
  function. It fits the camera's picture into the main one and centres
  it, the same way overlayView draws it.
- The game view reports where the mouse lies on the main picture, as a
  part of its width and height. The world turns that into a camera's
  view, so a main camera on the move does not drag the mouse across
  the HUD while the mouse stands still.
- `in view` is true only on the camera's own picture. A camera that is
  not drawn (switched off, a second plain one, not a camera) never has
  the mouse on it.
- On click and Mouse button stay with the main camera, as agreed in
  the issue.

Also fixes a unit test that 1d0fa7b broke. play-settings expected 8
to be no game view scale, but the steps go up to 20 since then. It now
checks 20 as the top and 21 as no choice.

Tests:
- Unit: the rule lands on the same world point as overlayView plus
  screenToWorld at four screen points for two cameras. One mouse gives
  (100, 50) through the main camera and (1050, 25) through an overlay
  at (1000, 0) with zoom 2.
- e2e 63c2: the real mouse 30 units right of the picture centre hovers
  the overlay's square through it, through the main camera not.
- Counter-checks: Math.max instead of Math.min in the rule, and no
  check for "drawn", each fail one unit test. The view reporting the
  centre of the picture always fails 63c2 with x 2000 instead of 2015.
- Gate: 231 files, 3526 tests (+6). e2e 63: 12 checks (+1), sum 1730.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:09:53 +02:00
adminandClaude Opus 5.5 808e1eefc2 Clean up duplicates and dead code, and fix the + menu check (#85)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 15m29s
Refactoring round 9 covers everything since round 8 (14972d0): 62
commits with about 29 000 new lines in src, api, tools and tests.
Behaviour is unchanged. The one fix is in a check: e2e 04 counted 25
asset types, and the shader function from #303 makes 26, so the run
of a526e1b failed there.

Duplicate code (jscpd over src, 50 tokens, without the two licence
texts: 35 clones / 289 lines before, 29 / 232 after):

- ShaderPicture (new, viewport/shaderPicture.ts) is the picture a
  camera's shader and a particle field's shader are laid over. It
  keeps the render texture while its size holds and frees the
  replaced one once the shader holds the new one. CameraShade and
  ParticleField.shadeField drew it the same way, line for line.
- CameraShade builds its mesh on the first draw. Before, every redraw
  of a view without a camera shader built a mesh and threw it away.
- emptyHost in inspector/host.ts builds the empty host the Inspector,
  the Variables tab and the Tilemap panel start from.
- paddedQuad(null) is the plain unit quad. symbols.ts and
  shaderPasses.ts each wrote it out by hand.
- In graph/nested.ts, calls of shader functions and On behavior
  nodes are followed by one walk, `reaches`, over ids `namedBy` a
  node type.

Dead code:

- ShaderEditor.colorFieldOf and the getter CurveCanvas.snapping had
  no reader.
- 22 values used only in their own file lose their `export`. Types
  and the node catalogues of shaders and particles stay exported.
- Kept on purpose: PhysicsSystem.holdsJoint is read by tests only,
  but it is their one view of whether a joint holds.
- A pass with stricter lint rules over every file changed since
  round 8 found no bug. PixelArtEditor.started is now readonly.

Comments and docs:

- Ten comments told history ("since #190", "used to", "any more").
  They now describe what is. The bundle is md5-identical before and
  after, and the four e2e files minify to the same bytes.
- README: joint and shader function in the asset list, joints on the
  Physics row, what the Shaders page covers, two CI runners.
- e2e 04 keeps the number of asset types once, as ASSET_TYPE_COUNT.
- screenshot.png in the README is a new picture of the editor
  (3840x2160 instead of 1825x1140), taken by the user.

Tests:
- Gate on melektaus-atx: 229 files, 3514 tests, CHECK EXIT 0
  (+3 for emptyHost, +1 for the unit quad).
- e2e 1-4, 31, 63 on melektaus-atx: ALL PASSED, 195 checks. That is
  the + menu of 04, the field shader of 31 and the camera shader of
  63, both through ShaderPicture.
- No new e2e check, so the sum stays 1728.
- Counter-checks, each put back and compared with cmp: reaches that
  never goes on fails 1 test, listensTo reading the wrong parameter
  fails 1, emptyHost dropping what a panel hands in fails 1, folding
  without a relayout fails 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:38:39 +02:00
adminandClaude Opus 5.5 2e55071fa5 Lay a shader over a camera's whole picture (#299)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m59s
A shader so far worked only on its own wearer. Effects over the whole
game picture, such as a vignette, grey, scanlines or a red flash on a
hit, had no place to live. A camera now wears one, drawn the way a
field shader of a particle source is (#170).

- The camera gets a Shader row and, while one is picked, a Shader
  resolution row. The shader's properties follow as camera rows. They
  load, animate and appear in a Properties node under Type Camera.
- Every view keeps masks and objects in one container. CameraShade
  renders it into the camera's picture, cleared to the background,
  and draws the shader over the frame. Game tab and play.html take
  the container off the stage while the picture stands in for it.
- Screen pixels gives the picture as many pixels as it covers on
  screen. Camera pixels uses View width x View height and scales up
  without smoothing.
- A pixel counts as a camera pixel in both (the user's choice). The
  uniform uPixelScale makes Texture size read View width x View
  height, so a Blur keeps its reach at any game scale.
- Each camera wears its own shader, overlay cameras included. A red
  flash on the game camera leaves the HUD of an overlay camera alone.
- The scene view's eye has a Shader switch on the Camera row, off by
  default. With it on, the main camera's shader lies over its frame,
  below symbols and gizmos, and the world around it stays drawn.
- New tool tools/perf-camera-shader.mjs measures the cost.

Measured on stein2 (HD 630, Game tab at Fit 1310x679): a camera
shader that hands the picture through stays inside the scene's own
spread of 3 to 5 fps between runs. A Blur of 4 cost 4 to 8 fps.

Tests:
- Gate on melektaus-atx: 227 files, 3497 tests (3487 + 10).
- e2e 63 alone: 11 checks (6 + 5), e2e sum 1725 counted.
- 63e-63i: pass-through is byte-equal and upright at 100%, flat red
  reaches the corners while the overlay keeps its green, Texture
  size reads 640 x 360 in both resolutions at Fit, no shader draws
  as before, and the scene view shows it only with the switch on.
- Counter-checks: a camera left out of the shader wearers fails 1
  unit test. Counting screen pixels fails 63g with #ab6000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:03:09 +02:00