Compare commits

..
15 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 b5ea7524e6 Let a pattern swing its eighths or sixteenths (#372)
feuerwerk / gate (push) Failing after 12m20s
feuerwerk / suite (push) Failing after 25m17s
A pattern played its notes strictly on the grid, so a shuffle or a
groove needed every second note moved by hand in the piano roll. Now
a pattern carries its own swing, as agreed in comment 3994.

- Two new rows in the inspector: Swing (%) from 0 to 100, default 0,
  and Swing grid with 1/8 (default) or 1/16.
- Swing is only heard. Model, piano roll, ghost notes and song items
  keep the notes on the grid. notesIn adds a separate late offset at
  planning time, so loops and automation still go by the grid.
- It applies in the pattern tab, as a pattern item of a song, to
  Play pattern and Play song, in compositions and in bakes. A
  composition applies it exactly once. Sound items stay straight.
- A changed swing makes a bake stale. Bakes without swing stay valid.
- Pairs count from each bar start. A pair cut by the bar end stays
  straight, like the last eighth of a 7/8 bar.
- A note that swing would push past a loop end starts one tick
  before it.

Tests:
- Unit: 10 new (swing 9, composition-audio 1). At 1/8 and 120 BPM
  the second eighth comes 1/12 s late at 100 %, 1/24 s at 50 %.
- 13 counter-checks, all red.
- e2e 64 on atx: ALL PASSED, 38 checks. New 64k' measures gaps of
  0.333333 and 0.166667 s in turn. Without late it sees 0.25 s.
- Gate on atx: exit 0, 338 files, 5232 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:34:21 +01:00
adminandClaude Opus 5.5 b268891f48 Run the CI suite on stein2 next to the gate
Suite 759 went red on e2e 67a-ii (firstAt 0.083 s) while a job of
another project loaded the GPU on melektaus-atx. The suite measures
frame timings, so it should not share that GPU.

- The suite job now runs on `feuerwerk-ci`, the runner on stein2,
  like the gate. Its config.yaml already passes /dev/dri/renderD128
  and a 1 GB /dev/shm into the job container, and has capacity 2.
- The header of ci.yml says how to move the suite back to
  `feuerwerk-wsl`, and that GM_TEST_WORKERS now matters again,
  because gate and suite share the cores of one machine.

Tests: YAML parses, both jobs name `feuerwerk-ci`. Not run in CI yet,
the next push starts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:40:40 +01:00
adminandClaude Opus 5.5 e53121a0bb Let a behavior hear a song, pattern or sound come to its end (#373)
feuerwerk / gate (push) Successful in 5m56s
feuerwerk / suite (push) Failing after 20m55s
A game should be able to start the next piece of music when one is
over, or wait for a sound before it goes on. Until now a behavior
could start and stop music and sounds, but never learn that they had
ended.

- Two new events: On music finished with the fields Song and Pattern
  and the input playback, and On sound finished with the field Sound
  and the input voice. Both give out what ended and stopped.
- Two new data nodes, Music playing and Sound playing, ask whether
  something is still heard.
- Over means nothing is heard any more: no tail of the effects, and
  a fade-out has run down.
- stopped is on for Stop song, pattern or sound, when the behavior
  that owns it ends and on a scene change. For a sound also when a
  newer voice needs its place or the file does not load.
- Only what the Play nodes of this run started is heard, across
  scene changes too. Previews in the tabs, compositions and notes of
  instruments do not count.
- Switch song ends nothing. The number goes on, and the end is told
  under the song heard last.
- Playbacks and the SoundLibrary tell each end once to a new
  PlayedEnds per run. The AudioSwitch passes them on from both sinks.

Found and fixed: a pattern whose last bar ends in silence counted as
done up to one planning window before its end. In e2e 65 the next
pattern started after 1.962 s instead of 2 s. Scheduler.done now also
wants the playhead to have reached the end. Cleaning up done
playbacks therefore comes a few tenths of a second later. One test
that expected the early end is adjusted.

Tests:
- Unit: 23 new (played-ends 12, sound-library 5, node catalog 5,
  audio-switch 1), all green.
- Counter-probes: 19 bends, all red. One was green at first for want
  of a case, which is added now.
- e2e 65 on atx: ALL PASSED, 78 checks. New 65k6: the event came
  once with stopped off, and the second pattern started 2.051 s after
  the stinger. Stop song gave exactly one end with stopped on, and
  the switch from 65k3 gave none.
- Gate on atx: CHECK EXIT 0, 337 files, 5222 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:48:16 +01:00
adminandClaude Opus 5.5 2333f66ba2 Keep e2e 65 and 67 green in the full suite after #410
feuerwerk / gate (push) Successful in 5m55s
feuerwerk / suite (push) Successful in 20m21s
Suite 755 for b17a924 was red in three places. Both e2e 65 and e2e 67
were green on their own. Two of the new checks did not fit into the
full run. The product is not at fault.

- e2e 66 (66s): the per-frame pixel read never stopped. splice(0)
  emptied the array, so the length bound was never reached. Each
  pixelAt leaves the stage shifted to its pixel until the next
  drawing. The editor, idle after Stop, kept it at (-710, -376), so
  the Play click of e2e 67 hit no object. The loop now ends on a flag
  while the run still draws each frame.
- e2e 65 (65k2, 65k3): the console panel keeps 500 lines and is never
  cleared in the suite. Lines of earlier steps pushed the first beats
  out, so the beats began at 1.3. The checks now read the harness's
  console log since Play, which has no limit.
- 65k2 also writes panelBusiest, the three most frequent other lines
  in the panel. The next CI run names the module that logs that much.

Tests (melektaus-atx):
- e2e 66+67 before the fix: 67 breaks off as in CI.
- e2e 66+67 with the fix: ALL PASSED, 52 checks.
- e2e 65 under a flood of 100 lines/s: ALL PASSED, 76 checks.
  The old reading under the same flood shows the CI picture.
- e2e 65+66+67: ALL PASSED, 126 checks (a first run had the known
  e2e 67 firstAt wobble, 0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:05:46 +01:00
adminandClaude Opus 5.5 b17a924157 Let a behavior play, pause and seek a video and hear it end (#410)
feuerwerk / gate (push) Successful in 5m58s
feuerwerk / suite (push) Failing after 17m35s
A game should be able to stop a video on a sprite, jump to a spot in
it and start it again, and react when it has run out. Until now a
video on a sprite either ran free on its own or followed a
composition, and no behavior could touch it.

- Five new nodes: Play video, Pause video and Seek video (time in s
  from the clip start), On video finished with a Video field, and the
  data node Video time with time, length and playing.
- Play, Pause and Seek act on the owner when object is not linked.
- On video finished with an empty field hears every video. A chosen
  video also hears its clips.
- A new VideoSystem keeps a clock for every sprite with a video. It
  counts from the moment the sprite appears, as long as it is on. For
  a Driven sprite the clock is its Video time. A paused game holds all
  clocks.
- A free sprite that one of the nodes touches gets the clock
  "steered" at run time. It then has its own player, and other
  sprites with the same clip play on. Pause of the game and sound go
  the existing Driven way.
- On a Driven sprite and in the editor run the nodes do nothing and
  write a line to the log.
- Until its own player has a frame, a steered sprite shows the frame
  of the shared player. Without that it would blink out on the first
  Pause video.
- Play at the end of a clip without loop starts it from the start.
  The issue does not say, this is my choice.

Found, not fixed: with a Driven sprite in the scene the run waits up
to 3 s for its frame at the start. The world does not tick then, but
the free videos already play. Their clocks lag behind the picture by
that wait, so On video finished comes that much late.

Tests:
- Unit: 23 new (video-steer 16, node catalog 7), affected groups
  4820/4820.
- Counter-probes: 16 bends, all red.
- e2e 66 on atx: ALL PASSED, 39 checks. New 66s: Pause at 0.15 s
  held its own player at 0.2 s, the picture stayed red for 1 s. Seek
  landed on 0.5 s exactly, green, held. Play showed green, blue,
  white, and white stayed at the end. On video finished came once.
  Without the clock "steered" all three 66s checks are red.
- Gate on atx: CHECK EXIT 0, 336 files, 5199 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:13:52 +01:00
adminandClaude Opus 5.5 f9428c5bc2 Let a game take a song's automation lane over and give it back (#338)
A game should be able to steer a lane of a song, the filter of the
pads in a fight, the pan of a track when the player turns, and later
hand it back to the curve the composer drew. Set song lane holds a
lane of a track at a value, Release song lane glides it back.

- Set song lane takes playback, track ("Song / Track"), lane, value,
  fade and quantize (Next bar by default). lane is volume, pan or a
  parameter name, with or without "param:".
- From where the lane stands it goes over fade to value and stays.
  The value is clamped to the lane's range. It replaces the lane and
  is not mixed with it (decision 10).
- Release song lane glides over fade back to the curve, to where the
  curve stands at the end of the fade. Then the lane follows the
  curve again.
- The override sits in the Scheduler, where the lane marks are made.
  Volume, pan and parameters go one way. The maths is pure functions
  in the new module laneHolds.ts.
- Notes that start during a parameter hold get the held value. A
  Switch song to the same song keeps the holds.
- The holds are a list per lane. The running one holds until the next
  one lands. A key held down fires the node on every beat, and with a
  single hold per lane the new call replaced the running one before it
  landed, so the lane fell back to the curve. e2e 65 found this.
- A track without that lane and a baked song write a line to the log
  (with a hint at "live tracks").
- The test harness knows the keys l and r.

Tests:
- Unit: 24 new (lane-holds 14, song-play 5, sound-library 2, node
  catalog 3), affected groups 748/748.
- Counter-probes: 12 bends, all red.
- e2e 65 on atx: ALL PASSED, 76 checks. New 65k5: the hold landed on
  the bar line, curve until 0.02 s before, -40 dB 0.14 s after, no
  values between. It stood 1.8 s. The release began at -39.8 dB and
  followed the curve again from 0.55 s on. With one hold per lane
  65k5 is red.
- Gate on atx: CHECK EXIT 0, 335 files, 5176 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:22:12 +01:00
adminandClaude Opus 5.5 e390d39314 Let a sound, note or pattern land on the beat of the music (#338)
A stinger has to start on the next beat or bar of the song that is
playing, not whenever the frame happens to run. Play sound, Play note
and Play pattern now take the number of a song or pattern playback and
plan their start on the audio clock, as the music is heard.

- New inputs sync (a playback number) and quantize (Next bar by
  default, decision 6) on Play sound, Play note and Play pattern.
- The landing is worked out in Playbacks (landOn), on the next beat,
  bar or marker of the playback as it is heard. SoundLibrary and the
  audio switch only pass it on, and only on the clock the stinger
  plays on.
- A pattern with sync plays in the tempo of the playback. It takes its
  bake only when both tempos match.
- Without a link, or when the playback is not heard, the node starts
  at once as before.
- The sync ports live in their own module musicSync.ts. Putting them
  in songSync.ts would have made an import cycle through Play song.

Tests:
- Unit: 8 new (6 in song-play, 1 each in sound-library and
  audio-switch), all green.
- Counter-probes: 12 bends, all red.
- e2e 65 on atx: ALL PASSED, 74 checks. New 65k4: the pattern (own
  tempo 100) started exactly 8 beats after the song and played at the
  song's 120 BPM. Without sync it is red (6.2 beats, 100 BPM).
- Gate on atx: CHECK EXIT 0, 334 files, 5152 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:40:25 +01:00
adminandClaude Opus 5.5 53ec1094f4 Let a game switch songs and turn their tracks on the beat (#338)
A game could start and stop a song, but not steer one while it plays.
The battle theme could not take over on the next bar, a song could not
jump to a marker, and a track could not fall away when the player
hides. This is stage 3 of 5 in #338.

- Quantize: now, next beat, next bar (the default) or next marker. The
  spot is the next one as the song is heard, and the change is planned
  on the audio clock to the sample, round the loop as the ruler counts.
- Set song track turns one track in dB on top of the song's level, over
  a fade, on the bus input before its FX and sends.
- Switch song plays another song, or the same one from a marker, in
  place of the playing one. It keeps the playback number, so Stop song,
  Set song track and On beat go on reaching it.
- A playing now has a key for its buses apart from its public number.
  During a switch the old and the new song each have buses of their own.
- Play song has a "live tracks" switch. With it off a baked song plays
  its bake, and Set song track on it writes a line to the log.
- A Switch song to a marker plays the notes, as a bake only plays from
  its start.

Found on the way:

- A cut on a bar line faded over seconds in Chrome. cancelAndHoldAtTime
  holds nothing where nothing is planned later, so the ramp ran from the
  bus's last change. The turn now sets its start value on the spot. It
  had been measured at 0.52 of full level 0.2 s past the bar line.

Tests:
- Unit: 23 new cases in beats, song-play, sound-library and the node
  catalog. Counter-checks: 17 bends, all compile, all red.
- e2e 65 (atx): ALL PASSED, 73 checks. 65k3 is new. The switch landed
  three bars after the start under the same number, and the track cut
  on the next bar line with nothing in between. With the old ramp 65k3
  is red (0.53 and 0.51 between).
- Gate (atx): CHECK EXIT 0, 334 files, 5144 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:17:52 +01:00
adminandClaude Opus 5.5 a6a41b6b69 Let a behavior follow the beat, bar and markers of the music (#338)
feuerwerk / gate (push) Failing after 15m41s
feuerwerk / suite (push) Canceled after 18m24s
Stage 2 of #338. A game that syncs to its music has to know where the
music is heard. This stage reads that on the audio clock and hands it
to behaviors as events and as a position.

- New event nodes On beat (playback, bar, beat, late), On bar
  (playback, bar, late) and On song marker (playback, marker, name,
  late).
- New data node Song position with bar, beat, fraction, seconds and
  playing.
- The nodes have the fields Song and Pattern. With neither picked,
  every playback counts. A linked playback input picks that one.
  Compositions do not count.
- The Marker field offers the markers of all songs as Song / Marker,
  as a sound field offers its clips.
- Everything follows what is heard: the audio clock minus the output
  latency. That holds for baked songs and for Play pattern too.
- Only the last beat since the frame before fires, and only when it is
  at most half a beat late, 0.15 s at most. Bars and markers alike.
- A copied song gets markers with ids of its own, so a Marker field
  never names two songs.
- The audio clock is only asked while something plays. Asked in every
  frame, it made the AudioContext without a user gesture (#436).

Tests:
- beats 14 and music-events 6 new, 4 in song-play, 2 in song-markers,
  2 catalog cases, the touched groups 756/756
- 16 counter-probes, all compile, all red
- e2e 65 on atx: ALL PASSED, 71 checks, 65k2 new: 11 beats 1.1 to 2.4
  and 2.1 again, none missing or twice, across the pause too. late
  0.015 to 0.027 s. Counter-probe without the last position: red
- gate on atx: CHECK EXIT 0, 334 files, 5121 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:48:51 +01:00
adminandClaude Opus 5.5 5cc1cc9ef6 Tell a stream that fell behind apart from a pause that lost samples
feuerwerk / gate (push) Failing after 15m58s
feuerwerk / suite (push) Canceled after 19m19s
CI task 750 failed the pause check in e2e 34 although the pause lost
nothing. The stream on the runner never got a second ahead. It had
skipped 1.1 s before the pause was clicked, with 0.04 s left ahead.
The wait for the second ran out without a word, so the check blamed
the pause for samples the stream had lost from the start.

- A check of its own before the pause asks for a second ahead and not
  a sample skipped. A runner too slow to decode fails there now.
- When the stream was behind, the pause check judges only the clock,
  the resume and the stop, and says in its name that the samples were
  not judged.
- Nothing in the product changes.

Tests:
- e2e 34 on melektaus-atx: ALL PASSED, 149 checks. The new check saw
  1.3 MB ahead and 0 samples skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:48:01 +01:00
adminandClaude Opus 5.5 56425fca6a Let a song carry named markers on a strip in the song tab (#338)
feuerwerk / gate (push) Successful in 5m22s
feuerwerk / suite (push) Failing after 19m54s
Stage 1 of #338. Game sync needs named places in a song: an event when
the playhead passes one, and a switch that jumps to one. This stage
adds the markers themselves. Games do nothing with them yet.

- A song has markers, each with id, name and tick. A song without
  markers stores nothing for them. Two markers may share tick and
  name, since nodes will name them by id.
- Markers change neither the sound nor the bake fingerprint.
- The song tab draws a strip under the ruler. A marker has a green
  flag with its name and a faint line through the tracks. Tracks and
  lanes sit 18 px lower. The ruler of the pattern tab is unchanged.
- Double click on an empty place adds a marker on the grid, with Alt
  or Shift on the exact tick. Dragging moves it in one undo step.
- A click on a marker sets the start of playback there. A double click
  renames it in the name dialog of the hierarchy groups.
- The context menu offers Play from here, Rename and Delete marker,
  on an empty place Add marker. A tooltip shows the full name. The
  wheel over the strip zooms time like the ruler.
- New agent commands add_song_marker, change_song_marker and
  remove_song_marker. get_asset lists them under markers.

Tests:
- song-markers in core 6 and editor 4, 1 agent test, with the touched
  song, agent and MCP tests 175/175
- 9 counter-probes, all compile, all red
- e2e 65 on atx: ALL PASSED, 70 checks, 3 new in 65g3. Counter-probe
  where a dragged marker is not taken over: 65g3 red
- gate on atx: CHECK EXIT 0, 332 files, 5093 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:44:58 +02:00
adminandClaude Opus 5.5 2099511695 Let the agent set automation lanes of a song track (#337)
feuerwerk / gate (push) Successful in 6m3s
feuerwerk / suite (push) Successful in 20m14s
Stage 4 of #337. Lanes could only be drawn in the song tab. Now the
agent can set them in one step and sees which parameters a lane of a
track can move.

- New command set_song_lane. The target is named as it is stored:
  volume, pan or param:<name>. All points come at once, each with
  tick, value and ease, from the same eases as animation keys,
  curve assets included.
- Points on one tick keep the order they come in, which makes a jump.
  points [] removes a volume or pan lane, remove a parameter lane.
- It refuses with a clear message: a value outside the range, an
  unknown ease, a missing curve asset, a range for volume or pan, and
  a new lane for a parameter nothing on the track has. The message
  then names the parameters there are.
- Without min and max a new parameter lane gets the range the dialog
  in the song tab offers.
- get_asset names under params of each song track the parameters a
  lane can move, with their default. The lanes were already in it.

Live in the user's project: get_asset named both parameters of their
two lanes (frequency, env) with defaults 20000 and 0.5. Playing to
3 s and 4.2 s, both tracks were heard, the audio clock running.

Tests:
- agent-music 13/13 (2 new), agent and MCP tests 129/129
- 6 counter-probes, all compile, all red
- gate on atx: CHECK EXIT 0, 330 files, 5082 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:54:51 +02:00
adminandClaude Opus 5.5 b50c7dbc35 Let a song track automate parameters of its sounds and FX (#337)
Stage 3 of #337. Lanes moved volume and pan only. Now a lane can
steer a parameter of anything on its track, and a voice already
planned or still waiting for its file follows it too.

- A lane may aim at param:<name>. A parameter has no range of its
  own, so the lane keeps min and max. A parameter lane stays without
  points, so its range is not lost. Remove lane takes it away.
- It reaches the track FX, the FX of an item, the instrument of a
  note with its own FX, and the FX of a clip. When several have the
  name, all of them change, as with Set sound FX parameters.
- The scheduler hands the lane value to a note or clip at its start.
  Values a voice only reads at its start act from the next note.
- While a voice sounds, the knobs of buses and voices of the track
  follow the lane on the audio clock, read every 10 ms, because a
  knob is not always the parameter itself (trackParams.ts).
- A voice waiting for its file takes the marks when it starts.
- When a lane is removed, the parameter goes back to its default.
  An edit while playing does not reset a parameter a lane rules.
- This holds in the song tab, for Play song, in compositions and in
  the bake.
- Song tab: Add parameter lane in the track head menu opens a dialog
  for parameter and range (ParamLaneDialog.ts). The lane head menu
  has Range and Remove lane.
- Inspector: while a lane rules a value, a hint at the track names
  the lanes and says the fields act once the lane is gone.

Known limit: a knob that depends on two automated parameters reckons
each lane with the last value of the other, so it may lag one window.

Tests:
- automation-params.test.ts (9, new), 3 new in sound-library and 3
  in song-lanes. Affected files 407/407.
- 17 counter checks, all compile, all red.
- e2e 65 on melektaus-atx: ALL PASSED, 67 checks. 65h3 is new: a
  clip with FX parameter cut starts at the lane value and its knob
  stays within 0.04 dB of the lane. It goes red when automateBus
  does not pass parameters on.
- Gate on melektaus-atx: CHECK EXIT 0, 330 files, 5080 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:30:42 +02:00
adminandClaude Opus 5.5 bd99f9e79d Let automation lanes of a song track be drawn in the song tab (#337)
Stage 2 of #337. The lanes of stage 1 played, but nobody could make
one. Now a track folds out a lane for volume and one for pan, and
points are set, dragged and eased right on the timeline.

- A lane is a row of its own, as high as a track, below its track.
  The view functions stay row based, so zoom, fit, item drags and
  the composition (which shares them) are unchanged.
- itemAt and itemsWithin take a map of track id to row.
- A triangle in the track head folds the lanes out. The head menu
  shows or hides each lane. A lane with points is open by itself.
- Which lanes are open belongs to the view of the tab and is saved
  with it. It is not part of the song and makes no undo step.
- A click sets a point on the grid, Alt or Shift on the exact tick.
  Press and drag sets one and drags it in a single undo step.
- A dragged point stops at its neighbour. Two points on one tick
  make a jump. Double click or Delete removes a point.
- The value shows next to a dragged point and as a tooltip.
- Right click on a point opens "Ease to next point" with the same
  choice as animation keys, curve assets included.
- The lane head shows the value at the playhead. Its menu clears
  or hides the lane.
- While a lane rules a value, the fader shows it in the lane colour
  and cannot be dragged (decision 4). A hint says why.
- Geometry in laneView.ts, edit rules in laneEdit.ts, the easing
  menu in easingMenu.ts. New test hooks automationAt,
  automationHeadAt and automation under __gm.song.

Open: the Volume (dB) field of a track in the inspector can still be
changed while a lane rules it. It has no effect while playing.

Tests:
- song-lanes.test.ts (12, new) and song-edit adapted: 27/27.
  Editor tests of song and composition 62/62.
- 9 counter checks, all compile, all red.
- e2e 65 on melektaus-atx: ALL PASSED, 66 checks. 65g2 is new with
  6 checks. It goes red when a set point is not taken on release.
- Gate on melektaus-atx: CHECK EXIT 0, 329 files, 5065 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:31:48 +02:00
adminandClaude Opus 5.5 389a7c0fd1 Let volume and pan of a song track follow automation lanes (#337)
A song track can now change its volume and pan over time, played on
the audio clock wherever the song is heard. This is stage 1 of #337:
the model and the playback. Lanes cannot be drawn in the song tab yet.

- A track holds automation lanes for volume (dB) and pan. Each point
  has a tick, a value and an easing from the shared list, curve assets
  included. Two points on one tick make a jump. Before the first point
  its value holds, after the last point the last value holds.
- The rules are pure functions in core/music/automation.ts: the value
  at a tick and the pieces of a lane inside one scheduling window.
- The scheduler hands those pieces to the host in every window. The
  host sets them as ramps or jumps on the gain and panner of the
  track's bus. Shapes the clock cannot ramp directly (dB as gain,
  curved easings) are sampled every 10 ms. At the loop end the value
  jumps back with the playhead.
- This works in the song tab, for Play song, inside a composition
  (across the song loop, on the item's own bus) and in a bake.
- While a lane has points the fader no longer sets that value. When
  the lane goes, the fader takes over from the current value.
- Automation enters the bake fingerprint only when there is some, so
  existing bakes stay valid.
- The level helpers moved to core/music/level.ts, so song.ts and
  automation.ts do not import each other.

Known gap: a delaying sound FX on a bus after the track (a pitch
shifter) makes the automation late by that delay.

Tests:
- automation.test.ts (13, new), 3 new in sound-library.test.ts.
  Song, bake and composition tests together 286/286.
- 12 counter-checks, all compile, all red.
- e2e 65 on atx: ALL PASSED, 60 checks. New 65h2: in the song tab the
  bus level follows a volume lane, 0.09 dB off, also with the fader at
  -60 dB. Red when the song tab does not pass the automation on.
- Gate on atx: 328 files, 5053 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:45:55 +02:00
95 changed files with 10609 additions and 329 deletions

No files matched your search

+10 -10
View File
@@ -1,19 +1,19 @@
# Runs the whole proof on the Gitea runner: gate, the editor suite and the three example games.
#
# The suite runs on `feuerwerk-wsl`, the runner in the WSL2 distro feuerwerk-ci on
# melektaus-atx (#283). Its label picks the image built from tools/ci/Dockerfile. The GPU
# (/dev/dxg and the Windows driver libraries), /dev/shm and FEUERWERK_BROWSER=wsl come from that
# runner's own config.yaml, not from here – without them chromium falls back to software WebGL and
# the harness stops. The gate needs no GPU and runs on `feuerwerk-ci`, the runner on stein2 with
# the same image. Pointing the suite at that label too moves the browser runs back to stein2.
# Both jobs run on `feuerwerk-ci`, the runner on stein2. Its label picks the image built from
# tools/ci/Dockerfile. The GPU (/dev/dri/renderD128) and /dev/shm come from that runner's own
# config.yaml, not from here – without them chromium falls back to software WebGL and the harness
# stops. The suite ran on `feuerwerk-wsl` in the WSL2 distro feuerwerk-ci on melektaus-atx (#283),
# whose config adds /dev/dxg and FEUERWERK_BROWSER=wsl. Pointing the suite at that label again
# moves the browser runs back there. It moved to stein2 because other jobs on melektaus-atx share
# its GPU and made the suite's frame timings wobble.
#
# Two jobs without `needs:`, so they start together. The gate does not stand in front of the
# suite: a lint error used to cost the full e2e run before it was reported, and the run reported
# it after six minutes instead of one. Both still have to pass for the push to be green.
#
# On two runners the gate's vitest no longer shares cores with the suite's chromium, which
# measures text widths and frame counts. GM_TEST_WORKERS still caps vitest at two workers, for the
# day both jobs share one runner again.
# On one runner the gate's vitest shares cores with the suite's chromium, which measures text
# widths and frame counts. GM_TEST_WORKERS caps vitest at two workers for that.
name: feuerwerk
on:
@@ -51,7 +51,7 @@ jobs:
php tests/php/rights.test.php
suite:
runs-on: feuerwerk-wsl
runs-on: feuerwerk-ci
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
+2
View File
@@ -231,6 +231,8 @@ function copyOf(original: Asset, id: string, name: string): Asset {
if (copy.type === 'texture' || copy.type === 'pixelart')
for (const frame of copy.frames) frame.id = newId();
renewClipIds(copy, new Map());
// A node names a marker by its id alone (#338), so a copy's markers are told apart.
if (copy.type === 'song') for (const marker of copy.markers ?? []) marker.id = newId();
if (copy.type === 'scene' || copy.type === 'container' || copy.type === 'composition') {
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
for (const object of copy.objects) {
+31
View File
@@ -8,6 +8,7 @@ import type { EdgeSend, Graph } from '../graph/Graph';
import type { Asset, SongAsset } from '../model/Asset';
import { findGraphAsset, type Project } from '../model/Project';
import { settleEmbedded } from '../music/embedded';
import { heldSongMarkers, type SongMarker } from '../music/markers';
import { routedKind, sendsTapped } from '../music/projectRouting';
import { heldSend, heldSongGraph } from '../music/routing';
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
@@ -75,6 +76,36 @@ export class SetSongCommand implements Command {
}
}
/** Writes the markers of a song whole (#338): one gesture, one step to undo. */
export class SetSongMarkersCommand implements Command {
private previous: SongMarker[] | undefined;
constructor(
private readonly assetId: string,
private readonly markers: readonly SongMarker[],
readonly label = 'Edit markers',
) {}
/** Nothing to do when the song already has exactly these markers – no empty undo step. */
static changes(asset: SongAsset, markers: readonly SongMarker[]): boolean {
return JSON.stringify(asset.markers ?? []) !== JSON.stringify(heldSongMarkers(markers));
}
execute(project: Project): void {
const asset = requireSong(project, this.assetId);
this.previous = asset.markers;
const held = heldSongMarkers(this.markers);
if (held.length > 0) asset.markers = held;
else delete asset.markers;
}
undo(project: Project): void {
const asset = requireSong(project, this.assetId);
if (this.previous) asset.markers = this.previous;
else delete asset.markers;
}
}
/**
* How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link
* of the master routing (#351), where only a bus and a song may tap anywhere but after their level.
+65 -6
View File
@@ -3,7 +3,9 @@ import type { CompositionPlayer } from '../model/composition';
import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties';
import type { FxParams } from '../soundfx/nodes';
import type { HeardPosition, Quantize } from '../music/beats';
import type { RecordingSettings } from '../recording/recording';
import type { VideoReading, VideoSteer } from '../model/videoClock';
import { type ChainTransform, composeAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
@@ -19,6 +21,15 @@ export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
* `x`/`y`/`props` is how actions take effect.
*/
/**
* A sound, note or pattern started in time with a playing song or pattern (#338), a stinger: on the
* spot `quantize` names of the playing numbered `playback`; 0 for none, which starts it at once.
*/
export interface MusicSync {
playback: number;
quantize: Quantize;
}
export interface EntityHandle {
readonly [ENTITY_HANDLE]: true;
id: string;
@@ -232,6 +243,13 @@ export interface NodeContext {
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
physics?: PhysicsControl;
log(message: string): void;
/**
* Plays, pauses or seeks the video the sprite `target` wears (#410). A free one gets a player of its
* own from then on; a driven one, and the editor run, are left alone and say so in the log.
*/
steerVideo(target: EntityHandle, steer: VideoSteer): void;
/** Where the video the sprite `target` wears stands (#410); null for none. */
videoReading(target: EntityHandle): VideoReading | null;
/**
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
@@ -246,7 +264,8 @@ export interface NodeContext {
* names (#352), `NO_FX` none.
* `fxParams` are values for its parameters; one not given is the default the FX declares.
* `tempo` is what a Delay synced to beats counts by (#332). `bus` is the bus it is heard on
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names.
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names. `sync` starts it in
* time with a playing (#338).
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
*/
playSound(
@@ -258,6 +277,7 @@ export interface NodeContext {
fxParams: FxParams,
tempo: number,
bus: string,
sync: MusicSync,
): number;
/**
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
@@ -266,7 +286,7 @@ export interface NodeContext {
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's;
* `''` the one the instrument names, `NO_FX` none (#352).
* `bus` as for `playSound`, `''` for the one the instrument names.
* `bus` as for `playSound`, `''` for the one the instrument names, and `sync` too.
* Returns the number of the voice, 0 where nothing plays.
*/
playNote(
@@ -281,13 +301,14 @@ export interface NodeContext {
fx: string,
fxParams: FxParams,
bus: string,
sync: MusicSync,
): number;
/**
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
* one chain (#346), `''` the one the pattern names, `NO_FX` none (#352); `fxParams` values for its
* parameters, `bus` as for `playSound`. Returns its
* number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
* parameters, `bus` as for `playSound`. Synced to a playing (#338), it starts on its spot and plays
* at its tempo. Returns its number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
*/
playPattern(
patternId: string,
@@ -297,6 +318,7 @@ export interface NodeContext {
fx: string,
fxParams: FxParams,
bus: string,
sync: MusicSync,
): number;
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
stopPattern(patternId: string, letRing: boolean): void;
@@ -304,8 +326,9 @@ export interface NodeContext {
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. Returns its number; 0 where
* there is nothing to play.
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. With `live` it plays its notes
* even where it has a bake, so its tracks can be turned (#338). Returns its number; 0 where there is
* nothing to play.
*/
playSong(
songId: string,
@@ -316,7 +339,33 @@ export interface NodeContext {
fx: string,
fxParams: FxParams,
bus: string,
live: boolean,
): number;
/**
* Turns one track of a playing song (#338) to `volumeDb` on top of its own level, over `fade`
* seconds from where `quantize` lands; a playing of its bake has no tracks, which is logged.
*/
setSongTrack(playback: number, track: string, volumeDb: number, fade: number, quantize: Quantize): void;
/**
* Holds the lane `lane` of one track of a playing song (#338) on `value`, in the lane's units,
* reached over `fade` seconds from where `quantize` lands; its points are not heard until it is
* let go. A track without such a lane, or a playing of its bake, is logged.
*/
setSongLane(
playback: number,
track: string,
lane: string,
value: number,
fade: number,
quantize: Quantize,
): void;
/** Lets such a held lane go (#338): back to its curve over `fade` seconds, as soon as can be heard. */
releaseSongLane(playback: number, track: string, lane: string, fade: number): void;
/**
* Plays `songId` in place of a playing song (#338), `''` the same one, from the marker `marker`
* (`''` its start), where `quantize` lands: over `fade` seconds, else in a cut. It keeps its number.
*/
switchSong(playback: number, songId: string, marker: string, quantize: Quantize, fade: number): void;
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
stopSong(songId: string, fadeOut: number): void;
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
@@ -354,6 +403,16 @@ export interface NodeContext {
setBusVolume(bus: string, volume: number, time: number, ease: EaseShare): void;
/** The volume a bus is at, `''` the master: what a game set, else that of its asset; 0 for none. */
busVolume(bus: string): number;
/**
* Where a playing song or pattern stands as it is heard (#338): the one numbered `playback`, else
* the one started last of `song` or `pattern`, of every one where both are `''`; null for none.
*/
songPosition(playback: number | null, song: string, pattern: string): HeardPosition | null;
/**
* Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its files,
* playing, fading or ringing on; false for 0.
*/
playing(id: number): boolean;
/**
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
+6 -1
View File
@@ -3,6 +3,7 @@ import { heldTempo } from '../../../music/time';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { detachedPort, exec, fxParamsPort, num, playedBusPort, soundFxPort, withDefault } from '../shared';
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
import { fxParamsOf, tempoPort } from './PlaySound';
export const PlayNote: NodeDef = {
@@ -10,7 +11,8 @@ export const PlayNote: NodeDef = {
label: 'Play note',
category: 'action',
description:
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names.',
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names. ' +
SYNC,
inputs: [
exec(),
{
@@ -41,6 +43,8 @@ export const PlayNote: NodeDef = {
{ ...soundFxPort(), key: 'soundFx' },
fxParamsPort('soundFxParams'),
playedBusPort(),
syncPort(),
quantizePort(),
],
outputs: [exec(), num('voice')],
params: [],
@@ -59,6 +63,7 @@ export const PlayNote: NodeDef = {
asString(inputs.soundFx),
fxParamsOf(inputs.soundFxParams),
asString(inputs.bus),
syncOf(inputs),
)
: 0;
return { next: 'exec', outputs: { voice } };
+6 -1
View File
@@ -10,6 +10,7 @@ import {
soundFxPort,
withDefault,
} from '../shared';
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
import { fxParamsOf } from './PlaySound';
export const patternPort = (): PortDef => ({
@@ -34,7 +35,8 @@ export const PlayPattern: NodeDef = {
'none. "fx params" takes values for its parameters from a Sound ' +
'FX parameters node. "playback" is the number of this playing, for Stop pattern and for Set sound ' +
'FX parameters; 0 where the pattern has no instrument or no notes. "bus" is the bus all of it is ' +
'heard on; left empty, the one the pattern names.',
`heard on; left empty, the one the pattern names. ${SYNC} Synced, it plays at the tempo of that ` +
'playing.',
inputs: [
exec(),
patternPort(),
@@ -44,6 +46,8 @@ export const PlayPattern: NodeDef = {
soundFxPort(),
fxParamsPort(),
playedBusPort(),
syncPort(),
quantizePort(),
],
outputs: [exec(), num('playback')],
params: [],
@@ -58,6 +62,7 @@ export const PlayPattern: NodeDef = {
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
asString(inputs.bus),
syncOf(inputs),
)
: 0;
return { next: 'exec', outputs: { playback } };
+6 -2
View File
@@ -33,8 +33,10 @@ export const PlaySong: NodeDef = {
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
'left empty, the one the song names, and None is none. ' +
'"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' +
'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' +
'nothing to play. "bus" is the bus all of it is heard on; left empty, the one the song names.',
'number of this playing, for Stop song, Set song track, Switch song and Set sound FX parameters; ' +
'0 where the song has nothing to play. "bus" is the bus all of it is heard on; left empty, the ' +
'one the song names. A song with a bake plays the bake, one sound for all its tracks; with "live ' +
'tracks" it plays its notes instead, as the Song tab does, so Set song track can turn its tracks.',
inputs: [
exec(),
songPort(),
@@ -45,6 +47,7 @@ export const PlaySong: NodeDef = {
soundFxPort(),
fxParamsPort(),
playedBusPort(),
checkPort('live', 'live tracks', 'Live tracks', false),
],
outputs: [exec(), num('playback')],
params: [],
@@ -60,6 +63,7 @@ export const PlaySong: NodeDef = {
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
asString(inputs.bus),
asBoolean(inputs.live),
)
: 0;
return { next: 'exec', outputs: { playback } };
+5 -1
View File
@@ -13,6 +13,7 @@ import {
soundPort,
withDefault,
} from '../shared';
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
/**
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
@@ -64,7 +65,7 @@ export const PlaySound: NodeDef = {
'the sound has not been loaded yet. A pitch never changes it. "tail" is that length and the ' +
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet. ' +
'"tempo" is what a Delay of its FX synced to beats counts by, in quarter notes a minute. "bus" is ' +
'the bus it is heard on; left empty, the one the sound names.',
`the bus it is heard on; left empty, the one the sound names. ${SYNC}`,
inputs: [
exec(),
soundPort(),
@@ -75,6 +76,8 @@ export const PlaySound: NodeDef = {
fxParamsPort(),
tempoPort(),
playedBusPort(),
syncPort(),
quantizePort(),
],
outputs: [exec(), num('voice'), num('length'), num('tail')],
params: [],
@@ -90,6 +93,7 @@ export const PlaySound: NodeDef = {
fxParamsOf(inputs.fxParams),
heldTempo(asNumber(inputs.tempo)),
asString(inputs.bus),
syncOf(inputs),
)
: 0;
const length = voice ? ctx.soundLength(sound) : 0;
+28
View File
@@ -0,0 +1,28 @@
import { heldQuantize, QUANTIZE_OPTIONS } from '../../../music/beats';
import type { MusicSync } from '../../NodeContext';
import type { PortDef } from '../../NodeRegistry';
import { asNumber, type Value } from '../../values';
import { num } from '../shared';
/** Where a change to the music lands (#338): at once, or on the next beat, bar or marker of the playing. */
export const quantizePort = (): PortDef => ({
key: 'quantize',
label: 'quantize',
type: 'string',
default: 'bar',
field: { key: 'quantize', label: 'Quantize', kind: 'enum', default: 'bar', options: [...QUANTIZE_OPTIONS] },
});
/** The playing a sound, note or pattern starts in time with (#338), a stinger: not typed in either. */
export const syncPort = (): PortDef => ({ ...num('sync'), inline: false });
/** What `syncPort` and `quantizePort` ask for. */
export function syncOf(inputs: Record<string, Value>): MusicSync {
return { playback: asNumber(inputs.sync), quantize: heldQuantize(inputs.quantize) };
}
/** How the nodes that play a sound, note or pattern tell of `sync` (#338). */
export const SYNC =
'"sync" takes the playback number of a playing song or pattern to start in time with, as a ' +
'stinger: on the next beat, bar or marker of it as it is heard, as "quantize" says, planned on ' +
'the audio clock to the sample. Unconnected, or where that playing is not heard, it starts at once.';
+173
View File
@@ -0,0 +1,173 @@
import { heldQuantize } from '../../../music/beats';
import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../../music/level';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { quantizePort } from './musicSync';
import { songPort } from './PlaySong';
/** The playing a node changes: not typed in, as it is a number Play song hands out. */
const playbackPort = (): PortDef => ({ ...num('playback'), inline: false });
const trackPort = (): PortDef => ({
key: 'track',
label: 'track',
type: 'string',
field: { key: 'track', label: 'Track', kind: 'asset', default: '', assetTypes: ['song'], tracks: true },
});
const markerPort = (): PortDef => ({
key: 'marker',
label: 'marker',
type: 'string',
field: { key: 'marker', label: 'Marker', kind: 'asset', default: '', assetTypes: ['song'], markers: true },
});
const QUANTIZE =
'"quantize" says where it lands: Now, or on the next beat, bar or marker of the song as it is ' +
'heard, planned on the audio clock to the sample; Next marker in a song without markers is the ' +
'next bar.';
export const SetSongTrack: NodeDef = {
type: 'SetSongTrack',
label: 'Set song track',
category: 'action',
description:
'Turns one track of a playing song up or down, by the "playback" number Play song handed out: ' +
'"volume" in decibels on top of the level the song gives it, 0 as the song has it, -60 silent – ' +
'what goes into the track, its sound FX and its sends alike. It gets there over "fade" seconds. ' +
`${QUANTIZE} The track is picked as "Song / Track"; a track the playing song does not have is ` +
'left alone. A song played from its bake has no tracks to turn: switch on "live tracks" in Play ' +
'song, else the log says so. A Switch song to the same song keeps what was turned.',
inputs: [
exec(),
playbackPort(),
trackPort(),
withDefault(
{
...num('volume', 'volume (dB)'),
field: {
key: 'volume',
label: 'Volume (dB)',
kind: 'number',
default: 0,
min: TRACK_MIN_DB,
max: TRACK_MAX_DB,
},
},
0,
),
withDefault(num('fade'), 1),
quantizePort(),
],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.setSongTrack(
asNumber(inputs.playback),
asString(inputs.track),
asNumber(inputs.volume),
asNumber(inputs.fade),
heldQuantize(inputs.quantize),
);
return { next: 'exec' };
},
};
const lanePort = (): PortDef => ({
key: 'lane',
label: 'lane',
type: 'string',
default: 'volume',
field: { key: 'lane', label: 'Lane', kind: 'text', default: 'volume' },
});
const LANE =
'The track is picked as "Song / Track"; "lane" names one of its automation lanes: "volume", ' +
'"pan" or a parameter by its name. A track the playing song does not have is left alone; one ' +
'without that lane is logged, and so is a song played from its bake ("live tracks" in Play song).';
export const SetSongLane: NodeDef = {
type: 'SetSongLane',
label: 'Set song lane',
category: 'action',
description:
'Takes an automation lane of one track of a playing song over, by the "playback" number Play ' +
'song handed out: from where the lane is, it goes to "value" over "fade" seconds and stays there, ' +
'the points of the lane no longer heard, until Release song lane lets it go. "value" is in the ' +
'units of the lane – decibels for the volume, -1 (left) to 1 (right) for the pan, a parameter in ' +
`the range of its lane – and held to that range. ${QUANTIZE} ${LANE} A Switch song to the same ` +
'song keeps what is held.',
inputs: [
exec(),
playbackPort(),
trackPort(),
lanePort(),
withDefault(num('value'), 0),
withDefault(num('fade'), 1),
quantizePort(),
],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.setSongLane(
asNumber(inputs.playback),
asString(inputs.track),
asString(inputs.lane),
asNumber(inputs.value),
asNumber(inputs.fade),
heldQuantize(inputs.quantize),
);
return { next: 'exec' };
},
};
export const ReleaseSongLane: NodeDef = {
type: 'ReleaseSongLane',
label: 'Release song lane',
category: 'action',
description:
'Lets an automation lane go that Set song lane took over, by the "playback" number Play song ' +
'handed out: as soon as it can be heard, it goes back over "fade" seconds to where the curve of ' +
'the lane is by then, and follows it from there. A lane that is not held stays as it is. ' +
LANE,
inputs: [exec(), playbackPort(), trackPort(), lanePort(), withDefault(num('fade'), 1)],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.releaseSongLane(
asNumber(inputs.playback),
asString(inputs.track),
asString(inputs.lane),
asNumber(inputs.fade),
);
return { next: 'exec' };
},
};
export const SwitchSong: NodeDef = {
type: 'SwitchSong',
label: 'Switch song',
category: 'action',
description:
'Plays another song, or the same one from a marker, in place of a playing song, by the ' +
'"playback" number Play song handed out; the playing keeps its number, so Stop song and Set ' +
'song track go on reaching it. "song" left empty is the same song, "marker" left empty its ' +
`start. ${QUANTIZE} With "fade" 0 it is a cut on that spot; else the old song falls silent over ` +
'that many seconds as the new one rises from it. The new song plays with the volume, loop, ' +
'bus, sound FX and "live tracks" of its Play song. A marker the song does not have is logged, ' +
'and nothing changes.',
inputs: [exec(), playbackPort(), songPort(), markerPort(), quantizePort(), withDefault(num('fade'), 0)],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.switchSong(
asNumber(inputs.playback),
asString(inputs.song),
asString(inputs.marker),
heldQuantize(inputs.quantize),
asNumber(inputs.fade),
);
return { next: 'exec' };
},
};
+60
View File
@@ -0,0 +1,60 @@
import type { VideoSteer } from '../../../model/videoClock';
import type { EntityHandle, NodeContext } from '../../NodeContext';
import type { NodeDef } from '../../NodeRegistry';
import { asEntity, asNumber, type Value } from '../../values';
import { exec, num, obj } from '../shared';
const STEERS =
'A free video gets a player of its own from then on, so other sprites wearing the same clip play ' +
'on. A driven one and the editor run are left alone and say so in the log. Unconnected, object is the owner.';
/** The sprite a video node works on: the connected object, or the owner. */
export function videoTarget(ctx: NodeContext, value: Value | undefined): EntityHandle | null {
return asEntity(value) ?? ctx.owner;
}
function steering(steer: (inputs: Record<string, Value>) => VideoSteer): NodeDef['run'] {
return (ctx, inputs) => {
const target = videoTarget(ctx, inputs.object);
if (target) ctx.steerVideo(target, steer(inputs));
return { next: 'exec' };
};
}
export const PlayVideo: NodeDef = {
type: 'PlayVideo',
label: 'Play video',
category: 'action',
description:
'Plays on the video a sprite wears from where it stands, heard as before. At the end of a clip ' +
`without Loop it starts over. ${STEERS}`,
inputs: [exec(), obj('object')],
outputs: [exec()],
params: [],
run: steering(() => ({ kind: 'play' })),
};
export const PauseVideo: NodeDef = {
type: 'PauseVideo',
label: 'Pause video',
category: 'action',
description: `Holds the video a sprite wears at the picture it shows, without sound. ${STEERS}`,
inputs: [exec(), obj('object')],
outputs: [exec()],
params: [],
run: steering(() => ({ kind: 'pause' })),
};
export const SeekVideo: NodeDef = {
type: 'SeekVideo',
label: 'Seek video',
category: 'action',
description:
'Sends the video a sprite wears to time, in seconds from the start of its clip; playing or held, ' +
'it stays so. Past the end it goes round with Loop and holds the last picture without, as Video ' +
`time does. ${STEERS}`,
inputs: [exec(), obj('object'), num('time', 'time (s)')],
outputs: [exec()],
params: [],
run: steering((inputs) => ({ kind: 'seek', time: asNumber(inputs.time) })),
};
+33
View File
@@ -0,0 +1,33 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { PLAYBACK_INPUT, playbackPort } from '../events/musicEvents';
import { bool, num } from '../shared';
export const MusicPlaying: NodeDef = {
type: 'MusicPlaying',
label: 'Music playing',
category: 'data',
description:
'Whether the song or pattern numbered "playback" is still to be heard: from Play song or Play ' +
'pattern on, while its files load, while it plays and fades, until its last echoes are gone – ' +
'the moment On music finished runs. Unlike "playing" of Song position it is on before the first ' +
'note is heard. Off for 0 and for a number nothing plays under.',
inputs: [playbackPort()],
outputs: [bool('playing')],
params: [],
read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input(PLAYBACK_INPUT))) }),
};
export const SoundPlaying: NodeDef = {
type: 'SoundPlaying',
label: 'Sound playing',
category: 'data',
description:
'Whether the voice numbered "voice", the number Play sound handed out, is still to be heard: ' +
'while its file loads, while it plays, until the echoes of its FX are gone – the moment On sound ' +
'finished runs. Off for 0 and for a voice that has ended.',
inputs: [{ ...num('voice'), inline: false }],
outputs: [bool('playing')],
params: [],
read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input('voice'))) }),
};
+34
View File
@@ -0,0 +1,34 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber, asString } from '../../values';
import { patternPort } from '../action/PlayPattern';
import { songPort } from '../action/PlaySong';
import { PLAYBACK_INPUT, playbackPort } from '../events/musicEvents';
import { bool, num } from '../shared';
export const SongPosition: NodeDef = {
type: 'SongPosition',
label: 'Song position',
category: 'data',
description:
'Where a playing song or pattern stands, as it is heard: the output latency of the device taken ' +
'off, a baked song too, so what is drawn by it keeps to the sound. "bar" and "beat" count from 1 ' +
'as the ruler does, "fraction" is how far into the beat (0 to below 1), "seconds" how far from ' +
'the start of the song, back with the loop. Connect "playback" for that playing; else it is the ' +
'one started last of the Song or the Pattern picked (both picked, either), of every song and ' +
'pattern where none is. "playing" is off before its first note is heard and after its end; with ' +
'no such playing everything is 0.',
inputs: [songPort(), patternPort(), playbackPort()],
outputs: [num('bar'), num('beat'), num('fraction'), num('seconds'), bool('playing')],
params: [],
read: (ctx, _params, input) => {
const playback = input(PLAYBACK_INPUT);
const at = ctx.songPosition(
playback === null ? null : asNumber(playback),
asString(input('song')),
asString(input('pattern')),
);
return at
? { bar: at.bar, beat: at.beat, fraction: at.fraction, seconds: at.seconds, playing: at.playing }
: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false };
},
};
+22
View File
@@ -0,0 +1,22 @@
import type { NodeDef } from '../../NodeRegistry';
import { videoTarget } from '../action/videoNodes';
import { bool, num, obj } from '../shared';
export const VideoTime: NodeDef = {
type: 'VideoTime',
label: 'Video time',
category: 'data',
description:
'Where the video a sprite wears stands: "time" in seconds from the start of its clip, "length" ' +
'how long the clip lasts, "playing" whether it moves. A free video counts from when the sprite ' +
'appeared, while it is switched on; a driven one stands where its Video time says. All 0 for a ' +
'sprite without a video. Unconnected, object is the owner.',
inputs: [obj('object')],
outputs: [num('time', 'time (s)'), num('length', 'length (s)'), bool('playing')],
params: [],
read: (ctx, _params, input) => {
const target = videoTarget(ctx, input('object'));
const reading = target ? ctx.videoReading(target) : null;
return reading ? { ...reading } : { time: 0, length: 0, playing: false };
},
};
@@ -0,0 +1,25 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { bool, exec, num } from '../shared';
/** The input that picks one play (#373): not typed in, so unconnected it is null and the Sound decides. */
export const VOICE_INPUT = 'voice';
export const OnSoundFinished: NodeDef = {
type: 'OnSoundFinished',
label: 'On sound finished',
category: 'event',
description:
'Runs once when a sound a Play sound of this game started is over: once nothing of it is heard ' +
'any more, the echoes of its FX included. "stopped" is on where it did not play to its end but ' +
'was stopped – by Stop sound, by the end of the behavior that owns it, or to make room for ' +
'newer voices – and where it never started because its file would not load. A looping sound ' +
'ends only so. Pick a Sound to hear only that one, or connect "voice" to hear only that play, ' +
'the number Play sound handed out. A note of an instrument is none of them. It comes in the ' +
'first frame after the end, so up to a frame late.',
inputs: [{ ...num(VOICE_INPUT), inline: false }],
outputs: [exec(), num('voice'), bool('stopped')],
params: [{ key: 'sound', label: 'Sound', kind: 'sound', default: '' }, orderParam],
hiddenParams: (params) => [...(textOf(params.sound ?? '') ? [] : ['sound']), ...hiddenOrder(params)],
};
@@ -0,0 +1,19 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, obj } from '../shared';
export const OnVideoFinished: NodeDef = {
type: 'OnVideoFinished',
label: 'On video finished',
category: 'event',
description:
'Runs once when a video without Loop reaches its last picture on the owner, or on a sprite below ' +
'it – and when one this behavior played, paused or sought does, on whatever sprite. A free video ' +
'counts from when its sprite appeared, while it is switched on; a driven one ends when its Video ' +
'time passes the end. Leave Video at None to hear every video; a video hears its clips as well.',
inputs: [],
outputs: [exec(), obj('object')],
params: [{ key: 'video', label: 'Video', kind: 'asset', default: '', assetTypes: ['video'] }, orderParam],
hiddenParams: (params) => [...(textOf(params.video ?? '') ? [] : ['video']), ...hiddenOrder(params)],
};
+112
View File
@@ -0,0 +1,112 @@
import { textOf } from '../../../model/Properties';
import type { PropertyDef } from '../../../properties/PropertySchema';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { bool, exec, num, str } from '../shared';
/** The input that picks one playing (#338): not typed in, so unconnected it is null and hears every one. */
export const PLAYBACK_INPUT = 'playback';
export const playbackPort = (): PortDef => ({ ...num(PLAYBACK_INPUT), inline: false });
const songParam: PropertyDef = {
key: 'song',
label: 'Song',
kind: 'asset',
default: '',
assetTypes: ['song'],
};
const patternParam: PropertyDef = {
key: 'pattern',
label: 'Pattern',
kind: 'asset',
default: '',
assetTypes: ['pattern'],
};
const markerParam: PropertyDef = {
key: 'marker',
label: 'Marker',
kind: 'asset',
default: '',
assetTypes: ['song'],
markers: true,
};
/** A pick left at None is not shown on the node: it hears every one. */
const hiddenEmpty =
(...keys: string[]) =>
(params: Parameters<NonNullable<NodeDef['hiddenParams']>>[0]) => [
...keys.filter((key) => textOf(params[key] ?? '') === ''),
...hiddenOrder(params),
];
const HEARS =
'It hears every song a Play song plays and every pattern a Play pattern plays, wherever it was ' +
'played; pick a Song or a Pattern to hear only that one (both picked, either), or connect ' +
'"playback" to hear only that playing. A composition plays none of them. It goes by what is heard, ' +
'the output latency of the device taken off, a baked song too. A frame comes now and then, so ' +
'"late" says how many seconds ago it was heard; one more than half a beat (at most 0.15 s) ago is ' +
'not told, and of several passed in one frame only the last.';
export const OnBeat: NodeDef = {
type: 'OnBeat',
label: 'On beat',
category: 'event',
description:
'Runs on every beat of a playing song or pattern, the first beat of a bar too. A beat is a note ' +
'of the value under the line of the time signature: a quarter in 4/4, an eighth in 6/8. "bar" ' +
'and "beat" count from 1, as the ruler does, and start over with the loop. ' +
HEARS,
inputs: [playbackPort()],
outputs: [exec(), num('playback'), num('bar'), num('beat'), num('late')],
params: [songParam, patternParam, orderParam],
hiddenParams: hiddenEmpty('song', 'pattern'),
};
export const OnBar: NodeDef = {
type: 'OnBar',
label: 'On bar',
category: 'event',
description:
'Runs at the start of every bar of a playing song or pattern, "bar" counting from 1 as the ruler ' +
'does; with the loop it starts over at the bar the loop starts in. ' +
HEARS,
inputs: [playbackPort()],
outputs: [exec(), num('playback'), num('bar'), num('late')],
params: [songParam, patternParam, orderParam],
hiddenParams: hiddenEmpty('song', 'pattern'),
};
export const OnMusicFinished: NodeDef = {
type: 'OnMusicFinished',
label: 'On music finished',
category: 'event',
description:
'Runs once when a song or pattern a Play song or Play pattern of this game started is over: once ' +
'nothing of it is heard any more, its last echoes and a fade out included. "stopped" is on where ' +
'it did not play to its end but was stopped – by Stop song, Stop pattern or Stop sound, or by ' +
'the end of the behavior that owns it. One with a loop ends only so. After a Switch song it ' +
'counts as the song heard last. Pick a Song or a Pattern to hear only that one (both picked, ' +
'either), or connect "playback" to hear only that playing. It comes in the first frame after ' +
'the end, so up to a frame late.',
inputs: [playbackPort()],
outputs: [exec(), num('playback'), bool('stopped')],
params: [songParam, patternParam, orderParam],
hiddenParams: hiddenEmpty('song', 'pattern'),
};
export const OnSongMarker: NodeDef = {
type: 'OnSongMarker',
label: 'On song marker',
category: 'event',
description:
'Runs when a playing song passes a marker of its ruler, every time round the loop. Pick a Marker ' +
'to hear only that one, or a Song to hear only its markers; markers on one tick each run it. ' +
'"marker" is the id a marker is known by, "name" what it is called. Connect "playback" to hear ' +
'only that playing. It goes by what is heard, a baked song too. "late" says how many seconds ago ' +
'it was heard; one more than half a beat (at most 0.15 s) ago is not told, and of several passed ' +
'in one frame only the last.',
inputs: [playbackPort()],
outputs: [exec(), num('playback'), str('marker'), str('name'), num('late')],
params: [songParam, markerParam, orderParam],
hiddenParams: hiddenEmpty('song', 'marker'),
};
+25
View File
@@ -31,10 +31,12 @@ import { PlayPattern } from './action/PlayPattern';
import { StopPattern } from './action/StopPattern';
import { PlaySong } from './action/PlaySong';
import { StopSong } from './action/StopSong';
import { ReleaseSongLane, SetSongLane, SetSongTrack, SwitchSong } from './action/songSync';
import { SetZ } from './action/SetZ';
import { Spawn } from './action/Spawn';
import { StopGame } from './action/StopGame';
import { StartRecording, StopRecording } from './action/recordingNodes';
import { PauseVideo, PlayVideo, SeekVideo } from './action/videoNodes';
import { StopSound } from './action/StopSound';
import { CellToWorld, SetTile, TileAt, TileLiteral, WorldToCell } from './action/tileNodes';
import { Abs } from './data/Abs';
@@ -125,6 +127,12 @@ import { SubList } from './data/SubList';
import { TextureSize } from './data/TextureSize';
import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnCompositionFinished } from './events/OnCompositionFinished';
import { OnVideoFinished } from './events/OnVideoFinished';
import { OnBar, OnBeat, OnMusicFinished, OnSongMarker } from './events/musicEvents';
import { OnSoundFinished } from './events/OnSoundFinished';
import { MusicPlaying, SoundPlaying } from './data/MusicPlaying';
import { SongPosition } from './data/SongPosition';
import { VideoTime } from './data/VideoTime';
import { OnBehavior } from './events/OnBehavior';
import { OnClick } from './events/OnClick';
import { OnMouseWheel } from './events/OnMouseWheel';
@@ -186,6 +194,12 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnMouseWheel,
OnAnimationFinished,
OnCompositionFinished,
OnVideoFinished,
OnBeat,
OnBar,
OnSongMarker,
OnMusicFinished,
OnSoundFinished,
OnBehavior,
OnStateEntered,
OnStateExited,
@@ -313,6 +327,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StopAnimation,
PlayComposition,
StopComposition,
PlayVideo,
PauseVideo,
SeekVideo,
PlaySound,
StopSound,
SetSoundFxParameters,
@@ -323,6 +340,14 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StopPattern,
PlaySong,
StopSong,
SetSongTrack,
SetSongLane,
ReleaseSongLane,
SwitchSong,
SongPosition,
MusicPlaying,
SoundPlaying,
VideoTime,
SetBusVolume,
BusVolume,
LoadScene,
+3
View File
@@ -1,5 +1,6 @@
import type { Graph } from '../graph/Graph';
import type { Bake } from '../music/bake';
import type { SongMarker } from '../music/markers';
import type { PatternNote } from '../music/pattern';
import type { SongItem, SongTrack } from '../music/song';
import type { LoopRange } from '../music/sequence';
@@ -490,6 +491,8 @@ export interface SongAsset extends AssetBase {
tracks: SongTrack[];
items: SongItem[];
loop?: LoopRange;
/** Named spots on its time (#338); left out without one. */
markers?: SongMarker[];
graph: Graph;
bake?: Bake;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* The time of the video a sprite wears, as a run counts it (#410): what `On video finished` and
* `Video time` go by, and what `Play video`, `Pause video` and `Seek video` set.
*
* A free video counts from when its sprite appears, while it is switched on; a behavior that steers
* it takes it over from there. A driven one has the time its `Video time` says.
*/
export interface VideoClock {
/** The texture it counts for, a video or a clip of one; another one starts it over. */
ref: string;
/** Seconds into the clip: between 0 and its length, once that is known. */
time: number;
playing: boolean;
/** Set once a behavior steered it: the run drives the sprite's video by this time from then on. */
steered: boolean;
/** The behaviors that steered it: they hear its end wherever it stands. */
steeredBy: object[];
}
export type VideoSteer = { kind: 'play' } | { kind: 'pause' } | { kind: 'seek'; time: number };
/** What `Video time` reads. */
export interface VideoReading {
time: number;
length: number;
playing: boolean;
}
export function newVideoClock(ref: string): VideoClock {
return { ref, time: 0, playing: true, steered: false, steeredBy: [] };
}
/**
* Where `time` seconds into a clip `length` long stand, by the rule of `Video time`: round again with
* Loop, held at the end without. An unknown length (0) bounds only at the start.
*/
export function clipTimeOf(time: number, length: number, loop: boolean): number {
const run = Number.isFinite(time) ? Math.max(0, time) : 0;
if (!(length > 0)) return run;
return loop ? run % length : Math.min(run, length);
}
/**
* Moves a playing clock on by `dt`. Returns whether it reached the end of a clip without Loop: it
* stops there, so that happens once a pass.
*/
export function advanceVideoClock(clock: VideoClock, dt: number, length: number, loop: boolean): boolean {
if (!clock.playing) return false;
const time = clock.time + dt;
if (!(length > 0) || loop || time < length) {
clock.time = clipTimeOf(time, length, loop);
return false;
}
clock.time = length;
clock.playing = false;
return true;
}
/** What a steering node does to the clock; Play at the end of a clip without Loop starts it over. */
export function steerVideoClock(clock: VideoClock, steer: VideoSteer, length: number, loop: boolean): void {
if (steer.kind === 'pause') clock.playing = false;
else if (steer.kind === 'seek') clock.time = clipTimeOf(steer.time, length, loop);
else {
if (!loop && length > 0 && clock.time >= length) clock.time = 0;
clock.playing = true;
}
}
+149 -5
View File
@@ -1,4 +1,15 @@
import type { FadeCurve } from '../model/sound';
import type { FxParams } from '../soundfx/nodes';
import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, type SequenceLane } from './automation';
import {
heldMarks,
heldParamsAt,
heldValueAt,
holdKey,
holdsAfter,
type LaneHold,
type LanePlaying,
} from './laneHolds';
import { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
import {
COMMON_TIME,
@@ -30,6 +41,8 @@ export interface ScheduledNote {
/** A factor on its loudness, and the track of a song it is heard on (#334). */
volume?: number;
bus?: string;
/** The values the parameter lanes of its track have where it starts (#337). */
params?: FxParams;
}
/** One clip handed to the player (#334): when it starts, how long it plays and from where in the sound. */
@@ -43,6 +56,8 @@ export interface ScheduledClip {
volume: number;
bus: string;
tempo: number;
/** As a note's. */
params?: FxParams;
}
/**
@@ -59,6 +74,18 @@ export interface ScheduledRamp {
done: number;
}
/**
* Where a lane of automation (#337) takes a bus over one window: each mark a second of the audio
* clock with the value the bus is ramped to, or jumps to there. With `fresh` the playing has just
* started, and what was planned for the bus from the first mark on is to go.
*/
export interface ScheduledAutomation {
bus: string;
target: AutomationTarget;
marks: LaneMark[];
fresh: boolean;
}
/** What the scheduler needs of the audio side; a test hands in a clock of its own. */
export interface SchedulerHost {
/** Seconds on the audio clock. */
@@ -77,6 +104,8 @@ export interface SchedulerHost {
playClip?(clip: ScheduledClip): number;
/** Ramps the level of a bus; a host without levels leaves it out. */
ramp?(ramp: ScheduledRamp): void;
/** Takes a bus along its automation; a host without it leaves it out. */
automate?(automation: ScheduledAutomation): void;
playing(voice: number): boolean;
/** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */
stop(voices: readonly number[], letRing?: boolean): void;
@@ -117,6 +146,8 @@ export class Scheduler {
private before: { anchor: number; from: number; tempo: number; loop: LoopRange | null } | null = null;
private voices: number[] = [];
private sleep: (() => void) | null = null;
/** The lanes a game holds on a value of its own (#338), by `holdKey`, each's holds in the order they land. */
private holds = new Map<string, LaneHold[]>();
constructor(
private readonly host: SchedulerHost,
@@ -138,16 +169,26 @@ export class Scheduler {
return this.anchor;
}
/** Played to its end, and none of its notes is heard any more. */
/**
* Played to its end, and none of its notes is heard any more. Planned to its end is not yet played
* to it: its notes are planned ahead, and a last bar may end in silence (#373).
*/
get done(): boolean {
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
return (
this.ended &&
this.headAt(this.host.now()) >= this.end &&
this.voices.every((voice) => !this.host.playing(voice))
);
}
/** Plays from `from` ticks of the sequence, or from where it was last started or sent. */
start(from = this.from): void {
/**
* Plays from `from` ticks of the sequence, or from where it was last started or sent – heard from
* `at` on the audio clock where that is later than it can be (#338).
*/
start(from = this.from, at = -Infinity): void {
this.stop();
this.from = Math.max(0, Math.round(from));
this.anchor = this.host.now() + LEAD + this.ahead();
this.anchor = Math.max(this.host.now() + LEAD + this.ahead(), at);
this.until = 0;
this.before = null;
this.ended = false;
@@ -188,6 +229,96 @@ export class Scheduler {
this.meter = heldMeter(meter);
}
/**
* Holds the lane of `target` on the bus `bus` on `value`, in its units (#338): from the second `at`
* of the audio clock it goes there from where it is over `fade` seconds and stays, its own points
* no longer heard. A hold that lands before it rules until then; one that would land later, or a
* release still to come, gives way. What is planned for it past `at` is planned again. False where
* the sequence has no such lane.
*/
holdLane(bus: string, target: AutomationTarget, value: number, at: number, fade: number): boolean {
const lane = this.laneOf(bus, target);
if (!lane) return false;
const key = holdKey(bus, target);
const playing = this.lanePlaying();
const kept = (this.holds.get(key) ?? []).filter((hold) => hold.at < at);
const start = this.anchor === null ? value : heldValueAt(lane, kept, playing, playing.elapsed(at));
const last = kept.at(-1);
if (last?.release && last.release.at >= at) last.release = null;
this.holds.set(key, [...kept, { at, fade: Math.max(0, fade), start, value, release: null }]);
this.replan(lane, at);
return true;
}
/**
* Lets a held lane go (#338): from the second `at` it goes back over `fade` seconds to where its
* curve is by then, and follows it from there. A hold that would land later is never heard. False
* where the lane is not held.
*/
releaseLane(bus: string, target: AutomationTarget, at: number, fade: number): boolean {
const lane = this.laneOf(bus, target);
const key = holdKey(bus, target);
const holds = this.holds.get(key);
if (!lane || !holds?.length) return false;
const kept = holds.filter((hold) => hold.at < at);
const dropped = holds[kept.length]?.at ?? Infinity;
const last = kept.at(-1);
if (last && !(last.release && last.release.at <= at)) last.release = { at, fade: Math.max(0, fade) };
if (kept.length > 0) this.holds.set(key, kept);
else this.holds.delete(key);
this.replan(lane, Math.min(at, dropped));
return true;
}
/** Holds the lanes `other` holds (#338): a playing that takes over from it keeps them. */
keepHolds(other: Scheduler): void {
for (const [key, holds] of other.holds)
this.holds.set(
key,
holds.map((hold) => ({ ...hold, release: hold.release && { ...hold.release } })),
);
}
/** The lane of the sequence on that bus for that target; undefined for none. */
private laneOf(bus: string, target: AutomationTarget): SequenceLane | undefined {
return this.sequence().lanes?.find((lane) => lane.bus === bus && lane.target === target);
}
/** How the sequence plays for its lanes: the playing time is counted from the anchor. */
private lanePlaying(): LanePlaying {
const anchor = this.anchor ?? 0;
return {
from: this.from,
loop: this.loop,
elapsed: (at) => ticksOfSeconds(at - anchor, this.tempo),
};
}
/** What is planned for a lane from the second `at` on is planned again, from there to where planning got. */
private replan(lane: SequenceLane, at: number): void {
if (this.anchor === null) return;
const start = Math.max(0, this.lanePlaying().elapsed(at));
if (start < this.until) this.automate(lane, start, this.until, true, true);
}
/**
* Takes the bus of a lane along it over the playing time from `start` up to `end` (#337), along its
* holds where they rule (#338). With `restart` what was planned for it from the first mark on goes.
*/
private automate(lane: SequenceLane, start: number, end: number, fresh: boolean, restart: boolean): void {
if (!this.host.automate || this.anchor === null) return;
const anchor = this.anchor;
const step = ticksOfSeconds(AUTOMATION_STEP, this.tempo);
const holds = this.holds.get(holdKey(lane.bus, lane.target)) ?? [];
const marks = heldMarks(lane, holds, this.lanePlaying(), start, end, fresh, step);
this.host.automate({
bus: lane.bus,
target: lane.target,
marks: marks.map((mark) => ({ ...mark, at: anchor + secondsOfTicks(mark.at, this.tempo) })),
fresh: restart,
});
}
/**
* Where what is heard right now stands: the audio clock less the output latency, so a playhead
* drawn from it is not ahead of the sound. Before the first note it is where it starts.
@@ -206,7 +337,10 @@ export class Scheduler {
if (to <= this.until) return;
this.voices = this.voices.filter((voice) => this.host.playing(voice));
const sequence = this.sequence();
const lanes = sequence.lanes ?? [];
const playing = this.lanePlaying();
for (const due of notesIn(sequence, this.from, this.loop, this.until, to)) {
const params = heldParamsAt(lanes, this.holds, playing, due.bus, due.tick, due.at);
const voice = this.host.play({
at: this.anchor + secondsOfTicks(due.at, this.tempo),
duration: secondsOfTicks(due.length, this.tempo),
@@ -216,11 +350,13 @@ export class Scheduler {
tempo: due.tempo ?? this.tempo,
...(due.volume !== undefined && { volume: due.volume }),
...(due.bus !== undefined && { bus: due.bus }),
...(params && { params }),
});
if (voice !== 0) this.voices.push(voice);
}
if (this.host.playClip)
for (const due of clipsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
const params = heldParamsAt(lanes, this.holds, playing, due.bus, due.tick + due.skip, due.at);
const voice = this.host.playClip({
at: this.anchor + secondsOfTicks(due.at, this.tempo),
duration: secondsOfTicks(due.length, this.tempo),
@@ -230,6 +366,7 @@ export class Scheduler {
volume: due.volume,
bus: due.bus,
tempo: due.tempo ?? this.tempo,
...(params && { params }),
});
if (voice !== 0) this.voices.push(voice);
}
@@ -246,8 +383,15 @@ export class Scheduler {
done: whole > 0 ? due.skip / whole : 0,
});
}
for (const lane of lanes) this.automate(lane, this.until, to, this.fresh, this.fresh);
this.fresh = false;
this.until = to;
// A hold let go and planned back to its curve is over.
for (const [key, holds] of this.holds) {
const left = holdsAfter(holds, playing, to);
if (left.length > 0) this.holds.set(key, left);
else this.holds.delete(key);
}
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
this.ended = true;
this.sleep?.();
+425
View File
@@ -0,0 +1,425 @@
/**
* Automation of a song (#337): the volume and the pan of a track changing over the song, as points
* on a lane. From each point the value runs to the next along that point's easing, as from one
* animation key to the next. While a lane has points it rules its value in place of the fader: before
* its first point it is that point's value, after its last the last one's. Two points on one tick
* are a jump there.
*
* The scheduler reads a lane window by window as marks on the audio clock, each ramped or jumped to.
* Where the value moves along a shape the clock cannot ramp, it is read every `AUTOMATION_STEP`.
*
* A lane can also move a parameter (`param:<name>`): of the track's sound FX, of the sound FX of its
* items, of the instruments of its notes and the sound FX of its clips – wherever one of them has a
* parameter of that name, as `Set sound FX parameters` reaches them. A note or clip starts at the
* value its lane has there, so a parameter a voice only reads when it starts is heard from the next one.
*/
import type { KeyInterpolation, VectorPoint } from '../model/Asset';
import { easedShare, easingOf, isKeyInterpolation } from '../model/animation';
import type { CurveLookup } from '../model/curve';
import type { FxParams } from '../soundfx/nodes';
import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level';
import { type LoopRange, playheadAt } from './sequence';
/** What a lane always moves on a track: its fader and its pan. */
export type TrackTarget = 'volume' | 'pan';
/** A parameter by its name (#337, stage 3); no name of a parameter has a colon. */
export type ParamTarget = `param:${string}`;
export type AutomationTarget = TrackTarget | ParamTarget;
export const AUTOMATION_TARGETS: readonly TrackTarget[] = ['volume', 'pan'];
const PARAM_PREFIX = 'param:';
export function paramTarget(name: string): ParamTarget {
return `${PARAM_PREFIX}${name}`;
}
/** The parameter a target moves; null for the volume and the pan. */
export function paramOf(target: AutomationTarget): string | null {
return target.startsWith(PARAM_PREFIX) ? target.slice(PARAM_PREFIX.length) : null;
}
/** Whether a text is a target a lane can have: the volume, the pan or a parameter with a name. */
export function isAutomationTarget(value: unknown): value is AutomationTarget {
if (value === 'volume' || value === 'pan') return true;
if (typeof value !== 'string' || !value.startsWith(PARAM_PREFIX)) return false;
const name = value.slice(PARAM_PREFIX.length);
return name !== '' && !name.includes(':');
}
/**
* The target a lane is named by in a game (#338): `volume`, `pan`, or a parameter by its name, with
* or without `param:` in front; null for a name no lane can have.
*/
export function targetNamed(name: string): AutomationTarget | null {
const text = name.trim();
if (isAutomationTarget(text)) return text;
const target = paramTarget(text);
return isAutomationTarget(target) ? target : null;
}
export interface AutomationPoint {
tick: number;
/** In the units of its target: decibels for the volume, -1 (left) to 1 (right) for the pan. */
value: number;
/** How it runs to the next point; linear where left out. */
ease?: KeyInterpolation;
}
export interface AutomationLane {
target: AutomationTarget;
/** In the order of their ticks. */
points: AutomationPoint[];
/**
* Only on a parameter's lane: the range its values are drawn and held in, `PARAM_RANGE` where left
* out. A parameter says nothing of its own range, so the lane does. Such a lane is kept without a
* point too, for its range; it rules its parameter once it has one.
*/
min?: number;
max?: number;
}
/** What says how a lane's values are held and drawn: its target, and a parameter's range. */
export type LaneKind = Pick<AutomationLane, 'target' | 'min' | 'max'>;
/** A lane's kind, or the volume or the pan by name: neither has a range of its own to name. */
export type LaneSpec = LaneKind | TrackTarget;
export function laneKind(spec: LaneSpec): LaneKind {
return typeof spec === 'string' ? { target: spec } : spec;
}
/** The range of a parameter's lane that sets none. */
export const PARAM_RANGE = { min: 0, max: 1 } as const;
/** Seconds between two readings of a value that moves along a shape the audio clock cannot ramp. */
export const AUTOMATION_STEP = 0.01;
/** The lowest and the highest value a lane of that kind holds. */
export function laneRange(spec: LaneSpec): { min: number; max: number } {
const { target, min, max } = laneKind(spec);
if (target === 'volume') return { min: TRACK_MIN_DB, max: TRACK_MAX_DB };
if (target === 'pan') return { min: -1, max: 1 };
return heldRange(min, max) ?? { ...PARAM_RANGE };
}
/** A range a parameter's lane may have: both ends finite, the top above the bottom; null otherwise. */
export function heldRange(min: unknown, max: unknown): { min: number; max: number } | null {
if (typeof min !== 'number' || typeof max !== 'number') return null;
if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return null;
return { min, max };
}
/** A lane's value as its bus takes it: the volume as a gain, the pan and a parameter as they are. */
export function audioValueOf(lane: LaneKind, value: number): number {
const { min, max } = laneRange(lane);
const held = Math.max(min, Math.min(max, value));
return lane.target === 'volume' ? dbToGain(held) : held;
}
/**
* Held to what the lanes of a track may be: a target it knows, one lane for each, points in whole
* ticks from 0 with their values in range, in the order of their ticks. A lane of the volume or the
* pan without a point is dropped; a parameter's is kept for its range.
*/
export function heldLanes(raw: unknown): AutomationLane[] {
const lanes: AutomationLane[] = [];
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
if (typeof entry !== 'object' || entry === null) continue;
const lane = entry as Record<string, unknown>;
const target = lane.target;
if (!isAutomationTarget(target) || lanes.some((l) => l.target === target)) continue;
const range = paramOf(target) !== null ? heldRange(lane.min, lane.max) : null;
const { min, max } = range ?? laneRange({ target });
const points = (Array.isArray(lane.points) ? (lane.points as unknown[]) : []).flatMap(
(point): AutomationPoint[] => {
if (typeof point !== 'object' || point === null) return [];
const { tick, value, ease } = point as Record<string, unknown>;
if (typeof tick !== 'number' || !Number.isFinite(tick) || !(tick >= 0)) return [];
if (typeof value !== 'number' || !Number.isFinite(value)) return [];
return [
{
tick: Math.round(tick),
value: Math.max(min, Math.min(max, value)),
...(isKeyInterpolation(ease) && ease !== 'linear' && { ease }),
},
];
},
);
points.sort((a, b) => a.tick - b.tick);
if (range) lanes.push({ target, points, ...range });
else if (points.length > 0 || paramOf(target) !== null) lanes.push({ target, points });
}
return lanes;
}
/** The parameters a track's lanes move, in their order, those without a point among them. */
export function laneParams(track: { automation?: readonly AutomationLane[] }): string[] {
return (track.automation ?? []).flatMap((lane) => paramOf(lane.target) ?? []);
}
/** The lane of a track for that target, while it has a point. */
export function laneOf(
track: { automation?: readonly AutomationLane[] },
target: AutomationTarget,
): AutomationLane | undefined {
return track.automation?.find((lane) => lane.target === target && lane.points.length > 0);
}
/** A lane on the bus it acts on, the Curve assets its points ease along looked up; one gone is linear. */
export function sequenceLaneOf(lane: AutomationLane, bus: string, curves: CurveLookup): SequenceLane {
return {
bus,
target: lane.target,
...(lane.min !== undefined && lane.max !== undefined && { min: lane.min, max: lane.max }),
points: lane.points.map((point) => {
const { shape, curve } = easingOf(point.ease ?? 'linear');
const found = shape === 'curve' ? curves(curve ?? '') : undefined;
return found ? { ...point, curve: found } : point;
}),
};
}
/** A point as a sequence plays it: a Curve asset it eases along already looked up. */
export interface SequenceLanePoint extends AutomationPoint {
curve?: readonly VectorPoint[];
}
/**
* A lane as a sequence plays it: on the bus it acts on, never without a point. A parameter's acts on
* that bus's sound FX and on everything heard on a bus inside it (`reaches`).
*/
export interface SequenceLane extends LaneKind {
bus: string;
points: readonly SequenceLanePoint[];
}
/** Whether a voice or a bus by that name is on the track of `bus` – it, or a bus inside it (`<bus>:…`). */
export function reaches(bus: string, name: string): boolean {
return name === bus || name.startsWith(`${bus}:`);
}
/**
* The values the parameter lanes of a sequence have at `tick` for what is heard on `bus`, a note's or
* a clip's; undefined where none reaches it.
*/
export function paramsAt(
lanes: readonly SequenceLane[],
bus: string | undefined,
tick: number,
): FxParams | undefined {
if (bus === undefined) return undefined;
let params: Record<string, number> | undefined;
for (const lane of lanes) {
const name = paramOf(lane.target);
if (name === null || !reaches(lane.bus, bus)) continue;
const { min, max } = laneRange(lane);
(params ??= {})[name] = Math.max(min, Math.min(max, laneValueAt(lane.points, tick)));
}
return params;
}
function between(a: SequenceLanePoint, b: SequenceLanePoint, t: number): number {
return a.value + (b.value - a.value) * easedShare(a.ease ?? 'linear', t, () => a.curve);
}
/** The index of the last point on or before `tick`, -1 for none. */
function lastAt(points: readonly SequenceLanePoint[], tick: number): number {
let i = -1;
while (i + 1 < points.length && points[i + 1]!.tick <= tick) i++;
return i;
}
/** The value at `tick`, a jump there already taken. */
export function laneValueAt(points: readonly SequenceLanePoint[], tick: number): number {
const i = lastAt(points, tick);
if (i < 0) return points[0]!.value;
const a = points[i]!;
const b = points[i + 1];
return b ? between(a, b, (tick - a.tick) / (b.tick - a.tick)) : a.value;
}
/** The value just before `tick`: what a jump there jumps from. */
export function laneValueBefore(points: readonly SequenceLanePoint[], tick: number): number {
let i = -1;
while (i + 1 < points.length && points[i + 1]!.tick < tick) i++;
if (i < 0) return points[0]!.value;
const a = points[i]!;
const b = points[i + 1];
if (!b) return a.value;
if (b.tick > tick) return between(a, b, (tick - a.tick) / (b.tick - a.tick));
return a.ease === 'step' ? a.value : between(a, b, 1);
}
/** Whether the way from `a` to `b` holds or is a straight line in lane units. */
function straight(a: SequenceLanePoint, b: SequenceLanePoint): boolean {
const ease = a.ease ?? 'linear';
return a.value === b.value || ease === 'step' || ease === 'linear';
}
/**
* Whether the bus takes it as one ramp: held, or a straight line also in its own units. A parameter
* becomes its knobs along ways of their own, so only one held is a ramp of them.
*/
function rampable(target: AutomationTarget, a: SequenceLanePoint, b: SequenceLanePoint): boolean {
const ease = a.ease ?? 'linear';
return a.value === b.value || ease === 'step' || (ease === 'linear' && target === 'pan');
}
/** One place on the way of a lane: `at` in ticks, the value as its bus takes it, ramped to or jumped to. */
export interface LaneMark {
at: number;
value: number;
jump: boolean;
}
/**
* Where a lane is over the playing time from `start` up to `end`, as `notesIn` lays out notes: a mark
* where the window starts, at every point and every reading in between, and where it ends. Where the
* playhead lands – at the start with `fresh`, after a jump back – the first mark of that stretch is a
* jump. `step` is `AUTOMATION_STEP` in ticks.
*/
export function laneMarks(
lane: SequenceLane,
from: number,
loop: LoopRange | null,
start: number,
end: number,
fresh: boolean,
step: number,
): LaneMark[] {
const { points } = lane;
const audio = (value: number) => audioValueOf(lane, value);
const marks: LaneMark[] = [];
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
let elapsed = start;
let head = playheadAt(start, from, loop);
let landed = fresh;
while (elapsed < end) {
const span = Math.min(end - elapsed, stretchEnd - head);
if (!(span > 0)) break;
const to = head + span;
const first = laneValueAt(points, head);
marks.push({
at: elapsed,
value: audio(first),
jump: landed || laneValueBefore(points, head) !== first,
});
for (const tick of readings(lane, head, to, step)) {
const before = laneValueBefore(points, tick);
const value = laneValueAt(points, tick);
const at = elapsed + (tick - head);
marks.push({ at, value: audio(before), jump: false });
if (value !== before) marks.push({ at, value: audio(value), jump: true });
}
marks.push({ at: elapsed + span, value: audio(laneValueBefore(points, to)), jump: false });
elapsed += span;
landed = to >= stretchEnd;
head = landed ? loop!.start : to;
}
return thinned(marks);
}
/** The ticks strictly between `head` and `to` to mark: every point, and readings where the bus cannot ramp. */
function readings(lane: SequenceLane, head: number, to: number, step: number): number[] {
const { points, target } = lane;
const ticks: number[] = [];
points.forEach((a, i) => {
if (a.tick > head && a.tick < to) ticks.push(a.tick);
const b = points[i + 1];
if (b && b.tick > head && a.tick < to && !rampable(target, a, b))
ticks.push(...stepsOf(a.tick, head, Math.min(b.tick, to), step));
});
ticks.sort((x, y) => x - y);
return ticks.filter((tick, i) => i === 0 || tick !== ticks[i - 1]);
}
/** The ticks `step` apart from `origin` on, strictly between `after` and `before`; none without a step. */
function stepsOf(origin: number, after: number, before: number, step: number): number[] {
const ticks: number[] = [];
if (!(step > 0)) return ticks;
for (let k = Math.max(1, Math.floor((after - origin) / step) + 1); origin + k * step < before; k++)
if (origin + k * step > after) ticks.push(origin + k * step);
return ticks;
}
/** Without the marks a ramp would pass anyway: a value held from the mark before on. */
function thinned(marks: readonly LaneMark[]): LaneMark[] {
const kept: LaneMark[] = [];
marks.forEach((mark, i) => {
const last = kept.at(-1);
const next = marks[i + 1];
const held =
last !== undefined &&
next !== undefined &&
!mark.jump &&
last.value === mark.value &&
(next.jump || next.value === mark.value);
if (!held) kept.push(mark);
});
return kept;
}
/**
* A lane as it is heard over the playing time from `start` up to `end`, played from 0 round `loop` –
* a song in an item of a composition (#418) –, as a lane of its own in ticks of that playing time:
* each round with its points, cut at the loop end and jumping back there. A way cut in the middle
* that is no straight line is read every `step` ticks.
*/
export function unrolledLane(
points: readonly SequenceLanePoint[],
loop: LoopRange | null,
start: number,
end: number,
step: number,
): SequenceLanePoint[] {
const out: SequenceLanePoint[] = [];
const stretchEnd = loop !== null && loop.end > 0 ? loop.end : Infinity;
let elapsed = start;
let head = playheadAt(start, 0, loop);
while (elapsed < end) {
const span = Math.min(end - elapsed, stretchEnd - head);
if (!(span > 0)) break;
const shift = elapsed - head;
for (const point of stretchOf(points, head, head + span, step))
out.push({ ...point, tick: point.tick + shift });
elapsed += span;
head = head + span >= stretchEnd ? loop!.start : head + span;
}
return out;
}
/** The points of a lane from `head` to `to` as a lane by itself, ending on the value it reaches there. */
function stretchOf(
points: readonly SequenceLanePoint[],
head: number,
to: number,
step: number,
): SequenceLanePoint[] {
const out: SequenceLanePoint[] = [];
const at = lastAt(points, head);
const starts: { tick: number; own: number; whole: boolean }[] = [
{ tick: head, own: Math.max(0, at), whole: at >= 0 && points[at]!.tick === head },
];
points.forEach((point, i) => {
if (point.tick > head && point.tick < to) starts.push({ tick: point.tick, own: i, whole: true });
});
starts.forEach((s, k) => {
const next = starts[k + 1]?.tick ?? to;
const a = points[s.own]!;
const b = points[s.own + 1];
const value = s.whole ? a.value : laneValueAt(points, s.tick);
if (!b || (k === 0 && at < 0)) out.push({ tick: s.tick, value });
else if (s.whole && b.tick <= to) out.push({ ...a, tick: s.tick });
else if (straight(a, b)) out.push({ tick: s.tick, value, ...(a.ease === 'step' && { ease: 'step' }) });
else {
out.push({ tick: s.tick, value });
for (const tick of stepsOf(a.tick, s.tick, next, step))
out.push({ tick, value: laneValueAt(points, tick) });
}
});
out.push({ tick: to, value: laneValueBefore(points, to) });
return out;
}
+2
View File
@@ -233,6 +233,8 @@ export function bakePrint(project: Project, asset: BakedAsset): string {
clips,
buses,
instruments,
// Only where there is any, so a bake made before automation (#337) still holds.
...(sequence.lanes?.length ? [sequence.lanes] : []),
]);
return hashText(text);
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Where a playing of a song or a pattern is heard, and the beats, bars and markers it has passed
* (#338). The audio side keeps each playing's time as an anchor on its clock; a game looks once a
* frame, so whatever it passed since the last look is told late, and too late is not told at all.
*/
import type { SongMarker } from './markers';
import { playheadAt, type LoopRange } from './sequence';
import { type Meter, positionOf, secondsOfTicks, ticksOfSeconds, ticksPerBar, ticksPerBeat } from './time';
/** The time of one playing: how fast, in what meter, from where, round what, to where. */
export interface Timeline {
tempo: number;
meter: Meter;
/** Where it started, in ticks. */
from: number;
/** What it runs round once it gets there; null plays it once to `length`. */
loop: LoopRange | null;
/** Where it ends when it does not loop, in ticks. */
length: number;
}
/** The most a spot passed is told late, and less where half a beat is shorter. */
export const LATE_MAX = 0.15;
/**
* How late a spot may be told: half a beat, at most `LATE_MAX`. A frame that slips loses no beat;
* after a long stall the old ones are gone, and no burst of them follows.
*/
export function lateLimit(timeline: Pick<Timeline, 'tempo' | 'meter'>): number {
return Math.min(LATE_MAX, secondsOfTicks(ticksPerBeat(timeline.meter), timeline.tempo) / 2);
}
/** What `Song position` hands on. */
export interface HeardPosition {
bar: number;
beat: number;
/** How far into the beat, 0 to below 1. */
fraction: number;
/** Seconds from the start of the song to where it stands; back to the loop's start with it. */
seconds: number;
tick: number;
/** Heard now: started, and not played to its end. */
playing: boolean;
}
/**
* Where a playing stands at `heard` seconds of its playing time, as heard: before it starts (null, or
* less than 0) where it starts, after its end at the end.
*/
export function heardPosition(timeline: Timeline, heard: number | null): HeardPosition {
const { tempo, meter, from, loop, length } = timeline;
let tick = from;
let playing = false;
if (heard !== null && heard >= 0) {
tick = playheadAt(ticksOfSeconds(heard, tempo), from, loop);
playing = !ends(timeline) || tick < length;
if (!playing) tick = length;
}
const beat = ticksPerBeat(meter);
const at = positionOf(tick, meter);
const inBeat = tick - Math.floor(tick / beat) * beat;
return {
bar: at.bar,
beat: at.beat,
fraction: Math.min(Math.max(inBeat / beat, 0), 1 - Number.EPSILON),
seconds: secondsOfTicks(tick, tempo),
tick,
playing,
};
}
/** A spot the playhead passed: the tick it is at, and how many seconds ago it was heard. */
export interface Passed {
tick: number;
late: number;
}
/**
* The ticks among the playhead's run from `low` to `high` that are spots of a kind. `lowOpen` leaves
* `low` itself out (it was told before), `highOpen` leaves `high` out (the loop's end is its start).
*/
export type SpotsIn = (low: number, high: number, lowOpen: boolean, highOpen: boolean) => number[];
/** Every multiple of `step` in the run: the beats or the bars. */
export function gridSpots(step: number): SpotsIn {
return (low, high, lowOpen, highOpen) => {
const spots: number[] = [];
let n = Math.ceil(low / step);
if (lowOpen && n * step <= low) n++;
for (; n * step < high || (!highOpen && n * step === high); n++) spots.push(n * step);
return spots;
};
}
/** Those of `ticks` in the run: the markers. */
export function tickSpots(ticks: readonly number[]): SpotsIn {
return (low, high, lowOpen, highOpen) =>
[...new Set(ticks)].filter((t) => (lowOpen ? t > low : t >= low) && (highOpen ? t < high : t <= high));
}
/** Whether it ends rather than going round. */
function ends(timeline: Timeline): boolean {
return timeline.loop === null;
}
/** One stretch of playing time the playhead runs straight through: from `start` on, at `head`, to `end`. */
interface Stretch {
start: number;
head: number;
end: number;
}
/** The stretch playing time `elapsed` (in ticks) lies in. */
function stretchAt({ from, loop }: Timeline, elapsed: number): Stretch {
if (!loop || from >= loop.end) return { start: 0, head: from, end: Infinity };
const toEnd = loop.end - from;
if (elapsed < toEnd) return { start: 0, head: from, end: toEnd };
const round = loop.end - loop.start;
const start = toEnd + Math.floor((elapsed - toEnd) / round) * round;
return { start, head: loop.start, end: start + round };
}
/** The stretch that runs up to where `stretch` begins; null for the first. */
function stretchBefore({ from, loop }: Timeline, stretch: Stretch): Stretch | null {
if (!loop || stretch.start <= 0) return null;
const toEnd = loop.end - from;
const round = loop.end - loop.start;
const start = stretch.start - round;
return start >= toEnd
? { start, head: loop.start, end: stretch.start }
: { start: 0, head: from, end: toEnd };
}
/** No more stretches than this are looked back through: a loop shorter than a frame is no tune. */
const MOST_STRETCHES = 16;
/**
* The spots the playhead passed after `before` up to `now` (seconds of playing time heard, `before`
* null where nothing was heard of it yet), newest first – only those no more than `lateLimit` ago.
* A loop's start is passed each time it comes round; nothing at or after the end of one that ends.
*/
export function passed(timeline: Timeline, before: number | null, now: number, spots: SpotsIn): Passed[] {
const low = Math.max(before ?? -Infinity, now - lateLimit(timeline));
if (!(now >= 0) || !(now > low)) return [];
const { tempo, length } = timeline;
const elapsedLow = ticksOfSeconds(low, tempo);
const high = ticksOfSeconds(now, tempo);
const found: Passed[] = [];
let stretch: Stretch | null = stretchAt(timeline, high);
let highOpen = false;
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
// What was heard at `low` was told before; where the run reaches back past a stretch's start,
// that start is among it – the very start of the playing too, before which nothing was heard.
const lowInside = elapsedLow >= stretch.start;
const headLow = stretch.head + ((lowInside ? elapsedLow : stretch.start) - stretch.start);
let headHigh = stretch.head + ((highOpen ? stretch.end : high) - stretch.start);
let open = highOpen;
if (ends(timeline) && headHigh >= length) {
headHigh = length;
open = true;
}
const ticks = spots(headLow, headHigh, lowInside, open).sort((a, b) => b - a);
for (const tick of ticks) {
const at = stretch.start + (tick - stretch.head);
found.push({ tick, late: Math.max(0, now - secondsOfTicks(at, tempo)) });
}
if (lowInside) break;
stretch = stretchBefore(timeline, stretch);
highOpen = true;
}
return found;
}
/** A beat passed, as `On beat` tells it. */
export interface PassedBeat extends Passed {
bar: number;
beat: number;
}
/** The newest beat passed, if one is still to be told. */
export function passedBeat(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBeat(timeline.meter)));
if (!newest) return null;
const at = positionOf(newest.tick, timeline.meter);
return { ...newest, bar: at.bar, beat: at.beat };
}
/** A marker passed, as `On song marker` tells it. */
export interface PassedMarker extends Passed {
id: string;
name: string;
}
/** Every marker on a tick passed, newest first; markers sharing a tick in their own order. */
export function passedMarkers(
timeline: Timeline,
before: number | null,
now: number,
markers: readonly SongMarker[],
): PassedMarker[] {
if (markers.length === 0) return [];
return passed(timeline, before, now, tickSpots(markers.map((m) => m.tick))).flatMap((spot) =>
markers.filter((m) => m.tick === spot.tick).map((m) => ({ ...spot, id: m.id, name: m.name })),
);
}
/** Of what passed, those passed last: one spot, but every marker on it. */
export function newestPassed<T extends Passed>(list: readonly T[]): T[] {
const [first] = list;
return first ? list.filter((p) => p.late === first.late) : [];
}
/** Where a change a game asks of its music lands (#338): at once, or on the next of these. */
export type Quantize = 'now' | 'beat' | 'bar' | 'marker';
export const QUANTIZE_OPTIONS: readonly { value: Quantize; label: string }[] = [
{ value: 'now', label: 'Now' },
{ value: 'beat', label: 'Next beat' },
{ value: 'bar', label: 'Next bar' },
{ value: 'marker', label: 'Next marker' },
];
export function heldQuantize(value: unknown): Quantize {
return QUANTIZE_OPTIONS.some((option) => option.value === value) ? (value as Quantize) : 'bar';
}
/** The spots a change waits for; a song without markers, or a pattern, waits for the next bar. */
export function quantizeSpots(
quantize: Exclude<Quantize, 'now'>,
meter: Meter,
markers: readonly SongMarker[],
): SpotsIn {
if (quantize === 'beat') return gridSpots(ticksPerBeat(meter));
if (quantize === 'marker' && markers.length > 0) return tickSpots(markers.map((m) => m.tick));
return gridSpots(ticksPerBar(meter));
}
/** The furthest ahead a spot is looked for on a stretch without end. */
const LOOK_AHEAD = 600;
/** The stretch that follows `stretch`; null after the last one of a playing that ends. */
function stretchAfter(timeline: Timeline, stretch: Stretch): Stretch | null {
return stretch.end === Infinity ? null : stretchAt(timeline, stretch.end);
}
/**
* The first spot at or after `elapsed` seconds of playing time, in seconds of playing time; null where
* none comes before a playing that ends does – a change then lands at once.
*/
export function nextSpot(timeline: Timeline, elapsed: number, spots: SpotsIn): number | null {
const { tempo, length } = timeline;
const low = ticksOfSeconds(Math.max(0, elapsed), tempo);
let stretch: Stretch | null = stretchAt(timeline, low);
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
const headLow = stretch.head + (Math.max(low, stretch.start) - stretch.start);
// One that runs on without end is looked through for `LOOK_AHEAD` seconds, not for ever.
let headHigh =
stretch.head + (Math.min(stretch.end, low + ticksOfSeconds(LOOK_AHEAD, tempo)) - stretch.start);
if (ends(timeline)) headHigh = Math.min(headHigh, length);
if (headHigh > headLow) {
const [first] = spots(headLow, headHigh, false, true).sort((a, b) => a - b);
if (first !== undefined) return secondsOfTicks(stretch.start + (first - stretch.head), tempo);
}
if (ends(timeline) && headHigh >= length) return null;
stretch = stretchAfter(timeline, stretch);
}
return null;
}
/** The newest bar line passed, if one is still to be told. */
export function passedBar(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBar(timeline.meter)));
if (!newest) return null;
return { ...newest, bar: positionOf(newest.tick, timeline.meter).bar, beat: 1 };
}
+16 -2
View File
@@ -13,6 +13,7 @@ import { compositionFps, compositionLength, compositionLoops, itemFades } from '
import { findAssetOf, type Project } from '../model/Project';
import { findSound } from '../model/sound';
import { pickedFx } from '../properties/assetFx';
import { AUTOMATION_STEP, type SequenceLane, unrolledLane } from './automation';
import { itemRoute, MASTER_MIX, type MixRoute } from './mixer';
import { fxVoiceOf } from './needs';
import { patternInstrument, patternLength, patternSequence, patternTempo } from './pattern';
@@ -75,6 +76,7 @@ export function compositionPlayings(
(playing.sequence.notes as SequenceNote[]).push(...laid.notes);
(playing.sequence.clips as SequenceClip[]).push(...laid.clips);
(playing.sequence.levels as SequenceLevel[]).push(...laid.levels);
if (laid.lanes?.length) playing.sequence.lanes = [...(playing.sequence.lanes ?? []), ...laid.lanes];
playing.buses.push(...laid.buses);
}
const playings = [...byRoute.values()];
@@ -100,6 +102,8 @@ interface Laid {
clips: SequenceClip[];
/** Its fades, on its bus. */
levels: SequenceLevel[];
/** A song's automation (#337), round its loop as the song plays inside the item. */
lanes?: SequenceLane[];
buses: BusLevel[];
/** The asset its routing is read from: the sound of a clip, else what it plays. */
routedAs: string;
@@ -168,6 +172,11 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n
return {
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, ...(n.bus && { bus: renamed(n.bus) }) })),
clips: heard.clips.map((c) => ({ ...c, tick: begin + c.tick, bus: renamed(c.bus) })),
lanes: heard.lanes.map((lane) => ({
...lane,
bus: renamed(lane.bus),
points: lane.points.map((p) => ({ ...p, tick: begin + p.tick })),
})),
levels,
buses: [withFx(pickedFx('', song.props), tempo), ...tracks],
routedAs: song.id,
@@ -207,7 +216,7 @@ function played(
tempo: number,
into: number,
seconds: number,
): { notes: SequenceNote[]; clips: SequenceClip[] } {
): { notes: SequenceNote[]; clips: SequenceClip[]; lanes: SequenceLane[] } {
const from = Math.round(ticksOfSeconds(into, tempo));
const to = from + Math.ceil(ticksOfSeconds(seconds, tempo));
const span = compositionTicks(seconds);
@@ -226,5 +235,10 @@ function played(
const { at: _, skip, ...clip } = c;
return [{ ...clip, tick, length, offset: c.offset + secondsOfTicks(skip, tempo), tempo }];
});
return { notes, clips };
const step = ticksOfSeconds(AUTOMATION_STEP, tempo);
const lanes = (sequence.lanes ?? []).map((lane) => ({
...lane,
points: unrolledLane(lane.points, loop, from, to, step).map((p) => ({ ...p, tick: at(p.tick) })),
}));
return { notes, clips, lanes };
}
+165
View File
@@ -0,0 +1,165 @@
import type { FxParams } from '../soundfx/nodes';
import {
type AutomationTarget,
type LaneMark,
laneMarks,
laneRange,
laneValueAt,
paramOf,
paramsAt,
reaches,
type SequenceLane,
type SequenceLanePoint,
} from './automation';
import { type LoopRange, playheadAt } from './sequence';
/**
* A lane a game holds on a value of its own (#338): from `at` it goes from `start`, where the lane
* is then, to `value` over `fade` and stays there, its own points no longer heard – until `release`
* takes it back over its own `fade` to where its curve is by then, or a later hold lands. Seconds of
* the audio clock, values in the units of the lane.
*/
export interface LaneHold {
at: number;
fade: number;
start: number;
value: number;
release: { at: number; fade: number } | null;
}
/** How a sequence is played: from where, round what, and its playing time in ticks at a second of the audio clock. */
export interface LanePlaying {
from: number;
loop: LoopRange | null;
elapsed(at: number): number;
}
/** What the holds of a lane are kept by: the bus of the lane and its target. */
export function holdKey(bus: string, target: AutomationTarget): string {
return `${bus}\n${target}`;
}
/**
* The playing time each of the holds of a lane rules, in ticks, in the order they land: from where
* it lands to where its release is through or the next one lands; open while it holds.
*/
export function holdSpans(
holds: readonly LaneHold[],
playing: LanePlaying,
): { hold: LaneHold; start: number; end: number }[] {
return holds
.map((hold, i) => {
const { release } = hold;
const next = holds[i + 1];
const own = release ? playing.elapsed(release.at + release.fade) : Infinity;
return {
hold,
start: playing.elapsed(hold.at),
end: next ? Math.min(own, playing.elapsed(next.at)) : own,
};
})
.filter((span) => span.end > span.start);
}
/** The holds of a lane still to be planned past `until`, in ticks of the playing time. */
export function holdsAfter(holds: readonly LaneHold[], playing: LanePlaying, until: number): LaneHold[] {
const going = new Set(
holdSpans(holds, playing)
.filter((span) => span.end > until)
.map((s) => s.hold),
);
return holds.filter((hold) => going.has(hold));
}
/**
* The way of a hold as the points of a lane in ticks of the playing time: from where the lane was to
* the value, and from wherever that got to by the release back to the curve where the release ends.
*/
export function holdPoints(hold: LaneHold, lane: SequenceLane, playing: LanePlaying): SequenceLanePoint[] {
const held: SequenceLanePoint[] = [
{ tick: playing.elapsed(hold.at), value: hold.start },
{ tick: playing.elapsed(hold.at + hold.fade), value: hold.value },
];
const { release } = hold;
if (!release) return held;
const from = playing.elapsed(release.at);
const to = playing.elapsed(release.at + release.fade);
const back = laneValueAt(lane.points, playheadAt(Math.max(0, to), playing.from, playing.loop));
return [
...held.filter((point) => point.tick <= from),
{ tick: from, value: laneValueAt(held, from) },
{ tick: to, value: back },
];
}
/** Where a lane is at the playing time `elapsed`, in its units: along the hold that rules then, if one does. */
export function heldValueAt(
lane: SequenceLane,
holds: readonly LaneHold[],
playing: LanePlaying,
elapsed: number,
): number {
const span = holdSpans(holds, playing).find((s) => elapsed >= s.start && elapsed < s.end);
if (span) return laneValueAt(holdPoints(span.hold, lane, playing), elapsed);
return laneValueAt(lane.points, playheadAt(Math.max(0, elapsed), playing.from, playing.loop));
}
/**
* The marks of a lane over the playing time from `start` up to `end`, as `laneMarks` gives them, but
* along its holds where they rule: its own points between them, the way of each hold while it rules.
* Where a hold or the way back to the lane begins is a jump, so a bus is set there and does not ramp
* from whatever was planned for it last.
*/
export function heldMarks(
lane: SequenceLane,
holds: readonly LaneHold[],
playing: LanePlaying,
start: number,
end: number,
fresh: boolean,
step: number,
): LaneMark[] {
const { from, loop } = playing;
const marks: LaneMark[] = [];
let cursor = start;
let landed = fresh;
for (const span of holdSpans(holds, playing)) {
if (span.end <= cursor) continue;
if (span.start >= end) break;
if (cursor < span.start) marks.push(...laneMarks(lane, from, loop, cursor, span.start, landed, step));
const to = Math.min(end, span.end);
const way: SequenceLane = { ...lane, points: holdPoints(span.hold, lane, playing) };
marks.push(
...laneMarks(way, 0, null, Math.max(cursor, span.start), to, fresh || span.start >= start, step),
);
cursor = to;
landed = true;
}
if (cursor < end) marks.push(...laneMarks(lane, from, loop, cursor, end, landed, step));
return marks;
}
/**
* The values the parameter lanes have for what starts on `bus` at `tick` of the sequence, `elapsed`
* of the playing time, as `paramsAt` gives them: a held one along its hold.
*/
export function heldParamsAt(
lanes: readonly SequenceLane[],
holds: ReadonlyMap<string, readonly LaneHold[]>,
playing: LanePlaying,
bus: string | undefined,
tick: number,
elapsed: number,
): FxParams | undefined {
const params = paramsAt(lanes, bus, tick);
if (!params || bus === undefined || holds.size === 0) return params;
const held: Record<string, number> = { ...params };
for (const lane of lanes) {
const name = paramOf(lane.target);
const laneHolds = holds.get(holdKey(lane.bus, lane.target));
if (name === null || !laneHolds || !reaches(lane.bus, bus)) continue;
const { min, max } = laneRange(lane);
held[name] = Math.max(min, Math.min(max, heldValueAt(lane, laneHolds, playing, elapsed)));
}
return held;
}
+7
View File
@@ -0,0 +1,7 @@
/** The volume of a track of a song (#334), in decibels; at the lowest it is silent. */
export const TRACK_MIN_DB = -60;
export const TRACK_MAX_DB = 12;
export function dbToGain(db: number): number {
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Markers of a song (#338): named spots on its time, where an event fires in the game and where a
* switch to the song can start. A marker sits on a whole tick; two may share one, and so may their
* names – a node names a marker by its id.
*/
import { newId } from '../ids';
export interface SongMarker {
id: string;
name: string;
tick: number;
}
const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
/** Held to what a marker may be: a whole tick from 0, a name, an id no other has; in the order of their ticks. */
export function heldSongMarkers(raw: unknown): SongMarker[] {
const ids = new Set<string>();
const markers: SongMarker[] = [];
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
if (typeof entry !== 'object' || entry === null) continue;
const m = entry as Record<string, unknown>;
const tick = Math.round(finite(m.tick));
if (!(tick >= 0)) continue;
let id = typeof m.id === 'string' && m.id ? m.id : newId();
while (ids.has(id)) id = newId();
ids.add(id);
const name = typeof m.name === 'string' ? m.name.trim() : '';
markers.push({ id, name: name || `Marker ${markers.length + 1}`, tick });
}
return markers.sort((a, b) => a.tick - b.tick);
}
/** "Marker 3": the first number no marker is called by yet. */
export function nextMarkerName(markers: readonly SongMarker[]): string {
const taken = new Set(markers.map((m) => m.name));
let n = 1;
while (taken.has(`Marker ${n}`)) n++;
return `Marker ${n}`;
}
export function newMarker(markers: readonly SongMarker[], tick: number, name?: string): SongMarker {
return { id: newId(), name: name?.trim() || nextMarkerName(markers), tick: Math.max(0, Math.round(tick)) };
}
/** The markers with one of them changed; the others as they are. */
export function changedMarker(
markers: readonly SongMarker[],
id: string,
change: Partial<Pick<SongMarker, 'name' | 'tick'>>,
): SongMarker[] {
return markers.map((m) => (m.id === id ? { ...m, ...change } : m));
}
export function withoutMarker(markers: readonly SongMarker[], id: string): SongMarker[] {
return markers.filter((m) => m.id !== id);
}
+3 -3
View File
@@ -22,9 +22,9 @@ export function fxVoiceOf(
return plan ? { plan, params, tempo } : null;
}
/** The sound FX a clip goes through. */
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
return fxVoiceOf(project, fxId, tempo) ?? DRY_VOICE;
/** The sound FX a clip goes through, with the values its parameters start at. */
export function clipFx(project: Project, fxId: string, tempo: number, params: FxParams = {}): FxVoice {
return fxVoiceOf(project, fxId, tempo, params) ?? DRY_VOICE;
}
/** Every layer and FX of every note and every file and FX of every clip a sequence plays. */
+32 -1
View File
@@ -8,6 +8,7 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
import type { PatternAsset } from '../model/Asset';
import { type PropertyBag, textOf } from '../model/Properties';
import { heldLoop, type LoopRange, type Sequence } from './sequence';
import { type Swing, swungNote } from './swing';
import { DEFAULT_SIGN, heldTempo, type Meter, signMeter, ticksPerBar } from './time';
export interface PatternNote {
@@ -26,6 +27,8 @@ export const PATTERN_BARS_KEY = 'bars';
export const PATTERN_GRID_KEY = 'grid';
export const PATTERN_SIGN_KEY = 'sign';
export const PATTERN_TEMPO_KEY = 'tempo';
export const PATTERN_SWING_KEY = 'swing';
export const PATTERN_SWING_GRID_KEY = 'swingGrid';
export const DEFAULT_BARS = 4;
export const MAX_BARS = 256;
@@ -49,6 +52,10 @@ const GRIDS: readonly (readonly [string, number])[] = [
export const GRID_OPTIONS = GRIDS.map(([value]) => ({ value, label: value }));
/** What swing (#372) pairs up: eighths or sixteenths. */
export const SWING_GRID_OPTIONS = ['1/8', '1/16'].map((value) => ({ value, label: value }));
export const DEFAULT_SWING_GRID = '1/8';
export function gridTicks(grid: string): number {
return (GRIDS.find(([value]) => value === grid) ?? GRIDS[4]!)[1];
}
@@ -84,6 +91,29 @@ export function patternInstrument(props: PropertyBag): string {
return textOf(props[PATTERN_INSTRUMENT_KEY] ?? '');
}
/** The swing it is played with (#372); null without, so a pattern with none plays as it always did. */
export function patternSwing(props: PropertyBag): Swing | null {
const percent = props[PATTERN_SWING_KEY];
const amount = typeof percent === 'number' && Number.isFinite(percent) ? Math.min(1, percent / 100) : 0;
if (!(amount > 0)) return null;
const grid = textOf(props[PATTERN_SWING_GRID_KEY] ?? DEFAULT_SWING_GRID) === '1/16' ? '1/16' : '1/8';
return { amount, pair: 2 * gridTicks(grid), bar: ticksPerBar(patternMeter(props)) };
}
/**
* A note of the pattern as the scheduler plays it: with swing heard `late` ticks after its place and
* held as long as its swung end says; without, as it is.
*/
export function playedNote(
tick: number,
length: number,
swing: Swing | null,
): { length: number; late?: number } {
if (!swing) return { length };
const swung = swungNote(tick, length, swing);
return swung.late > 0 ? swung : { length: swung.length };
}
/** Where the pattern ends, in ticks: a note starting there or later is kept, but not played. */
export function patternLength(asset: PatternAsset): number {
return patternBars(asset.props) * ticksPerBar(patternMeter(asset.props));
@@ -102,12 +132,13 @@ export function patternSequence(asset: PatternAsset): Sequence {
const instrument = patternInstrument(asset.props);
if (!instrument) return { notes: [] };
const length = patternLength(asset);
const swing = patternSwing(asset.props);
return {
notes: asset.notes
.filter((n) => n.tick < length)
.map((n) => ({
tick: n.tick,
length: Math.min(n.length, length - n.tick),
...playedNote(n.tick, Math.min(n.length, length - n.tick), swing),
note: n.note,
velocity: n.velocity,
instrument,
+283 -22
View File
@@ -1,9 +1,11 @@
import type { SendTap } from '../graph/Graph';
import type { FxPlan, FxVoice } from '../soundfx/nodes';
import type { AutomationTarget } from './automation';
import type { BakePlayback } from './bake';
import type { MixSend } from './mixer';
import {
LEAD,
type ScheduledAutomation,
type ScheduledClip,
type ScheduledNote,
type ScheduledRamp,
@@ -30,6 +32,11 @@ export interface BusLevel {
sends?: readonly BusSend[];
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
fx?: FxVoice;
/**
* What its automation sets while it plays (#337): `gain`, `pan` and a parameter of `fx` are then
* only where it starts.
*/
automated?: readonly AutomationTarget[];
}
/** One send of a track (#350): into another track's bus, or the output of the song where `to` is left out. */
@@ -64,6 +71,8 @@ export interface SequencePlay {
strikeClip?(clip: ScheduledClip): number;
/** Ramps the level of one of its buses (#418). */
ramp?(ramp: ScheduledRamp): void;
/** Takes one of its buses along its automation (#337). */
automate?(automation: ScheduledAutomation): void;
/** Its bake (#340), played in place of its notes and clips where the host can. */
bake?: BakePlay;
/**
@@ -71,6 +80,11 @@ export interface SequencePlay {
* waits for its files, it starts as much further in, to keep to the clock it was asked for by.
*/
from?: number;
/**
* The second of the audio clock it is heard from (#338), where that is still to come once its
* files are there: a stinger in time with another playing (`Playbacks.landOn`).
*/
at?: number;
}
/** A bake as it is played: its file, the level it is heard at, and which stretches of it. */
@@ -87,27 +101,64 @@ export interface Runner {
readonly startedAt: number | null;
/** Played to its end, and nothing of it is heard any more. */
readonly done: boolean;
start(from?: number): void;
/** Plays from `from` ticks, heard from `at` on the audio clock where that is still to come (#338). */
start(from?: number, at?: number): void;
stop(letRing?: boolean): void;
}
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead' | 'ramp'> {
/** What a change a game asks of a playing goes by (#338): its time, where it started, the clock now. */
export interface PlayingClock {
time: PlayingTime;
/** The second of the audio clock its playing time 0 is heard at; null before it starts. */
startedAt: number | null;
now: number;
/** The soonest second of the audio clock the change can be heard from. */
soonest: number;
}
/** The second of the audio clock a change to a playing lands at, from its clock (#338). */
export type LandingAt = (clock: PlayingClock) => number;
/** Seconds a hard cut between two playings takes, so it does not click. */
export const CUT = 0.005;
/**
* The audio side of the playings. Each is reached here by its `key`, the number its buses go by: the
* same as its `id` but where it took over another one's (#338).
*/
export interface PlaybackHost extends Omit<
SchedulerHost,
'play' | 'playClip' | 'ahead' | 'ramp' | 'automate'
> {
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
prepare(needs: SequenceNeeds, id: number): Promise<void>;
prepare(needs: SequenceNeeds, key: number): Promise<void>;
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
ahead?(id: number): number;
ahead?(key: number): number;
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
level(id: number, gain: number, at: number, seconds: number): void;
level(key: number, gain: number, at: number, seconds: number): void;
/** Calls `then` once the audio clock reaches `at`. */
after(at: number, then: () => void): void;
/** A playing is gone: stopped, or played to its end. */
closed?(id: number): void;
closed?(key: number): void;
/**
* Nothing of the playings numbered `id` is left (#373): played to their end, or `stopped`. `name`
* is that of the one heard last, a Switch song's in place of the one it took over from.
*/
ended?(id: number, name: string, stopped: boolean): void;
/** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */
playBaked?(id: number, bake: BakePlay, at: number): number;
playBaked?(key: number, bake: BakePlay, at: number): number;
}
export interface Playback {
id: number;
/** What its buses go by; its `id` but where it took over from another playing (#338). */
key: number;
/** The level of the whole of it, as it was asked for. */
volume: number;
/** The second of the audio clock another playing takes over from it at (#338); null while it is the one. */
until: number | null;
/** It takes over from another playing of its number, once it is heard (#338). */
successor: boolean;
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
name: string;
owner: string;
@@ -119,6 +170,28 @@ export interface Playback {
fading: number | null;
/** Played from its bake (#340) rather than its notes. */
baked: boolean;
/** How fast, round what and to where it plays, and from where it started (#338). */
time: PlayingTime;
/** A stop reached it (#373), rather than it playing to its end. */
stopped: boolean;
}
/** The time of a playing (#338): its tempo, where it started, its loop and its length, in ticks. */
export interface PlayingTime {
tempo: number;
from: number;
loop: LoopRange | null;
length: number;
}
/** A playing and how far into its playing time it is heard now, null before it starts (#338). */
export interface HeardTime extends PlayingTime {
id: number;
/** Told apart from the playing of its number it took over from (`Playback.key`). */
key: number;
name: string;
heard: number | null;
baked: boolean;
}
/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */
@@ -139,6 +212,155 @@ export class Playbacks {
/** Starts once its files are there; a stop that came first wins. */
start(id: number, name: string, play: SequencePlay, owner: string): void {
const playback = this.make(id, id, name, play, owner, false);
const asked = this.host.now();
void this.host.prepare(play.needs, id).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
const from = play.from;
playback.time.from =
from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo));
playback.runner.start(playback.time.from, play.at);
// From the first note heard, which is later where the buses are (#346).
const at = playback.runner.startedAt ?? this.host.now() + LEAD;
this.host.level(id, playback.volume, at, Math.max(0, play.fadeIn ?? 0));
});
}
/**
* Plays `play` in place of the playing numbered `id` (#338), on its buses `key`, from `play.from`
* ticks: once its files are there, it is heard from the second `at` names for the playing it takes
* over from, which falls silent there – over `fade` seconds as this one rises over them, else in a
* cut. It keeps the number, so whatever knows the playing goes on reaching it. Asked again before
* that, the one still waiting is dropped and the playing heard now is the one to go. Where nothing
* of that number plays any more, it starts as soon as it can.
*/
switchTo(
id: number,
key: number,
name: string,
play: SequencePlay,
owner: string,
at: LandingAt,
fade: number,
): void {
const now = this.host.now();
const waiting = this.list.filter((p) => p.id === id && this.waits(p, now));
this.close(waiting, false);
for (const p of this.list)
if (p.id === id && p.until !== null && p.until > now) {
p.until = null;
p.fading = null;
this.host.level(p.key, p.volume, now, 0);
}
const playback = this.make(id, key, name, play, owner, true);
// What a game holds of the lanes stays held: a lane of the same song is there again.
const heard = this.list.find((p) => p.id === id && p !== playback && p.until === null);
if (heard?.runner instanceof Scheduler && playback.runner instanceof Scheduler)
playback.runner.keepHolds(heard.runner);
void this.host.prepare(play.needs, key).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
const from = this.heardOne(id, playback);
const now = this.host.now();
const ahead = this.host.ahead?.(key) ?? 0;
const soonest = now + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
const clock = from && { time: from.time, startedAt: from.runner.startedAt, now, soonest };
playback.time.from = Math.max(0, Math.round(play.from ?? 0));
playback.runner.start(playback.time.from, clock ? at(clock) : undefined);
const start = playback.runner.startedAt ?? soonest;
const over = fade > 0 ? fade : 0;
if (from) this.handOver(from, start, Math.max(over, CUT));
this.host.level(key, playback.volume, start, over);
});
}
/** The playing of that number heard now, but `besides`: the one a switch takes over from. */
private heardOne(id: number, besides: Playback): Playback | null {
return this.current().find((p) => p.id === id && p !== besides && p.until === null) ?? null;
}
/** Falls silent from `at` over `fade` seconds and goes then: the one taken over from (#338). */
private handOver(playback: Playback, at: number, fade: number): void {
const end = at + fade;
playback.until = at;
playback.fading = end;
this.host.level(playback.key, 0, at, fade);
this.host.after(end, () => {
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
});
}
/** The clock of the playing of that number heard now (#338), and whether it is a bake; null where none is. */
clockOf(id: number): (PlayingClock & { baked: boolean }) | null {
const playing = this.current().find((p) => p.id === id && p.until === null && !this.waits(p));
if (!playing) return null;
const now = this.host.now();
const { time, baked } = playing;
return { time, startedAt: playing.runner.startedAt, now, soonest: now + CUT, baked };
}
/**
* The second of the audio clock `at` names on the clock of the playing of that number heard now
* (#338), no sooner than something started now can be heard: where a stinger in time with it
* starts. Null where none is heard.
*/
landOn(id: number, at: LandingAt): number | null {
const clock = this.clockOf(id);
if (!clock) return null;
const soonest = clock.now + LEAD;
return Math.max(soonest, at({ ...clock, soonest }));
}
/**
* Holds a lane of the playing of that number on `value` from the second `at` (#338), as
* `Scheduler.holdLane` does: the one heard now and one waiting to take over from it alike. Whether
* any of them has that lane; a bake has none.
*/
holdLane(
id: number,
bus: string,
target: AutomationTarget,
value: number,
at: number,
fade: number,
): boolean {
let found = false;
for (const p of this.named(id))
if (p.runner instanceof Scheduler && p.runner.holdLane(bus, target, value, at, fade)) found = true;
return found;
}
/** Lets a held lane of the playing of that number go from the second `at` (#338), see `Scheduler.releaseLane`. */
releaseLane(id: number, bus: string, target: AutomationTarget, at: number, fade: number): boolean {
let found = false;
for (const p of this.named(id))
if (p.runner instanceof Scheduler && p.runner.releaseLane(bus, target, at, fade)) found = true;
return found;
}
/** The playings of that number, the one heard now and one waiting to take over from it (#338). */
named(id: number): readonly Readonly<Playback>[] {
return this.current().filter((p) => p.id === id);
}
/** Whether the buses `key` belong to a playing still to be heard. */
holds(key: number): boolean {
return this.current().some((playback) => playback.key === key);
}
/** A successor not heard yet: waiting on its files, or on the second it takes over at. */
private waits(playback: Playback, now = this.host.now()): boolean {
const at = playback.runner.startedAt;
return playback.successor && (at === null || at > now);
}
private make(
id: number,
key: number,
name: string,
play: SequencePlay,
owner: string,
successor: boolean,
): Playback {
const planned: ScheduledNote[] = [];
const clips: ScheduledClip[] = [];
const keep = <T>(list: T[], item: T) => {
@@ -146,12 +368,12 @@ export class Playbacks {
if (list.length > KEPT) list.shift();
};
const strikeClip = play.strikeClip;
const ahead = () => this.host.ahead?.(id) ?? 0;
const ahead = () => this.host.ahead?.(key) ?? 0;
const { bake } = play;
const playBaked = this.host.playBaked?.bind(this.host);
const runner: Runner =
bake && playBaked
? new BakedRun(this.host, ahead, (at) => playBaked(id, bake, at), bake.playback.end !== null)
? new BakedRun(this.host, ahead, (at) => playBaked(key, bake, at), bake.playback.end !== null)
: new Scheduler(
{
...this.host,
@@ -159,12 +381,14 @@ export class Playbacks {
play: (note) => (keep(planned, note), play.strike(note)),
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
...(play.ramp && { ramp: play.ramp }),
...(play.automate && { automate: play.automate }),
},
() => play.sequence,
{ tempo: play.tempo, loop: play.loop, end: play.length },
);
const playback: Playback = {
id,
key,
name,
owner,
runner,
@@ -172,17 +396,14 @@ export class Playbacks {
clips,
fading: null,
baked: runner instanceof BakedRun,
volume: play.volume ?? 1,
until: null,
successor,
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
stopped: false,
};
this.list.push(playback);
const asked = this.host.now();
void this.host.prepare(play.needs, id).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
const from = play.from;
runner.start(from === undefined ? 0 : from + ticksOfSeconds(this.host.now() - asked, play.tempo));
// From the first note heard, which is later where the buses are (#346).
const at = runner.startedAt ?? this.host.now() + LEAD;
this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0));
});
return playback;
}
/** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */
@@ -190,6 +411,27 @@ export class Playbacks {
return this.current().some((playback) => playback.id === id);
}
/**
* Every playing with how far into its playing time it is heard now: the audio clock less the
* output latency, from the moment its first note is heard (#338). With none playing the clock is
* not asked: asking for it makes the audio context, which a page may make only after a gesture.
*/
times(): HeardTime[] {
const list = this.current();
if (list.length === 0) return [];
const heard = this.host.now() - this.host.latency();
// Of a switch (#338) one is heard at a time: the old one up to where the new one is.
const told = list.filter(
({ until, successor, runner }) =>
(until === null || heard < until) &&
(!successor || (runner.startedAt !== null && heard >= runner.startedAt)),
);
return told.map(({ id, key, name, runner, time, baked }) => {
const at = runner.startedAt;
return { id, key, name, ...time, heard: at === null ? null : heard - at, baked };
});
}
/** Those that go by `name` (see `goesBy`); with `fade` they fall silent over that many seconds first. */
stopNamed(name: string, letRing = false, fade = 0): void {
this.stopWhere((playback) => goesBy(playback.name, name), letRing, fade);
@@ -213,13 +455,14 @@ export class Playbacks {
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void {
const stopping = this.list.filter(going);
for (const playback of stopping) playback.stopped = true;
if (!(fade > 0)) return this.close(stopping, letRing);
const now = this.host.now();
const end = now + fade;
for (const playback of stopping) {
if (playback.fading !== null && playback.fading <= end) continue;
playback.fading = end;
this.host.level(playback.id, 0, now, fade);
this.host.level(playback.key, 0, now, fade);
this.host.after(end, () => {
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
});
@@ -230,10 +473,27 @@ export class Playbacks {
private close(going: readonly Playback[], letRing: boolean): void {
if (going.length === 0) return;
this.list = this.list.filter((playback) => !going.includes(playback));
const ended = this.endedOf(going);
for (const playback of going) {
playback.runner.stop(letRing);
this.host.closed?.(playback.id);
this.host.closed?.(playback.key);
}
for (const { id, name, stopped } of ended) this.host.ended?.(id, name, stopped);
}
/**
* The numbers nothing is left of once `going` goes (#373). Of a switch (#338) the one taken over
* from goes first and the number lives on in the other; where both go, it is named by the newer
* one if that had started.
*/
private endedOf(going: readonly Playback[]): { id: number; name: string; stopped: boolean }[] {
return [...new Set(going.map((playback) => playback.id))].flatMap((id) => {
if (this.list.some((playback) => playback.id === id)) return [];
const ones = going.filter((playback) => playback.id === id);
const started = ones.filter((playback) => !playback.successor || playback.runner.startedAt !== null);
const last = started[started.length - 1] ?? ones[0]!;
return [{ id, name: last.name, stopped: ones.some((playback) => playback.stopped) }];
});
}
}
@@ -264,10 +524,11 @@ class BakedRun implements Runner {
return this.anchor !== null && this.ends && !this.host.playing(this.voice);
}
start(): void {
/** From its start: a bake plays from nowhere else. Heard from `at` where that is still to come. */
start(_from?: number, at = -Infinity): void {
this.stop();
const ahead = this.ahead();
this.anchor = this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
this.anchor = Math.max(this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0), at);
this.voice = this.play(this.anchor);
}
+18 -7
View File
@@ -5,6 +5,7 @@
* where the playhead stands, which jumps back at the end of a loop.
*/
import type { FadeCurve } from '../model/sound';
import type { SequenceLane } from './automation';
export interface SequenceNote {
/** Where it starts in the sequence, and how long it is held, in ticks. */
@@ -22,6 +23,11 @@ export interface SequenceNote {
bus?: string;
/** What a Delay synced to beats counts by, where it is not the sequence's: a pattern in a composition (#418). */
tempo?: number;
/**
* How many ticks after `tick` it is heard: the swing of its pattern (#372). Where it lies in the
* sequence and its loop is still `tick`.
*/
late?: number;
}
/**
@@ -59,6 +65,8 @@ export interface Sequence {
notes: readonly SequenceNote[];
clips?: readonly SequenceClip[];
levels?: readonly SequenceLevel[];
/** The automation of a song's tracks (#337), each lane on the bus it acts on. */
lanes?: readonly SequenceLane[];
}
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
@@ -85,8 +93,8 @@ export function playheadAt(elapsed: number, from: number, loop: LoopRange | null
}
/** A note of the sequence where it falls in playing time, held no longer than the loop lets it. */
export interface DueNote extends SequenceNote {
/** Ticks of playing time from the start. */
export interface DueNote extends Omit<SequenceNote, 'late'> {
/** Ticks of playing time from the start, with its swing. */
at: number;
}
@@ -94,7 +102,8 @@ export interface DueNote extends SequenceNote {
* The notes starting in the playing time from `start` up to `end`, both whole ticks. Across the end
* of a loop the window goes on at its start, so the next round is planned ahead like any other
* stretch and the seam has no gap. A note reaching past the end of a loop ends there: the playhead
* has jumped back.
* has jumped back. One its swing would push past that end – a loop cut in the middle of a pair –
* starts a tick before it instead.
*/
export function notesIn(
sequence: Sequence,
@@ -110,13 +119,15 @@ export function notesIn(
while (elapsed < end) {
const span = Math.min(end - elapsed, stretchEnd - head);
if (!(span > 0)) break;
for (const note of sequence.notes)
if (note.tick >= head && note.tick < head + span)
for (const { late: swing = 0, ...note } of sequence.notes)
if (note.tick >= head && note.tick < head + span) {
const late = Math.min(swing, Math.max(0, stretchEnd - note.tick - 1));
due.push({
...note,
at: elapsed + (note.tick - head),
length: Math.min(note.length, stretchEnd - note.tick),
at: elapsed + (note.tick - head) + late,
length: Math.min(note.length, stretchEnd - note.tick - late),
});
}
elapsed += span;
head = head + span >= stretchEnd ? loop!.start : head + span;
}
+32 -13
View File
@@ -8,11 +8,21 @@
import { newId } from '../ids';
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
import { curveLookup } from '../model/curve';
import { type PropertyBag, textOf } from '../model/Properties';
import { findAssetOf, type Project } from '../model/Project';
import { findSound } from '../model/sound';
import { pickedFx } from '../properties/assetFx';
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
import { type AutomationLane, heldLanes, sequenceLaneOf } from './automation';
import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level';
import {
gridTicks,
GRID_OPTIONS,
patternInstrument,
patternLength,
patternSwing,
playedNote,
} from './pattern';
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time';
@@ -27,6 +37,8 @@ export interface SongTrack {
solo: boolean;
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
fx: string;
/** Its volume, pan and parameters over the song (#337); left out without a lane. */
automation?: AutomationLane[];
}
export type SongItemKind = 'pattern' | 'sound';
@@ -66,8 +78,7 @@ const DEFAULT_TRACKS = 1;
/** A song is never shorter than this many bars, on the ruler or when it plays. */
const MIN_BARS = 1;
export const TRACK_MIN_DB = -60;
export const TRACK_MAX_DB = 12;
export { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB };
export const ITEM_MIN_DB = -60;
/** A sound item can be turned up; a pattern item's notes are held to unity, as a note always is. */
export const SOUND_ITEM_MAX_DB = 12;
@@ -138,10 +149,6 @@ export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): s
return `${trackBus(songId, item.track)}:${item.id}`;
}
export function dbToGain(db: number): number {
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
}
/** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */
export function itemFx(project: Project, item: SongItem): string {
return pickedFx(item.fx, itemAsset(project, item)?.props ?? {});
@@ -179,22 +186,26 @@ export function firstRound(item: SongItem, span: number): number {
/**
* The notes a pattern item plays, in song ticks: its pattern over and over for as long as the item
* is, moved by its transpose, cut at the item's ends and at the end of each round.
* is, moved by its transpose, cut at the item's ends and at the end of each round. `swung` gives
* them the pattern's swing (#372) as they are heard; where they are drawn they go without.
*/
export function itemNotes(
pattern: PatternAsset,
item: SongItem,
): { tick: number; length: number; note: number; velocity: number }[] {
swung = false,
): { tick: number; length: number; late?: number; note: number; velocity: number }[] {
const span = patternLength(pattern);
const end = itemEnd(item);
const out: { tick: number; length: number; note: number; velocity: number }[] = [];
const swing = swung ? patternSwing(pattern.props) : null;
const out: { tick: number; length: number; late?: number; note: number; velocity: number }[] = [];
for (let round = firstRound(item, span); round < end; round += span)
for (const n of pattern.notes) {
const tick = round + n.tick;
const note = n.note + item.transpose;
if (n.tick >= span || tick < item.tick || tick >= end || note < LOWEST_NOTE || note > HIGHEST_NOTE)
continue;
out.push({ tick, length: Math.min(n.length, span - n.tick, end - tick), note, velocity: n.velocity });
const length = Math.min(n.length, span - n.tick, end - tick);
out.push({ tick, ...playedNote(n.tick, length, swing), note, velocity: n.velocity });
}
return out;
}
@@ -224,11 +235,17 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
const pattern = findAssetOf(project, item.asset, 'pattern');
const instrument = pattern && (item.instrument || patternInstrument(pattern.props));
if (!pattern || !instrument) continue;
for (const n of itemNotes(pattern, item)) notes.push({ ...n, instrument, volume, bus });
for (const n of itemNotes(pattern, item, true)) notes.push({ ...n, instrument, volume, bus });
}
notes.sort((a, b) => a.tick - b.tick || a.note - b.note);
clips.sort((a, b) => a.tick - b.tick);
return { notes, clips };
const curves = curveLookup(project);
const lanes = song.tracks.flatMap((track) =>
(track.automation ?? [])
.filter((lane) => lane.points.length > 0)
.map((lane) => sequenceLaneOf(lane, trackBus(song.id, track.id), curves)),
);
return { notes, clips, ...(lanes.length > 0 && { lanes }) };
}
const finite = (value: unknown, fallback: number) =>
@@ -255,6 +272,8 @@ export function heldSongTracks(raw: unknown): SongTrack[] {
solo: t.solo === true,
fx: text(t.fx),
});
const automation = heldLanes(t.automation);
if (automation.length > 0) tracks.at(-1)!.automation = automation;
}
return tracks.length > 0 ? tracks : [newTrack(1)];
}
+38 -8
View File
@@ -1,6 +1,7 @@
import type { Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import type { Graph } from '../graph/Graph';
import { audioValueOf, laneOf, laneValueAt, paramOf, type TrackTarget } from './automation';
import { fxVoiceOf } from './needs';
import type { BusLevel } from './playbacks';
import { songRoutes, trackGates } from './routing';
@@ -18,8 +19,10 @@ import {
/**
* The buses of a song: each track's at its volume and pan, through its sound FX (#346), on to where
* its routing sends it (#350); a bus for its own items into it; and one for each pattern item with a
* sound FX into that, at unity – its volume is its notes'. Mute and solo close sends and items
* its routing sends it (#350), a level, pan or parameter its lane rules (#337) from where the lane
* starts – a parameter on the sound FX of its items too; a bus
* for its own items into it; and one for each pattern item with a sound FX into that, at unity – its
* volume is its notes'. Mute and solo close sends and items
* (`trackGates`). A sound FX that does not compile is none, and the bus then only passes it on.
*/
export function songBuses(
@@ -33,18 +36,36 @@ export function songBuses(
},
): BusLevel[] {
const tempo = songTempo(song.props);
const fxOf = (id: string) => {
const fx = fxVoiceOf(project, id, tempo);
const byId = new Map(song.tracks.map((track) => [track.id, track]));
// A parameter a lane rules starts where the lane does (#337).
const fxOf = (id: string, track: SongTrack | undefined) => {
const lanes = (track?.automation ?? []).filter((lane) => paramOf(lane.target) !== null);
const params = Object.fromEntries(
lanes.flatMap((lane) =>
lane.points.length > 0
? [[paramOf(lane.target)!, audioValueOf(lane, laneValueAt(lane.points, 0))]]
: [],
),
);
const fx = fxVoiceOf(project, id, tempo, params);
return fx ? { fx } : {};
};
const routes = songRoutes(song);
const gates = trackGates(song.tracks, routes);
const tracks = song.tracks.map((track) => {
const gate = gates.get(track.id);
const automated = (track.automation ?? []).flatMap((lane) =>
lane.points.length > 0 ? [lane.target] : [],
);
const atStart = (target: TrackTarget, fader: number) => {
const lane = laneOf(track, target);
return lane ? audioValueOf(lane, laneValueAt(lane.points, 0)) : fader;
};
return {
name: trackBus(song.id, track.id),
gain: dbToGain(track.volume),
pan: track.pan,
gain: atStart('volume', dbToGain(track.volume)),
pan: atStart('pan', track.pan),
...(automated.length > 0 && { automated }),
sends: routes
.filter((route) => route.from === track.id)
.map((route) => ({
@@ -52,7 +73,7 @@ export function songBuses(
gain: gate?.passes(route.to) ? dbToGain(route.volume) : 0,
tap: route.tap,
})),
...fxOf(track.fx),
...fxOf(track.fx, track),
};
});
const own = song.tracks.map((track) => ({
@@ -69,7 +90,16 @@ export function songBuses(
gain: 1,
pan: 0,
into: trackItemsBus(song.id, item.track),
...fxOf(itemFx(project, item)),
...fxOf(itemFx(project, item), byId.get(item.track)),
...automatedParams(byId.get(item.track)),
}));
return [...tracks, ...own, ...items];
}
/** The parameters a track's lanes rule, for the bus of one of its items: its sound FX leaves them alone. */
function automatedParams(track: SongTrack | undefined): Pick<BusLevel, 'automated'> {
const automated = (track?.automation ?? []).flatMap((lane) =>
paramOf(lane.target) !== null && lane.points.length > 0 ? [lane.target] : [],
);
return automated.length > 0 ? { automated } : {};
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Swing (#372): every second eighth or sixteenth a little later, as it is heard and not as it is
* written. The notes stay on the grid in the model and the piano roll; only the scheduler plays them
* late. The time of each pair is bent, not just the note on its second half: the first half is
* stretched up to where the second now starts and the second squeezed into what is left, so a note
* set off the grid and the end of every note go along with it.
*/
export interface Swing {
/** 0 to 1: at 1 the second half of a pair starts on the triplet, at 2/3 of it instead of 1/2. */
amount: number;
/** How long a pair is, in ticks: two eighths or two sixteenths. */
pair: number;
/** How long a bar is, in ticks: pairs are counted from the start of each. */
bar: number;
}
/**
* Where a tick of the pattern is heard with swing. A pair cut short by the end of a bar – the last
* eighth of a 7/8 bar – is played straight, as there is no second half to push.
*/
export function swungTick(tick: number, swing: Swing): number {
const { amount, pair, bar } = swing;
if (!(amount > 0) || !(pair > 0) || !(bar > 0)) return tick;
const barStart = Math.floor(tick / bar) * bar;
const pairStart = barStart + Math.floor((tick - barStart) / pair) * pair;
if (pairStart + pair > barStart + bar) return tick;
const half = pair / 2;
const middle = half + (amount * pair) / 6;
const into = tick - pairStart;
return into < half
? pairStart + (into * middle) / half
: pairStart + middle + ((into - half) * (pair - middle)) / half;
}
/**
* A note of the pattern with swing: how many ticks after its place it is heard (`late`), and how
* long it is held then, which is where its end is heard less where its start is.
*/
export function swungNote(tick: number, length: number, swing: Swing): { late: number; length: number } {
const start = swungTick(tick, swing);
return { late: start - tick, length: swungTick(tick + length, swing) - start };
}
+63
View File
@@ -0,0 +1,63 @@
import { VELOCITY_PARAM } from '../instrument/nodes';
import { instrumentParams } from '../instrument/compile';
import { findAssetOf, type Project } from '../model/Project';
import { assetFxOf } from '../properties/assetFx';
import { soundFxParams } from '../soundfx/compile';
import { patternInstrument } from './pattern';
import { itemFx, type SongItem, type SongTrack } from './song';
/** A parameter a lane of a track can move, with the default it has where it is first declared. */
export interface TrackParam {
name: string;
default: number;
}
/**
* The parameters a lane of a track can move (#337): those of its sound FX, of the sound FX of its
* items, of the instruments its pattern items play and of their own sound FX, each name once, in
* that order. A name several of them have moves all of them.
*/
export function trackParams(
project: Project,
song: { items: readonly SongItem[] },
track: SongTrack,
): TrackParam[] {
const found = new Map<string, number>();
const add = (ports: readonly { key: string; default?: number }[]) => {
for (const port of ports)
if (!found.has(port.key) && port.key !== VELOCITY_PARAM) found.set(port.key, port.default ?? 0);
};
const addFx = (fxId: string) => {
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
if (fx) add(soundFxParams(fx.graph));
};
addFx(track.fx);
for (const item of song.items) {
if (item.track !== track.id) continue;
addFx(itemFx(project, item));
if (item.kind !== 'pattern') continue;
const pattern = findAssetOf(project, item.asset, 'pattern');
const id = item.instrument || (pattern ? patternInstrument(pattern.props) : '');
const instrument = id ? findAssetOf(project, id, 'instrument') : undefined;
if (!instrument) continue;
add(instrumentParams(instrument.graph));
addFx(assetFxOf(instrument.props));
}
return [...found].map(([name, value]) => ({ name, default: value }));
}
/**
* The range a new lane of a parameter gets: 0 to 1 for a default in there, else from 0 – or as far
* below – to twice the default, on a round number.
*/
export function defaultParamRange(value: number): { min: number; max: number } {
if (!Number.isFinite(value) || (value >= 0 && value <= 1)) return { min: 0, max: 1 };
const top = roundUp(Math.abs(value) * 2);
return value > 0 ? { min: 0, max: top } : { min: -top, max: top };
}
/** The next of 1, 2 and 5 times a power of ten from `value` up. */
function roundUp(value: number): number {
const power = 10 ** Math.floor(Math.log10(value));
return ([1, 2, 5, 10].find((step) => step * power >= value) ?? 10) * power;
}
+26
View File
@@ -112,13 +112,17 @@ import {
import {
DEFAULT_BARS,
DEFAULT_GRID,
DEFAULT_SWING_GRID,
GRID_OPTIONS,
MAX_BARS,
PATTERN_BARS_KEY,
PATTERN_GRID_KEY,
PATTERN_INSTRUMENT_KEY,
PATTERN_SIGN_KEY,
PATTERN_SWING_GRID_KEY,
PATTERN_SWING_KEY,
PATTERN_TEMPO_KEY,
SWING_GRID_OPTIONS,
} from '../music/pattern';
import {
DEFAULT_SONG_GRID,
@@ -159,6 +163,10 @@ type Extras<K extends FlatKind> = K extends 'number'
/** Offers `TO_COMPOSITION_BUS`, where empty means the routing of the asset. */
toComposition?: boolean;
noFx?: boolean;
/** Offers the markers of the songs, each as "Song / Marker", instead of the songs (#338). */
markers?: boolean;
/** Offers the tracks of the songs, each as "Song / Track", instead of the songs (#338). */
tracks?: boolean;
}
: K extends 'texture'
? { uploadedOnly?: boolean; moving?: boolean }
@@ -766,6 +774,24 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
max: MAX_TEMPO,
step: 1,
},
// Heard, not drawn (#372): the notes stay on the grid.
{
key: PATTERN_SWING_KEY,
label: 'Swing (%)',
kind: 'number',
default: 0,
min: 0,
max: 100,
step: 5,
decimals: 0,
},
{
key: PATTERN_SWING_GRID_KEY,
label: 'Swing grid',
kind: 'enum',
default: DEFAULT_SWING_GRID,
options: SWING_GRID_OPTIONS,
},
FX_ROW,
],
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
+49 -2
View File
@@ -1,7 +1,8 @@
import type { Asset, SoundAsset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
import { clipsOf, findSound, soundRefName } from '../model/sound';
import { clipRefName, clipsOf, findSound, soundRefName } from '../model/sound';
import { heldSongMarkers } from '../music/markers';
import { listedAssets, markedName } from '../music/embedded';
import { NO_FX } from './assetFx';
import {
@@ -29,7 +30,53 @@ export function assetRefLabel(project: Project | null, id: string): string {
if (asset) return markedName(asset);
// A clip of a sound (#357) reads "Sound / Clip", as a frame does.
const clip = project ? findSound(project, id) : null;
return clip ? soundRefName(clip, markedName(clip.asset)) : `${id}?`;
if (clip) return soundRefName(clip, markedName(clip.asset));
// A marker of a song (#338) reads "Song / Marker", as a clip does.
const marker = project ? findSongMarker(project, id) : null;
if (marker) return clipRefName(markedName(marker.song), marker.name);
// So does a track of a song (#338).
const track = project ? findSongTrack(project, id) : null;
return track ? clipRefName(markedName(track.song), track.name) : `${id}?`;
}
/** The first song with a track of that id (#338), with the track's name; null where none has one. */
export function findSongTrack(project: Project, id: string): { song: Asset; name: string } | null {
if (id === '') return null;
for (const song of project.assets) {
if (song.type !== 'song') continue;
const track = song.tracks.find((t) => t.id === id);
if (track) return { song, name: track.name };
}
return null;
}
/** What a track field offers (#338): the tracks of every song, "Theme / Drums", in their order. */
export function trackChoices(project: Project): { id: string; label: string }[] {
return assetRefChoices(project, ['song']).flatMap((song) =>
song.type === 'song'
? song.tracks.map((t) => ({ id: t.id, label: clipRefName(markedName(song), t.name) }))
: [],
);
}
/** The marker of a song an id names (#338), with its song; null where none does. */
export function findSongMarker(project: Project, id: string): { song: Asset; name: string } | null {
if (id === '') return null;
for (const song of project.assets) {
if (song.type !== 'song') continue;
const marker = heldSongMarkers(song.markers).find((m) => m.id === id);
if (marker) return { song, name: marker.name };
}
return null;
}
/** What a marker field offers (#338): the markers of every song, "Theme / Chorus", in the order of their ticks. */
export function markerChoices(project: Project): { id: string; label: string }[] {
return assetRefChoices(project, ['song']).flatMap((song) =>
song.type === 'song'
? heldSongMarkers(song.markers).map((m) => ({ id: m.id, label: clipRefName(markedName(song), m.name) }))
: [],
);
}
/** What a sound field offers (#357): each sound, followed by its clips. */
+12 -1
View File
@@ -95,8 +95,19 @@ export function videoLoopsOf(bag: PropertyBag): boolean {
return bag[VIDEO_LOOP_KEY] === undefined ? true : booleanOf(bag[VIDEO_LOOP_KEY]);
}
/**
* The clock a run gives a free sprite once a behavior plays, pauses or seeks its video (#410): driven
* like `driven`, by the run's own count of its time. No row offers it, and nothing saves it.
*/
export const VIDEO_STEERED = 'steered';
/** Whether something other than the video sets its time: an animation, a composition, a behavior. */
export function videoDrivenOf(bag: PropertyBag): boolean {
return bag[VIDEO_CLOCK_KEY] === 'driven';
return bag[VIDEO_CLOCK_KEY] === 'driven' || bag[VIDEO_CLOCK_KEY] === VIDEO_STEERED;
}
export function videoSteeredOf(bag: PropertyBag): boolean {
return bag[VIDEO_CLOCK_KEY] === VIDEO_STEERED;
}
export function videoTimeOf(bag: PropertyBag): number {
+3
View File
@@ -25,6 +25,7 @@ import { heldBake } from '../music/bake';
import { heldStream } from '../music/streams';
import { heldPatternNotes } from '../music/pattern';
import { isEmbeddable } from '../music/embedded';
import { heldSongMarkers } from '../music/markers';
import { heldSongItems, heldSongTracks } from '../music/song';
import { heldSend, heldSongGraph } from '../music/routing';
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
@@ -348,6 +349,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const loop = readLoop(a.loop);
const bake = heldBake(a.bake);
const tracks = heldSongTracks(a.tracks);
const markers = heldSongMarkers(a.markers);
assets.push({
id,
type,
@@ -356,6 +358,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
tracks,
items: heldSongItems(a.items, tracks),
...(loop && { loop }),
...(markers.length > 0 && { markers }),
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
...(bake && { bake }),
});
+1
View File
@@ -137,6 +137,7 @@ export class EditorRun {
audio: soundLibrary,
saves: saveArea(project.id, 'editor'),
playsCompositions: false,
steersVideos: false,
});
this.world = fresh;
this.hadFocus = this.hooks.focused();
+1 -1
View File
@@ -65,7 +65,7 @@ const keyOf = (args: Args, name: string): AnimationKey => {
};
/** Every easing a key can name, but the curve ones: those take the id of a Curve asset. */
const EASINGS = EASING_SHAPES.flatMap(({ value }) =>
export const EASINGS = EASING_SHAPES.flatMap(({ value }) =>
value === 'linear' || value === 'step'
? [value]
: value === 'curve'
+207 -1
View File
@@ -9,7 +9,7 @@ import { oneStep } from '../../../core/commands/CompoundCommand';
import { ReplaceGraphCommand } from '../../../core/commands/graphCommands';
import { SetLoopCommand, SetPatternNotesCommand } from '../../../core/commands/patternCommands';
import { requireAsset, requireEdge, requirePattern, requireSong } from '../../../core/commands/require';
import { SetSendCommand, SetSongCommand } from '../../../core/commands/songCommands';
import { SetSendCommand, SetSongCommand, SetSongMarkersCommand } from '../../../core/commands/songCommands';
import type { SendTap } from '../../../core/graph/Graph';
import { newId } from '../../../core/ids';
import {
@@ -20,6 +20,7 @@ import {
TONE_PRESETS,
} from '../../../core/instrument/chipPresets';
import { HIGHEST_NOTE, LOWEST_NOTE, noteName } from '../../../core/instrument/notes';
import { easingOf, isKeyInterpolation } from '../../../core/model/animation';
import type { PatternAsset, SongAsset } from '../../../core/model/Asset';
import { audioSeconds } from '../../../core/model/composition';
import type { PropertyBag } from '../../../core/model/Properties';
@@ -31,6 +32,16 @@ import {
ROUTING_ID,
} from '../../../core/model/Project';
import { clipsOf, findSound, hasClips } from '../../../core/model/sound';
import {
type AutomationLane,
type AutomationPoint,
heldRange,
isAutomationTarget,
type LaneKind,
laneRange,
paramOf,
} from '../../../core/music/automation';
import { changedMarker, newMarker, type SongMarker, withoutMarker } from '../../../core/music/markers';
import { DEFAULT_VELOCITY, patternLength, type PatternNote } from '../../../core/music/pattern';
import { SEND_TAPS } from '../../../core/music/routing';
import {
@@ -47,8 +58,11 @@ import {
TRACK_MIN_DB,
} from '../../../core/music/song';
import { TICKS_PER_QUARTER, ticksOfSeconds, ticksPerBar } from '../../../core/music/time';
import { defaultParamRange, trackParams } from '../../../core/music/trackParams';
import { NO_FX } from '../../../core/properties/assetFx';
import { withLanePoints, withoutLane, withParamLane } from '../../song/laneEdit';
import type { ArgSchema } from '../argCheck';
import { EASINGS } from './animation';
import {
type Args,
type CatalogCommand,
@@ -68,6 +82,7 @@ import {
const PATTERN = id('Id of the pattern asset.');
const SONG = id('Id of the song asset.');
const TRACK = id('Id of the track, as get_asset lists it under tracks.');
const MARKER = id('Id of the marker, as get_asset lists it under markers.');
const ITEM = id('Id of the item, as get_asset lists it under items.');
const INDEX = whole('Place in the list of tracks, from 0 (the top).', 0);
const TICK = whole(`Ticks from the start; ${TICKS_PER_QUARTER} to a quarter note.`, 0);
@@ -280,6 +295,81 @@ export const MUSIC_COMMANDS: readonly CatalogCommand[] = [
},
shows: theTab,
},
{
name: 'set_song_lane',
description:
'Sets an automation lane of a song track: its volume (in dB), its pan (-1 left to 1 right) or a parameter ' +
'changing over the song along points; the points given replace those the lane had. From each point the ' +
'value runs to the next along its ease; before the first point it holds the first value, after the last ' +
'the last one; two points on one tick jump there. While the lane of the volume or the pan has a point, it ' +
"rules that value in place of the track's own; with no point that lane goes and the track's value rules " +
'again. A parameter lane moves every parameter of that name on the track: of its sound FX, of the sound FX ' +
'of its items, of the instruments its patterns play and of their sound FX (get_asset lists them under ' +
"params of the track). It keeps a range its points lie in, round the parameter's default where none is " +
'given, and stays without a point until remove. A note or clip starts at the value there, so what a ' +
'voice reads only when it starts changes from the next one.',
args: object(
{
asset: SONG,
track: TRACK,
target: id(
'"volume", "pan", or "param:<name>" for a parameter, as get_asset lists lanes under automation.',
),
points: {
type: 'array',
items: object(
{
tick: TICK,
value: number(
'In the units of the lane: dB, -1 to 1 for the pan, within the range of a parameter.',
),
ease: id(
`How it runs to the next point: ${EASINGS.join(', ')}, or "curve:<id of a Curve asset>"; ` +
'linear where left out.',
),
},
['tick', 'value'],
),
description: 'All points of the lane, in any order; those on one tick in the order given.',
},
min: number('A parameter lane: the lowest value it holds.'),
max: number('A parameter lane: the highest value it holds.'),
remove: flag("Take the lane away, a parameter's range with it."),
},
['asset', 'track', 'target'],
),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const track = requireTrack(song, text(args, 'track'));
const target = args.target;
if (!isAutomationTarget(target))
throw new CommandError(
`${JSON.stringify(target)} is no lane – give "volume", "pan" or "param:<name>"`,
);
const had = track.automation?.find((lane) => lane.target === target);
const given = [args.points, args.min, args.max].some((value) => value !== undefined);
let changed: SongTrack;
let label: string;
if (args.remove === true) {
if (given) throw new CommandError('remove takes no points and no range');
if (!had) throw new CommandError(`${track.name} has no ${target} lane`);
changed = withoutLane(track, target);
label = 'Remove automation lane';
} else {
const name = paramOf(target);
const range = name === null ? noRange(args) : paramRange(project, song, track, name, had, args);
const kind: LaneKind = { target, ...range };
const points = args.points === undefined ? null : lanePointsOf(project, kind, args.points as Args[]);
if (name === null && !points) throw new CommandError('Give the points, [] to take the lane away');
changed = range ? withParamLane(track, name!, range) : track;
if (points) changed = withLanePoints(changed, target, points);
label = had ? 'Set automation lane' : 'Add automation lane';
}
const tracks = song.tracks.map((t) => (t.id === track.id ? changed : t));
return { command: new SetSongCommand(song.id, { tracks }, label) };
},
shows: theTab,
},
{
name: 'move_song_track',
description: 'Moves a track of a song to another place in its list.',
@@ -309,6 +399,57 @@ export const MUSIC_COMMANDS: readonly CatalogCommand[] = [
},
shows: theTab,
},
{
name: 'add_song_marker',
description:
'Adds a marker to a song: a named spot on its time, drawn on the strip under the ruler of the song tab. ' +
'Markers may share a tick and a name; a marker is known by its id.',
args: object({ asset: SONG, tick: TICK, name: id('Its name; "Marker <n>" where left out.') }, [
'asset',
'tick',
]),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const markers = song.markers ?? [];
const name = typeof args.name === 'string' ? args.name : undefined;
const marker = newMarker(markers, numberOf(args, 'tick'), name);
return {
command: new SetSongMarkersCommand(song.id, [...markers, marker], 'Add marker'),
made: { id: marker.id },
};
},
shows: theTab,
},
{
name: 'change_song_marker',
description: 'Renames a marker of a song or moves it to another tick. Give only what changes.',
args: object({ asset: SONG, marker: MARKER, name: id('Its name.'), tick: TICK }, ['asset', 'marker']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const marker = requireMarker(song, text(args, 'marker'));
const change: { name?: string; tick?: number } = {};
if (typeof args.name === 'string') {
if (!args.name.trim()) throw new CommandError('A marker needs a name');
change.name = args.name.trim();
}
if (typeof args.tick === 'number') change.tick = numberOf(args, 'tick');
const markers = changedMarker(song.markers ?? [], marker.id, change);
return { command: new SetSongMarkersCommand(song.id, markers, 'Change marker') };
},
shows: theTab,
},
{
name: 'remove_song_marker',
description: 'Removes a marker of a song.',
args: object({ asset: SONG, marker: MARKER }, ['asset', 'marker']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const marker = requireMarker(song, text(args, 'marker'));
const markers = withoutMarker(song.markers ?? [], marker.id);
return { command: new SetSongMarkersCommand(song.id, markers, 'Delete marker') };
},
shows: theTab,
},
{
name: 'add_song_item',
description:
@@ -537,12 +678,77 @@ function itemLength(project: Project, song: SongAsset, kind: SongItem['kind'], p
return Math.max(1, Math.round(ticksOfSeconds(seconds, songTempo(song.props))));
}
/** Refuses a range given to the lane of the volume or the pan: theirs is fixed. */
function noRange(args: Args): null {
if (args.min !== undefined || args.max !== undefined)
throw new CommandError('Only a parameter lane has a range – the volume and the pan have theirs');
return null;
}
/**
* The range of a parameter's lane: the one given, else the one it has, else one round the default of
* the parameter. A new lane is refused for a parameter nothing on the track has.
*/
function paramRange(
project: Project,
song: SongAsset,
track: SongTrack,
name: string,
had: AutomationLane | undefined,
args: Args,
): { min: number; max: number } {
const offered = trackParams(project, song, track);
const param = offered.find((p) => p.name === name);
if (!had && !param)
throw new CommandError(
offered.length === 0
? `Nothing on ${track.name} has a parameter to move`
: `Nothing on ${track.name} has a parameter ${name} – it has ${offered.map((p) => p.name).join(', ')}`,
);
const given = [args.min, args.max].filter((value) => value !== undefined).length;
if (given === 1) throw new CommandError('Give min and max, or neither');
if (given === 2) {
const range = heldRange(args.min, args.max);
if (!range) throw new CommandError('max has to be above min');
return range;
}
return had ? laneRange(had) : defaultParamRange(param!.default);
}
/**
* The points the agent wrote, refused where a value or an easing is wrong. The song puts them in the
* order of their ticks, those on one tick as they came.
*/
function lanePointsOf(project: Project, kind: LaneKind, raw: readonly Args[]): AutomationPoint[] {
const { min, max } = laneRange(kind);
return raw.map((point): AutomationPoint => {
const tick = point.tick as number;
const value = within(point.value as number, min, max, `value at tick ${tick}`);
const ease = point.ease;
if (ease === undefined || ease === 'linear') return { tick, value };
if (!isKeyInterpolation(ease))
throw new CommandError(
`${JSON.stringify(ease)} is no easing – give ${EASINGS.join(', ')} or "curve:<id>"`,
);
const { curve } = easingOf(ease);
if (curve !== undefined && !findAssetOf(project, curve, 'curve'))
throw new CommandError(`${curve} is no Curve asset`);
return { tick, value, ease };
});
}
function requireTrack(song: SongAsset, trackId: string): SongTrack {
const track = song.tracks.find((t) => t.id === trackId);
if (!track) throw new CommandError(`${song.name} has no track ${trackId}`);
return track;
}
function requireMarker(song: SongAsset, markerId: string): SongMarker {
const marker = song.markers?.find((m) => m.id === markerId);
if (!marker) throw new CommandError(`${song.name} has no marker ${markerId}`);
return marker;
}
/** A pitch the agent wrote as a MIDI number or a name like "C#4". */
export function pitchOf(value: unknown): number {
const number =
+16 -3
View File
@@ -17,6 +17,7 @@ import { instrumentSourcesOf, soundingNotes } from '../../core/instrument/compil
import { noteName } from '../../core/instrument/notes';
import { patternInstrument, patternLength, patternSequence } from '../../core/music/pattern';
import { itemNotes, songLength } from '../../core/music/song';
import { trackParams } from '../../core/music/trackParams';
import { TICKS_PER_QUARTER } from '../../core/music/time';
import { CLIP_ROWS } from '../../core/properties/sounds';
import { VIDEO_CLIP_ROWS } from '../../core/properties/videos';
@@ -100,7 +101,15 @@ export function getAsset(project: Project, assetId: string, readCel?: CelLookup)
plain.notes = asset.notes.map((note) => ({ ...note, name: noteName(note.note) }));
plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: patternLength(asset) };
}
if (asset.type === 'song') plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: songLength(asset) };
if (asset.type === 'song') {
plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: songLength(asset) };
// What a lane of each track can move (#337): set_song_lane takes them as "param:<name>".
plain.tracks = asset.tracks.map((track, i) => {
const params = trackParams(project, asset, track);
const stored = (plain.tracks as object[])[i];
return params.length > 0 ? { ...stored, params } : stored;
});
}
const silent = silentParts(project, asset);
if (silent.length > 0) plain.silent = silent;
if (asset.type === 'sound' || asset.type === 'video') {
@@ -415,11 +424,15 @@ function nodeEntry({ id, def, params }: PaletteEntry): object {
};
}
/** A property's row without what only the inspector's drawing needs. */
/**
* A property's row without what only the inspector's drawing needs. `markers` says the field takes
* the id of a marker of a song (#338), as `get_asset` lists them, not the song's; `tracks` the id of
* a track of one.
*/
function propertyRow(def: PropertyDef): object {
const { key, label, kind } = def;
const row: Record<string, unknown> = { key, label, kind, default: def.default };
for (const extra of ['options', 'min', 'max', 'assetTypes'] as const) {
for (const extra of ['options', 'min', 'max', 'assetTypes', 'markers', 'tracks'] as const) {
if (extra in def) row[extra] = (def as Record<string, unknown>)[extra];
}
return row;
+1 -1
View File
@@ -59,7 +59,7 @@
{
"name": "get_asset",
"title": "Read an asset",
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"), the notes of a pattern with their names and how many ticks it is long, the rows a clip of a sound or video has (clipProperties), how many seconds a loaded sound lasts, and under silent what of a pattern, song or composition is not heard: all of it without an instrument, or the notes its instrument does not sound. Under usedBy: every asset naming it or one of its frames or clips, with the objects, nodes, items or keys in there that do (none for a property of the asset itself), and the project's start scene, master FX or Master routing; empty when nothing uses it. A graph is only counted here; read it with get_graph.",
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"), the notes of a pattern with their names and how many ticks it is long, on each track of a song its automation lanes and the parameters a lane can move (params), the markers of a song with their ids, names and ticks, the rows a clip of a sound or video has (clipProperties), how many seconds a loaded sound lasts, and under silent what of a pattern, song or composition is not heard: all of it without an instrument, or the notes its instrument does not sound. Under usedBy: every asset naming it or one of its frames or clips, with the objects, nodes, items or keys in there that do (none for a property of the asset itself), and the project's start scene, master FX or Master routing; empty when nothing uses it. A graph is only counted here; read it with get_graph.",
"inputSchema": {
"type": "object",
"properties": { "asset": { "type": "string", "description": "Id of the asset." } },
+17 -2
View File
@@ -1,3 +1,4 @@
import type { AutomationTarget } from '../../core/music/automation';
import { findAssetOf } from '../../core/model/Project';
import type { HeadPart } from '../song/songView';
import { soundLibrary } from '../../viewport/SoundLibrary';
@@ -12,12 +13,17 @@ export function songHooks({ state, editor }: HookContext) {
current: () => songEditor.currentAssetId,
info: () => songEditor.shownInfo,
rect: () => songEditor.canvasRect(),
/** The tracks, items and loop as the model holds them. */
/** The tracks, items, loop and markers as the model holds them. */
model: (assetId: string) => {
const asset = findAssetOf(state.project, assetId, 'song');
return asset
? (JSON.parse(
JSON.stringify({ tracks: asset.tracks, items: asset.items, loop: asset.loop ?? null }),
JSON.stringify({
tracks: asset.tracks,
items: asset.items,
loop: asset.loop ?? null,
markers: asset.markers ?? [],
}),
) as unknown)
: null;
},
@@ -29,6 +35,15 @@ export function songHooks({ state, editor }: HookContext) {
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
rulerAt: (tick: number) => timeline.rulerPoint(tick),
loopAt: (tick: number) => timeline.loopPoint(tick),
/** The markers (#338): where a tick of their strip is on screen, and each as drawn. */
markerAt: (tick: number) => timeline.markerPoint(tick),
markers: () => timeline.markersShown(),
/** The automation lanes (#337): where a value of one is drawn, its head, and the rows as shown. */
automationAt: (track: number, target: AutomationTarget, tick: number, value: number) =>
timeline.automationPoint(track, target, tick, value),
automationHeadAt: (track: number, target: AutomationTarget) =>
timeline.automationHeadPoint(track, target),
automation: () => timeline.automationShown(),
view: () => timeline.viewInfo(),
items: () => timeline.itemsDrawn(),
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
+4
View File
@@ -15,9 +15,11 @@ import {
assetRefChoices,
assetRefLabel,
MASTER_LABEL,
markerChoices,
NO_ASSET_LABEL,
soundChoices,
soundEntries,
trackChoices,
} from '../../core/properties/assetRef';
import { NO_FX } from '../../core/properties/assetFx';
import {
@@ -415,6 +417,8 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
label: assetRefLabel,
choices: (project, def) => {
const extras = def.kind === 'asset' ? def : undefined;
if (extras?.markers) return markerChoices(project);
if (extras?.tracks) return trackChoices(project);
return [
...(extras?.fromAsset ? [{ id: FROM_ASSET_BUS, label: FROM_ASSET_LABEL }] : []),
...(extras?.toComposition ? [{ id: TO_COMPOSITION_BUS, label: TO_COMPOSITION_LABEL }] : []),
+23 -6
View File
@@ -16,6 +16,7 @@ import {
TRACK_MIN_DB,
} from '../../core/music/song';
import type { MenuEntry } from '../../ui';
import { laneLabel } from '../song/laneView';
import { ownFxEntries } from '../song/ownFx';
import type { EditorState } from '../state';
import { createFieldRow } from './fieldFactory';
@@ -33,6 +34,9 @@ const FX_ROW = {
/** What an item's pick says while it is empty (#352): what its asset names. */
const FROM_ASSET = 'From the asset';
/** What a row of a track sets: one of its own values, never its lanes (#337). */
type TrackField = Exclude<keyof SongTrack, 'automation'>;
const TRACK_ROWS: readonly PropertyDef[] = [
{
key: 'volume',
@@ -104,7 +108,13 @@ export function songSignature(
const asset = findAssetOf(project, assetId, 'song');
const item = itemOf(asset, itemIds);
if (item) return `item:${item.id}:${item.kind}`;
return asset?.tracks.some((t) => t.id === trackId) ? `track:${trackId}` : 'missing';
const track = asset?.tracks.find((t) => t.id === trackId);
return track ? `track:${trackId}:${ruledLanes(track).join(',')}` : 'missing';
}
/** The lanes with a point on a track (#337): what its fields no longer set while it plays. */
function ruledLanes(track: SongTrack): string[] {
return (track.automation ?? []).flatMap((lane) => (lane.points.length > 0 ? [laneLabel(lane.target)] : []));
}
/** The item picked last (#334), else the track: its rows. */
@@ -137,7 +147,7 @@ export function refreshSongSection(
const track = asset?.tracks.find((t) => t.id === trackId);
if (!track) return false;
host.nameField?.setValue(track.name);
for (const def of TRACK_ROWS) host.fields.get(def.key)?.setValue(track[def.key as keyof SongTrack]);
for (const def of TRACK_ROWS) host.fields.get(def.key)?.setValue(track[def.key as TrackField]);
return true;
}
@@ -160,22 +170,29 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
const count = asset.items.filter((i) => i.track === track.id).length;
addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`);
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
const ruled = ruledLanes(track);
if (ruled.length > 0)
addNote(
host,
`Automation lanes rule ${ruled.join(', ')} while it plays; a field here is only heard once its lane is cleared.`,
'small',
);
for (const def of TRACK_ROWS) {
const commit = (value: SongTrack[keyof SongTrack]) =>
const commit = (value: SongTrack[TrackField]) =>
new SetSongCommand(
asset.id,
{ tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) },
`Set track ${def.key}`,
);
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
const handlers = previewingHandlers<SongTrack, SongTrack[TrackField]>(host, {
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
read: (target) => target[def.key as keyof SongTrack],
read: (target) => target[def.key as TrackField],
write: (target, value) => Object.assign(target, { [def.key]: value }),
commit,
});
if (def.key === FX_ROW.key)
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
const row = createFieldRow(host, def, track[def.key as keyof SongTrack], handlers);
const row = createFieldRow(host, def, track[def.key as TrackField], handlers);
host.fields.set(def.key, row);
host.body.add(row.row);
}
+120
View File
@@ -0,0 +1,120 @@
import { heldRange } from '../../core/music/automation';
import { defaultParamRange, type TrackParam } from '../../core/music/trackParams';
import { type Button, Dropdown, Label, Menu, NumberField } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
const PICKER_WIDTH = 200;
const FIELD_WIDTH = 90;
const slot = dialogSlot<ParamLaneDialog>();
/** The open dialog, for the check scripts. */
export const currentParamLaneDialog = slot.current;
/** Opens the dialog centred, closing whichever one was open before it. */
export const openParamLaneDialog = slot.open;
/**
* A lane for a parameter of a track (#337): which parameter, and the range its lane spans – a
* parameter says nothing of its own. A new one starts at a range round its default; `editing` sets
* the range of a lane there is, its parameter fixed.
*/
export class ParamLaneDialog extends FormDialog {
private param: string;
private min: number;
private max: number;
private readonly picker: Dropdown;
private readonly minField: NumberField;
private readonly maxField: NumberField;
private readonly summary: Label;
/** Public for the check scripts. */
readonly confirm: Button;
constructor(
private readonly offered: readonly TrackParam[],
editing: { name: string; min: number; max: number } | null,
onConfirm: (name: string, range: { min: number; max: number }) => void,
) {
super(340, editing ? `Range of the ${editing.name} lane` : 'Add a parameter lane');
this.param = editing?.name ?? offered[0]?.name ?? '';
this.picker = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openPicker() });
this.picker.setEnabled(!editing && offered.length > 1);
this.content.add(this.formRow(this.fieldLabel('Parameter'), this.picker));
({ min: this.min, max: this.max } = editing ?? this.rangeOf(this.param));
const field = (value: number, set: (value: number) => void) => {
const made = new NumberField(value, {
onInput: (now) => (set(now), this.refresh()),
onChange: (now) => (set(now), this.refresh()),
});
made.minWidth = FIELD_WIDTH;
return made;
};
this.minField = field(this.min, (value) => (this.min = value));
this.maxField = field(this.max, (value) => (this.max = value));
this.content.add(this.formRow(this.fieldLabel('From'), this.minField));
this.content.add(this.formRow(this.fieldLabel('To'), this.maxField));
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
this.confirm = this.addButtons(editing ? 'Set range' : 'Add lane', 'primary', () => {
const range = this.range();
if (range && this.param) onConfirm(this.param, range);
});
this.refresh();
}
/** What the dialog holds, for the check scripts. */
debugInfo(): { name: string; offered: string[]; min: number; max: number; ok: boolean } {
return {
name: this.param,
offered: this.offered.map((p) => p.name),
min: this.min,
max: this.max,
ok: this.confirm.enabled,
};
}
/** Picks a parameter and a range by value rather than by hand, for the check scripts. */
pick(name: string, range?: { min: number; max: number }): void {
this.param = name;
({ min: this.min, max: this.max } = range ?? this.rangeOf(name));
this.minField.setNumber(this.min);
this.maxField.setNumber(this.max);
this.refresh();
}
private rangeOf(name: string): { min: number; max: number } {
return defaultParamRange(this.offered.find((p) => p.name === name)?.default ?? 0);
}
private range(): { min: number; max: number } | null {
return heldRange(this.min, this.max);
}
private openPicker(): void {
new Menu(
this.offered.map((param) => ({
label: param.name,
current: param.name === this.param,
onSelect: () => this.pick(param.name),
})),
{ searchable: this.offered.length > 9 },
).openBelow(this.picker);
}
private refresh(): void {
this.picker.setText(this.param || '—');
const range = this.range();
const param = this.offered.find((p) => p.name === this.param);
this.summary.setText(
!this.param
? 'Nothing on this track has a parameter to move.'
: !range
? '"To" has to be above "From".'
: param
? `Its default is ${param.default}.`
: 'Nothing on the track has this parameter now.',
);
this.confirm.setEnabled(!!this.param && range !== null);
this.refreshLayout();
}
}
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
import { listedAssets } from '../../core/music/embedded';
import type { KeyInterpolation } from '../../core/model/Asset';
import { EASING_DIRECTIONS, EASING_SHAPES, easingOf, easingValue } from '../../core/model/animation';
import type { Project } from '../../core/model/Project';
import type { MenuEntry } from '../../ui';
/**
* The easings as entries of a menu (#337), the same choice the inspector gives an animation key: Linear
* and Step straight away, every curve with its directions in a submenu, and the Curve assets of the
* project. The one in use is marked.
*/
export function easingEntries(
project: Project,
current: KeyInterpolation,
set: (ease: KeyInterpolation) => void,
): MenuEntry[] {
const now = easingOf(current);
const curves = listedAssets(project)
.filter((a) => a.type === 'curve')
.sort((a, b) => a.name.localeCompare(b.name));
return EASING_SHAPES.map(({ value, label }): MenuEntry => {
if (value === 'linear' || value === 'step')
return { label, current: now.shape === value, onSelect: () => set(value) };
if (value === 'curve')
return {
label,
disabled: curves.length === 0,
submenu: curves.map((curve) => ({
label: curve.name,
current: now.shape === 'curve' && now.curve === curve.id,
onSelect: () => set(easingValue('curve', undefined, curve.id)),
})),
};
return {
label,
submenu: EASING_DIRECTIONS.map((direction) => ({
label: direction.label,
current: now.shape === value && now.direction === direction.value,
onSelect: () => set(easingValue(value, direction.value)),
})),
};
});
}
+132
View File
@@ -0,0 +1,132 @@
import {
AUTOMATION_TARGETS,
type AutomationLane,
type AutomationPoint,
type AutomationTarget,
type LaneSpec,
laneRange,
paramOf,
paramTarget,
} from '../../core/music/automation';
import type { SongTrack } from '../../core/music/song';
import type { KeyInterpolation } from '../../core/model/Asset';
/**
* Editing the points of a lane in the Song tab (#337), apart from Pixi so a test can drive it. Points
* stay in the order of their ticks: one that is dragged stops at its neighbours, so two on one tick –
* a jump – keep which comes first.
*/
/** The points of a track's lane for that target; none while it has no lane. */
export function lanePoints(track: SongTrack, target: AutomationTarget): AutomationPoint[] {
return track.automation?.find((lane) => lane.target === target)?.points ?? [];
}
/**
* The track with these points on that lane. With none the lane of the volume or the pan goes; a
* parameter's stays for its range. With the last lane `automation` goes.
*/
export function withLanePoints(
track: SongTrack,
target: AutomationTarget,
points: readonly AutomationPoint[],
): SongTrack {
const old = track.automation?.find((lane) => lane.target === target);
const range = paramOf(target) !== null ? laneRange(old ?? { target }) : null;
const lane: AutomationLane | null =
points.length > 0 || range ? { target, points: [...points], ...range } : null;
return withLane(track, target, lane);
}
/** The track with a lane for that parameter in this range, its points held to it; a new one is empty. */
export function withParamLane(
track: SongTrack,
name: string,
range: { min: number; max: number },
): SongTrack {
const target = paramTarget(name);
const points = lanePoints(track, target).map((point) => ({
...point,
value: Math.max(range.min, Math.min(range.max, point.value)),
}));
return withLane(track, target, { target, points, min: range.min, max: range.max });
}
/** The track without that lane at all, a parameter's range with it. */
export function withoutLane(track: SongTrack, target: AutomationTarget): SongTrack {
return withLane(track, target, null);
}
/** In a fixed order: the volume, the pan, then the parameters as they came; a new one last. */
function withLane(track: SongTrack, target: AutomationTarget, lane: AutomationLane | null): SongTrack {
const others = (track.automation ?? []).filter((l) => l.target !== target);
const fixed = AUTOMATION_TARGETS.flatMap((t) =>
t === target && lane ? [lane] : others.filter((l) => l.target === t),
);
const params = (track.automation ?? []).flatMap((l) =>
paramOf(l.target) === null ? [] : l.target === target ? (lane ? [lane] : []) : [l],
);
const known = (track.automation ?? []).some((l) => l.target === target);
if (lane && paramOf(target) !== null && !known) params.push(lane);
const lanes = [...fixed, ...params];
const { automation: _, ...rest } = track;
return lanes.length > 0 ? { ...rest, automation: lanes } : rest;
}
function held(kind: LaneSpec, value: number): number {
const { min, max } = laneRange(kind);
return Math.max(min, Math.min(max, value));
}
/** A new point, after those already on its tick, and where it went. */
export function addedPoint(
points: readonly AutomationPoint[],
kind: LaneSpec,
tick: number,
value: number,
): { points: AutomationPoint[]; index: number } {
const at = Math.max(0, Math.round(tick));
let index = 0;
while (index < points.length && points[index]!.tick <= at) index++;
const out = [...points];
out.splice(index, 0, { tick: at, value: held(kind, value) });
return { points: out, index };
}
/** The point at `index` dragged to a tick and a value, the tick held between its neighbours'. */
export function draggedPoint(
points: readonly AutomationPoint[],
kind: LaneSpec,
index: number,
tick: number,
value: number,
): AutomationPoint[] {
const point = points[index];
if (!point) return [...points];
const low = points[index - 1]?.tick ?? 0;
const high = points[index + 1]?.tick ?? Infinity;
const out = [...points];
out[index] = {
...point,
tick: Math.min(high, Math.max(low, Math.round(tick))),
value: held(kind, value),
};
return out;
}
export function withoutPoint(points: readonly AutomationPoint[], index: number): AutomationPoint[] {
return points.filter((_, i) => i !== index);
}
/** The point at `index` easing to the next one along `ease`; linear is stored as no easing. */
export function easedPoint(
points: readonly AutomationPoint[],
index: number,
ease: KeyInterpolation,
): AutomationPoint[] {
return points.map((point, i) => {
if (i !== index) return point;
const { ease: _, ...rest } = point;
return ease === 'linear' ? rest : { ...rest, ease };
});
}
+186
View File
@@ -0,0 +1,186 @@
import {
AUTOMATION_TARGETS,
type AutomationPoint,
type AutomationTarget,
type LaneKind,
laneKind,
laneOf,
laneParams,
laneRange,
laneValueAt,
laneValueBefore,
paramOf,
paramTarget,
type LaneSpec,
type SequenceLanePoint,
} from '../../core/music/automation';
import type { SongTrack } from '../../core/music/song';
import { clamp } from '../../ui';
import { tickToX, xToTick } from '../music/timeView';
import type { SongView } from './songView';
/**
* The automation lanes of the Song tab (#337), apart from Pixi so a test can drive them: each lane a
* row of its own right under its track, as high as a track, so the rows stay evenly spaced and the
* geometry of `songView` holds for them as for the tracks.
*/
/** A row of the tracks: a track itself, or one of its lanes. `track` is the track's index. */
export interface SongRow {
track: number;
lane: AutomationTarget | null;
}
/** The lanes a track was opened to or closed on by hand, by its id. */
export type OpenLanes = ReadonlyMap<string, readonly AutomationTarget[]>;
/** What the head of a lane calls it: the volume, the pan, or the name of its parameter. */
export function laneLabel(target: AutomationTarget): string {
return target === 'volume' ? 'Volume' : target === 'pan' ? 'Pan' : (paramOf(target) ?? target);
}
/** Every lane a track can show: the volume, the pan, and each parameter it has a lane of (#337). */
export function trackLanes(track: SongTrack): AutomationTarget[] {
return [...AUTOMATION_TARGETS, ...laneParams(track).map(paramTarget)];
}
/**
* The lanes a track shows, in a fixed order: as opened by hand, else the ones with a point and every
* parameter's – one is only there because it was added.
*/
export function shownLanes(track: SongTrack, open: OpenLanes): AutomationTarget[] {
const named = open.get(track.id);
return trackLanes(track).filter((target) =>
named ? named.includes(target) : paramOf(target) !== null || !!laneOf(track, target),
);
}
/** A track's lane of that target as far as its kind goes: a parameter's range is on its lane. */
export function laneKindOf(track: SongTrack, target: AutomationTarget): LaneKind {
const lane = track.automation?.find((l) => l.target === target);
return lane ? { target, min: lane.min, max: lane.max } : { target };
}
export function songRows(tracks: readonly SongTrack[], open: OpenLanes): SongRow[] {
return tracks.flatMap((track, i) => [
{ track: i, lane: null },
...shownLanes(track, open).map((lane) => ({ track: i, lane })),
]);
}
/** The row of each track, by its id. */
export function trackRows(tracks: readonly SongTrack[], rows: readonly SongRow[]): Map<string, number> {
const out = new Map<string, number>();
rows.forEach((row, i) => {
if (row.lane === null) out.set(tracks[row.track]!.id, i);
});
return out;
}
/** What is left free above and below the way of a lane, so a point at either end can still be taken. */
export const LANE_INSET = 6;
/** How near a point a press takes it. */
export const POINT_REACH = 6;
/** Where a value is drawn in a lane `h` high, from its top; past the range it is held to the edge. */
export function laneY(kind: LaneSpec, value: number, h: number): number {
const { min, max } = laneRange(kind);
const share = clamp((value - min) / (max - min), 0, 1);
return LANE_INSET + (1 - share) * Math.max(1, h - 2 * LANE_INSET);
}
/**
* The step a lane's values are set in: a tenth of a dB, a hundredth of the pan, and for a parameter
* a power of ten that cuts its range into at least a hundred.
*/
export function laneStep(kind: LaneSpec): number {
const { target } = laneKind(kind);
if (target === 'volume') return 0.1;
if (target === 'pan') return 0.01;
const { min, max } = laneRange(kind);
return 10 ** Math.floor(Math.log10((max - min) / 100));
}
/** The value a height of a lane stands for, in its step. */
export function laneValue(kind: LaneSpec, y: number, h: number): number {
const { min, max } = laneRange(kind);
const share = clamp(1 - (y - LANE_INSET) / Math.max(1, h - 2 * LANE_INSET), 0, 1);
const step = laneStep(kind);
return clamp(roundTo(min + share * (max - min), step), min, max);
}
/** Rounded to a step, without the tail a float adds to a tenth. */
function roundTo(value: number, step: number): number {
const digits = Math.max(0, -Math.floor(Math.log10(step)));
return Number((Math.round(value / step) * step).toFixed(Math.min(digits, 12)));
}
/** The point of a lane under a point of its row, the last drawn first; null for none. */
export function pointAt(
view: SongView,
target: LaneSpec,
points: readonly AutomationPoint[],
x: number,
y: number,
): number | null {
for (let i = points.length - 1; i >= 0; i--) {
const point = points[i]!;
const dx = tickToX(view, point.tick) - x;
const dy = laneY(target, point.value, view.trackHeight) - y;
if (Math.abs(dx) <= POINT_REACH && Math.abs(dy) <= POINT_REACH) return i;
}
return null;
}
/**
* The way of a lane across `width` pixels, a corner every `every` pixels and at each point – a jump
* shows as an upright –, in the row's own pixels.
*/
export function laneWay(
view: SongView,
target: LaneSpec,
points: readonly SequenceLanePoint[],
width: number,
every = 2,
): { x: number; y: number }[] {
if (points.length === 0) return [];
const h = view.trackHeight;
const way: { x: number; y: number }[] = [];
const corners = new Set<number>();
for (let x = 0; x <= width; x += every) corners.add(x);
for (const point of points) {
const x = tickToX(view, point.tick);
if (x > 0 && x < width) corners.add(x);
}
for (const x of [...corners].sort((a, b) => a - b)) {
const at = points.findIndex((p) => tickToX(view, p.tick) === x);
if (at < 0) {
way.push({ x, y: laneY(target, laneValueAt(points, xToTick(view, x)), h) });
continue;
}
// On a point the way comes in with the value before it and leaves from the last point on its tick.
const tick = points[at]!.tick;
const before = laneValueBefore(points, tick);
if (before !== points[at]!.value) way.push({ x, y: laneY(target, before, h) });
for (let i = at; i < points.length && points[i]!.tick === tick; i++)
way.push({ x, y: laneY(target, points[i]!.value, h) });
}
return way;
}
/** A value as the head of a lane and the sign over a point say it. */
export function laneValueText(kind: LaneSpec, value: number): string {
const { target } = laneKind(kind);
if (target === 'volume') {
const { min } = laneRange('volume');
if (value <= min) return '-inf dB';
const shown = Math.round(value * 10) / 10;
return `${shown > 0 ? '+' : ''}${shown.toFixed(1)} dB`;
}
if (target === 'pan') {
const share = Math.round(Math.abs(value) * 100);
return share === 0 ? 'Centre' : `${share}% ${value < 0 ? 'left' : 'right'}`;
}
const digits = Math.max(0, -Math.floor(Math.log10(laneStep(kind))));
return roundTo(value, laneStep(kind)).toFixed(Math.min(digits, 6));
}
+51
View File
@@ -0,0 +1,51 @@
import type { SongMarker } from '../../core/music/markers';
import { RULER_HEIGHT, type TimeView, tickToX } from '../music/timeView';
/** The strip under the song's ruler that holds its markers (#338). */
export const MARKER_STRIP = 18;
/** Where the tracks of the Song tab start: below the ruler and its markers. */
export const SONG_TOP = RULER_HEIGHT + MARKER_STRIP;
/** How far either side of its line a press still takes a marker. */
export const MARKER_REACH = 5;
/** The flag at the head of a marker's line, and the room between it and the name. */
export const MARKER_FLAG = 8;
const NAME_GAP = 3;
/** Where a marker is drawn on the strip: its line at `x`, flag and name over `w` pixels right of it. */
export interface MarkerSpan {
id: string;
x: number;
w: number;
}
/**
* Each marker as drawn on the strip: its flag and as much of its name as there is room for before
* the next one. `measure` gives the width of a name in pixels.
*/
export function markerSpans(
view: TimeView,
markers: readonly SongMarker[],
measure: (name: string) => number,
): MarkerSpan[] {
return markers.map((marker, i) => {
const x = tickToX(view, marker.tick);
const next = markers[i + 1];
const room = next ? tickToX(view, next.tick) - x - 2 : Infinity;
const w = Math.max(MARKER_FLAG, Math.min(MARKER_FLAG + NAME_GAP + measure(marker.name), room));
return { id: marker.id, x, w };
});
}
/** Where a marker's name starts, right of its flag. */
export function markerNameX(span: MarkerSpan): number {
return span.x + MARKER_FLAG + NAME_GAP;
}
/** The marker under `x` on the strip, the last drawn first: near its line, or on its flag and name. */
export function markerAt(spans: readonly MarkerSpan[], x: number): string | null {
for (let i = spans.length - 1; i >= 0; i--) {
const span = spans[i]!;
if (x >= span.x - MARKER_REACH && x <= span.x + Math.max(span.w, MARKER_REACH)) return span.id;
}
return null;
}
+16 -11
View File
@@ -99,17 +99,19 @@ export function itemBox(view: SongView, index: number, item: Pick<SongItem, 'tic
export type ItemPart = 'start' | 'body' | 'end';
/** The item under a point, the last drawn first, and which part of it: an end within reach, else the body. */
/**
* The item under a point, the last drawn first, and which part of it: an end within reach, else the
* body. `rows` is the row of each track by its id (`indexOf` while no lane is open, #337).
*/
export function itemAt(
view: SongView,
items: readonly SongItem[],
tracks: readonly SongTrack[],
rows: ReadonlyMap<string, number>,
p: Vec2,
): { id: string; part: ItemPart } | null {
const index = indexOf(tracks);
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]!;
const row = index.get(item.track);
const row = rows.get(item.track);
if (row === undefined) continue;
const box = itemBox(view, row, item);
if (p.y < box.y || p.y > box.y + box.h || p.x < box.x || p.x > box.x + box.w) continue;
@@ -125,18 +127,17 @@ export function itemAt(
export function itemsWithin(
view: SongView,
items: readonly SongItem[],
tracks: readonly SongTrack[],
rows: ReadonlyMap<string, number>,
a: Vec2,
b: Vec2,
): string[] {
const index = indexOf(tracks);
const x0 = Math.min(a.x, b.x);
const x1 = Math.max(a.x, b.x);
const y0 = Math.min(a.y, b.y);
const y1 = Math.max(a.y, b.y);
return items
.filter((item) => {
const row = index.get(item.track);
const row = rows.get(item.track);
if (row === undefined) return false;
const box = itemBox(view, row, item);
return box.x <= x1 && box.x + box.w >= x0 && box.y <= y1 && box.y + box.h >= y0;
@@ -180,11 +181,15 @@ export function indexOf(tracks: readonly SongTrack[]): Map<string, number> {
return new Map(tracks.map((t, i) => [t.id, i]));
}
/** The parts of a track's head, in its own pixels: name, sound FX (#346), mute, solo, volume and pan. */
export type HeadPart = 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
/**
* The parts of a track's head, in its own pixels: the switch that opens its lanes (#337), name, sound
* FX (#346), mute, solo, volume and pan.
*/
export type HeadPart = 'lanes' | 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
name: { x: 8, y: 4, w: 88, h: 18 },
lanes: { x: 4, y: 5, w: 14, h: 16 },
name: { x: 22, y: 4, w: 76, h: 18 },
fx: { x: 102, y: 5, w: 22, h: 16 },
mute: { x: 128, y: 5, w: 18, h: 16 },
solo: { x: 150, y: 5, w: 18, h: 16 },
@@ -202,7 +207,7 @@ export function trackMeterBox(trackHeight: number): Box {
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
for (const part of ['fx', 'mute', 'solo', 'volume', 'pan'] as const) {
for (const part of ['lanes', 'fx', 'mute', 'solo', 'volume', 'pan'] as const) {
if (!sliders && (part === 'volume' || part === 'pan')) continue;
const box = HEAD_BOXES[part];
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)
+3
View File
@@ -5,6 +5,7 @@ import type { CompositionPlayer } from '../core/model/composition';
import type { FrameOutline } from '../core/model/frameOutline';
import type { TileEdits } from '../core/model/liveTilemap';
import type { PropertyBag } from '../core/model/Properties';
import type { VideoClock } from '../core/model/videoClock';
/** A live instance of a scene object; its resolved props are mutable runtime state the world mirrors back. */
export interface Entity {
@@ -29,6 +30,8 @@ export interface Entity {
animations?: AnimationPlayer[];
/** For a placed composition: what `Play composition` started (#417), out of `props` like `animations`. */
composition?: CompositionPlayer;
/** For a sprite wearing a video: the time a run counts for it (#410), out of `props` like `animations`. */
video?: VideoClock;
/** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */
emits?: number;
/**
+7 -1
View File
@@ -18,6 +18,7 @@ import {
import type { Input } from './systems/Input';
import { bringObjects, keptEntities, objectsToBring } from './keptObjects';
import type { Rapier } from './physics/engine';
import { PlayedEnds } from './systems/playedEnds';
import { World } from './World';
export interface RunServices {
@@ -49,6 +50,8 @@ export class GameRun {
private readonly services: RunServices,
public world: World,
public behaviors: BehaviorSystem,
/** What the run played, heard ending in whatever scene it is in by then (#373). */
private readonly ends: PlayedEnds,
) {}
static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun {
@@ -58,7 +61,9 @@ export class GameRun {
services.audio.resetMix();
services.audio.setMix(mixOf(project), videoBusesOf(project));
const world = World.instantiate(project, sceneId);
const run = new GameRun(project, input, services, world, addGameSystems(world, project, input, services));
const ends = new PlayedEnds();
const behaviors = addGameSystems(world, project, input, { ...services, ends });
const run = new GameRun(project, input, services, world, behaviors, ends);
if (before) run.stored.set(sceneId, before);
return run;
}
@@ -106,6 +111,7 @@ export class GameRun {
...this.services,
carried,
kept,
ends: this.ends,
});
}
+67 -4
View File
@@ -1,8 +1,9 @@
import type { EaseShare } from '../core/model/animation';
import type { AutomationTarget } from '../core/music/automation';
import type { MixBus } from '../core/music/mixer';
import type { SequencePlay } from '../core/music/playbacks';
import type { HeardTime, LandingAt, SequencePlay } from '../core/music/playbacks';
import type { FxParams, FxPlan, FxVoice } from '../core/soundfx/nodes';
import type { AudioSink, ClipTiming, NoteTiming } from './systems/Audio';
import type { AudioSink, ClipTiming, EndedPlay, NoteTiming } from './systems/Audio';
/** The first number a sink diverted to gives out: whatever it numbers is told apart from the live one's. */
export const DIVERTED_IDS = 1_000_000_000;
@@ -46,8 +47,16 @@ export class AudioSwitch implements AudioSink {
return id >= DIVERTED_IDS ? this.aside : this.live;
}
play(file: string, volume: number, loop: boolean, owner: string, fx?: FxVoice, bus?: string): number {
return this.now.play(file, volume, loop, owner, fx, bus);
play(
file: string,
volume: number,
loop: boolean,
owner: string,
fx?: FxVoice,
bus?: string,
at?: number,
): number {
return this.now.play(file, volume, loop, owner, fx, bus, at);
}
playNote(
@@ -133,4 +142,58 @@ export class AudioSwitch implements AudioSink {
clock(): number {
return this.now.clock?.() ?? 0;
}
/** What ended in either sink (#373): their numbers are told apart. */
endings(): EndedPlay[] {
return this.both.flatMap((sink) => sink.endings?.() ?? []);
}
playing(id: number): boolean {
return this.of(id)?.playing?.(id) ?? false;
}
/** Both sinks' playings: each counts its own on its own clock. */
playingTimes(): HeardTime[] {
return this.both.flatMap((sink) => sink.playingTimes?.() ?? []);
}
/** Only on the clock of the sink a stinger is played in: one diverted since counts on another. */
landOn(id: number, at: LandingAt): number | null {
const sink = this.of(id);
return sink === this.now ? (sink.landOn?.(id, at) ?? null) : null;
}
switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean {
return this.of(id)?.switchSequence?.(id, name, play, at, fade) ?? false;
}
setSongTrack(
id: number,
track: string,
gain: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none' {
return this.of(id)?.setSongTrack?.(id, track, gain, fade, at) ?? 'none';
}
holdSongLane(
id: number,
track: string,
target: AutomationTarget,
value: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none' {
return this.of(id)?.holdSongLane?.(id, track, target, value, fade, at) ?? 'none';
}
releaseSongLane(
id: number,
track: string,
target: AutomationTarget,
fade: number,
): 'set' | 'baked' | 'none' {
return this.of(id)?.releaseSongLane?.(id, track, target, fade) ?? 'none';
}
}
+87 -3
View File
@@ -1,5 +1,6 @@
import type { EaseShare } from '../../core/model/animation';
import type { SequencePlay } from '../../core/music/playbacks';
import type { AutomationTarget } from '../../core/music/automation';
import type { HeardTime, LandingAt, SequencePlay } from '../../core/music/playbacks';
import type { MixBus } from '../../core/music/mixer';
import type { FxParams, FxPlan, FxVoice } from '../../core/soundfx/nodes';
@@ -19,9 +20,18 @@ export interface AudioSink {
* `''` means the voice has no owner and outlives whatever started it. `fx` is the sound FX the
* voice passes on its way out (#311), with the values of its parameters; `DRY_VOICE` passes none.
* Returns the number of the voice, never used twice, or 0 where nothing is going to play. `bus` is
* the bus of the mix it is heard on (#325), the master where left out.
* the bus of the mix it is heard on (#325), the master where left out. `at` is the second of the
* audio clock it is heard from (#338), now where it is left out or already past.
*/
play(file: string, volume: number, loop: boolean, owner: string, fx?: FxVoice, bus?: string): number;
play(
file: string,
volume: number,
loop: boolean,
owner: string,
fx?: FxVoice,
bus?: string,
at?: number,
): number;
/**
* Starts one note of an instrument (#331), as `play` starts a sound: `layers` are what the note is
* made of, played side by side as one voice, each way reading the file of its own source. `name`
@@ -108,6 +118,67 @@ export interface AudioSink {
* picture by. A sink without it keeps no time, and nothing is held to it.
*/
clock?(): number;
/**
* The voices of `play` and the playings of `playSequence` that ended lately, oldest first (#373):
* once nothing of them is heard any more, after their echoes and a fade out. The last
* `ENDED_KEPT` are kept, so one followed every frame misses none. A sink without it tells of none.
*/
endings?(): readonly EndedPlay[];
/**
* Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its bytes,
* playing, fading, or its echoes ringing. A sink without it plays nothing.
*/
playing?(id: number): boolean;
/**
* The patterns and songs playing, each with how far into its playing time it is heard now (#338):
* what beats, bars and `Song position` go by. A sink without it plays none to be followed.
*/
playingTimes?(): readonly HeardTime[];
/**
* The second of the audio clock `at` names on the clock of the playing numbered `id` (#338), no
* sooner than something started now is heard: where a stinger in time with it starts. Null where
* no such playing is heard; a sink without it syncs nothing.
*/
landOn?(id: number, at: LandingAt): number | null;
/**
* Plays `play` in place of the playing numbered `id` (#338), heard from the second `at` names and
* taking its number; the old one falls silent there, over `fade` seconds or in a cut. Says whether
* there was such a playing. A sink without it switches nothing.
*/
switchSequence?(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean;
/**
* Turns the track bus `track` of the playing numbered `id` to `gain` (#338), on top of its own
* level, over `fade` seconds from the second `at` names: `baked` where it plays its bake, `none`
* where there is no such playing or track.
*/
setSongTrack?(
id: number,
track: string,
gain: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none';
/**
* Holds the lane of `target` of the track bus `track` of the playing numbered `id` on `value`, in
* the lane's units (#338), reached over `fade` seconds from the second `at` names: its own points
* are not heard until it is let go. `baked` where it plays its bake, `none` where there is no such
* playing or lane.
*/
holdSongLane?(
id: number,
track: string,
target: AutomationTarget,
value: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none';
/** Lets such a held lane go as soon as can be heard (#338), back to its curve over `fade` seconds. */
releaseSongLane?(
id: number,
track: string,
target: AutomationTarget,
fade: number,
): 'set' | 'baked' | 'none';
}
/**
@@ -123,6 +194,19 @@ export interface NoteTiming {
note?: number;
}
/**
* A voice or a playing that ended (#373), by its number: `name` is its file, or what stands for the
* pattern or song. `stopped` where a stop ended it rather than it playing to its end.
*/
export interface EndedPlay {
id: number;
name: string;
stopped: boolean;
}
/** So many ends a sink keeps to be told (see `AudioSink.endings`). */
export const ENDED_KEPT = 256;
/** When and how a clip of a song plays (#334); see `playClip`. */
export interface ClipTiming {
at: number;
+57 -3
View File
@@ -24,6 +24,8 @@ import {
type Value,
} from '../../core/graph';
import { GraphCache } from '../../core/graph/graphIndex';
import { PLAYBACK_INPUT } from '../../core/graph/nodes/events/musicEvents';
import { VOICE_INPUT } from '../../core/graph/nodes/events/OnSoundFinished';
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
import { keyName } from '../../core/keys';
import { graphLookup } from '../../core/model/graphLookup';
@@ -51,14 +53,25 @@ import {
type Pulse,
pulsesOf,
timerPulses,
videoFinishedPulses,
} from './behaviorPulses';
import { CollisionSystem } from './CollisionSystem';
import {
barPulses,
beatPulses,
heardPlayings,
markerPulses,
MusicFollower,
type PlaybackOf,
} from './musicPulses';
import { musicFinishedPulses, PlayedEnds, soundFinishedPulses } from './playedEnds';
import type { CollisionSource } from './solids';
import { AnimationSystem } from './AnimationSystem';
import { CompositionSystem, type VideoPictures } from './CompositionSystem';
import type { Input } from './Input';
import type { RecordingSink } from '../../core/recording/recording';
import type { System } from './System';
import { VideoSystem } from './VideoSystem';
export type { Breakpoint, TimeScaled } from './behaviorContext';
@@ -122,13 +135,17 @@ export interface GameSystemOptions {
recorder?: RecordingSink | null;
/** Whether `Play composition` plays (#417); the editor run ignores it. */
playsCompositions?: boolean;
/** Whether `Play video`, `Pause video` and `Seek video` steer (#410); the editor run ignores them. */
steersVideos?: boolean;
/** The view a played game is shown in: a composition waits for its videos there (#417). */
pictures?: VideoPictures | null;
/** Where a `Break` node pauses: only the editor's Play has one. */
breakpoint?: Breakpoint | null;
/** What the run played, kept through its scenes (#373); a world without one keeps its own. */
ends?: PlayedEnds | null;
}
/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */
/** Collisions, compositions, animations and videos are ticked first, so behaviors see this frame's events. */
export function addGameSystems(
world: World,
project: Project,
@@ -143,14 +160,28 @@ export function addGameSystems(
saves = null,
recorder = null,
playsCompositions = true,
steersVideos = true,
pictures = null,
breakpoint = null,
ends = null,
}: GameSystemOptions = {},
): BehaviorSystem {
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const compositions = new CompositionSystem(playsCompositions, pictures, audio);
const animations = new AnimationSystem();
const sources = { collisions, animations, compositions, audio, clock, saves, recorder, breakpoint };
const videos = new VideoSystem(steersVideos);
const sources = {
collisions,
animations,
compositions,
videos,
audio,
clock,
saves,
recorder,
breakpoint,
ends,
};
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
if (carried) behaviors.carryOver(carried);
if (kept) {
@@ -160,6 +191,7 @@ export function addGameSystems(
world.addSystem(collisions);
world.addSystem(compositions);
world.addSystem(animations);
world.addSystem(videos);
world.addSystem(behaviors);
return behaviors;
}
@@ -169,12 +201,15 @@ export interface EventSources {
collisions?: (CollisionSource & System) | null;
animations?: AnimationSystem | null;
compositions?: CompositionSystem | null;
videos?: VideoSystem | null;
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
audio?: AudioSink | null;
clock?: TimeScaled | null;
saves?: SaveArea | null;
recorder?: RecordingSink | null;
breakpoint?: Breakpoint | null;
/** What the run played, to hear of its end (#373). */
ends?: PlayedEnds | null;
}
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
@@ -216,13 +251,17 @@ export class BehaviorSystem implements System {
private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null;
private readonly compositions: CompositionSystem | null;
private readonly videos: VideoSystem | null;
private readonly audio: AudioSink;
private readonly clock: TimeScaled | null;
private readonly saves: SaveArea | null;
private readonly recorder: RecordingSink | null;
private readonly breakpoint: Breakpoint | null;
private readonly ends: PlayedEnds;
/** What was last written to `saves`, so a frame that changed nothing writes nothing. */
private lastSaved = '';
/** Where each playing of a song or pattern was heard last frame (#338). */
private readonly music = new MusicFollower();
constructor(
private readonly project: Project,
@@ -237,11 +276,13 @@ export class BehaviorSystem implements System {
this.collisions = sources.collisions ?? null;
this.animations = sources.animations ?? null;
this.compositions = sources.compositions ?? null;
this.videos = sources.videos ?? null;
this.audio = sources.audio ?? SILENT_AUDIO;
this.clock = sources.clock ?? null;
this.saves = sources.saves ?? null;
this.recorder = sources.recorder ?? null;
this.breakpoint = sources.breakpoint ?? null;
this.ends = sources.ends ?? new PlayedEnds();
}
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
@@ -633,9 +674,14 @@ export class BehaviorSystem implements System {
const owner = i.entity.parent;
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
};
const { input, collisions, animations, compositions } = this;
const { input, collisions, animations, compositions, videos } = this;
const perCell: PerCell = (i, node) =>
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
// Every frame, paused or not: the music goes on, and what a paused one passed is not told after.
const passed = this.music.follow(heardPlayings(this.project, this.audio));
const playbackOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, PLAYBACK_INPUT, contextOf(i));
const voiceOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, VOICE_INPUT, contextOf(i));
const finished = this.ends.follow(this.audio.endings?.() ?? []);
const kinds: (() => Pulse[])[] = [
() =>
ticking.flatMap((i) =>
@@ -650,6 +696,12 @@ export class BehaviorSystem implements System {
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
() => (frozen ? [] : ticking.flatMap((i) => compositionFinishedPulses(i, compositions))),
() => (frozen ? [] : ticking.flatMap((i) => videoFinishedPulses(i, videos))),
() => ticking.flatMap((i) => beatPulses(i, passed, playbackOf)),
() => ticking.flatMap((i) => barPulses(i, passed, playbackOf)),
() => ticking.flatMap((i) => markerPulses(i, passed, playbackOf)),
() => ticking.flatMap((i) => musicFinishedPulses(i, finished, playbackOf)),
() => ticking.flatMap((i) => soundFinishedPulses(i, finished, voiceOf)),
() =>
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
() =>
@@ -778,10 +830,12 @@ export class BehaviorSystem implements System {
project: this.project,
input: this.input,
collisions: this.collisions,
videos: this.videos,
audio: this.audio,
clock: this.clock,
recorder: this.recorder,
breakpoint: this.breakpoint,
ends: this.ends,
shared: this.sharedState,
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
});
+132
View File
@@ -0,0 +1,132 @@
import { isHandleEnabled } from '../../core/graph';
import { findVideo, videoLengthOf, videoPlayOf, type VideoRef } from '../../core/model/video';
import {
advanceVideoClock,
clipTimeOf,
newVideoClock,
steerVideoClock,
type VideoClock,
type VideoReading,
type VideoSteer,
} from '../../core/model/videoClock';
import {
VIDEO_CLOCK_KEY,
VIDEO_STEERED,
VIDEO_TIME_KEY,
videoDrivenOf,
videoTimeOf,
} from '../../core/properties/videos';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
export interface VideoFinish {
/** The sprite. */
entity: Entity;
videoId: string;
/** The video or clip it wears. */
ref: string;
steeredBy: readonly object[];
}
/**
* Counts the time of every video a sprite wears in the run (#410) and tells when one without Loop
* reaches its end. Runs after the compositions and animations, which set a driven one's time, and
* before the behaviors, so they hear this frame's `finished`. A paused game does not tick it: every
* clock holds.
*
* A sprite a behavior steered is driven by its clock from then on: a player of its own shows the
* picture at that time, as for a driven one. The editor run steers none: it writes into the saved
* scene, where the clock would stay behind.
*/
export class VideoSystem implements System {
finished: VideoFinish[] = [];
constructor(private readonly steers = true) {}
update(world: World, dt: number): void {
this.finished = [];
for (const entity of world.entities) {
if (entity.type !== 'sprite') continue;
const ref = wornVideo(world, entity);
if (!ref) {
letGo(entity);
continue;
}
const known = entity.video?.ref === entity.props.texture;
const clock = clockOf(entity);
const length = videoLengthOf(ref);
const loop = videoPlayOf(ref).loop;
let ended: boolean;
if (!clock.steered && videoDrivenOf(entity.props)) {
const time = videoTimeOf(entity.props);
const was = known ? clock.time : time;
ended = !loop && length > 0 && was < length && time >= length;
clock.playing = time !== was;
clock.time = time;
} else {
ended = isHandleEnabled(entity) && advanceVideoClock(clock, dt, length, loop);
if (clock.steered) drive(entity, clock);
}
if (ended)
this.finished.push({ entity, videoId: ref.asset.id, ref: clock.ref, steeredBy: clock.steeredBy });
}
}
stop(): void {
this.finished = [];
}
/** What a steering node does (#410); the line for the log where it does nothing. */
steer(world: World, entity: Entity, steer: VideoSteer, by: object): string | null {
const ref = entity.type === 'sprite' ? wornVideo(world, entity) : null;
if (!ref) return `${entity.name} wears no video, so it cannot be played, paused or sought`;
if (!this.steers) return 'Play, Pause and Seek video are ignored in the editor run';
const clock = clockOf(entity);
if (!clock.steered && videoDrivenOf(entity.props))
return `${entity.name} has a driven Video clock: its Video time says where the video stands`;
clock.steered = true;
if (!clock.steeredBy.includes(by)) clock.steeredBy.push(by);
steerVideoClock(clock, steer, videoLengthOf(ref), videoPlayOf(ref).loop);
drive(entity, clock);
return null;
}
/** Where the video `entity` wears stands; null for none. */
reading(world: World, entity: Entity): VideoReading | null {
const ref = entity.type === 'sprite' ? wornVideo(world, entity) : null;
if (!ref) return null;
const clock = clockOf(entity);
const length = videoLengthOf(ref);
return { time: clipTimeOf(clock.time, length, videoPlayOf(ref).loop), length, playing: clock.playing };
}
}
function wornVideo(world: World, entity: Entity): VideoRef | null {
const texture = entity.props.texture;
return typeof texture === 'string' ? findVideo(world.project, texture) : null;
}
/** The sprite's clock, started over when it wears another video; a steered one stays steered. */
function clockOf(entity: Entity): VideoClock {
const texture = entity.props.texture as string;
const clock = entity.video;
if (clock?.ref === texture) return clock;
const fresh = newVideoClock(texture);
if (clock?.steered) {
fresh.steered = true;
fresh.steeredBy = clock.steeredBy;
}
return (entity.video = fresh);
}
function drive(entity: Entity, clock: VideoClock): void {
entity.props[VIDEO_CLOCK_KEY] = VIDEO_STEERED;
entity.props[VIDEO_TIME_KEY] = clock.time;
}
/** A sprite that wears no video any more has no clock; one a behavior steered is free again. */
function letGo(entity: Entity): void {
if (entity.video?.steered) entity.props[VIDEO_CLOCK_KEY] = 'free';
delete entity.video;
}
+358 -49
View File
@@ -3,7 +3,7 @@
* the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values.
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
*/
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
import type { EntityHandle, MusicSync, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
import { keyName } from '../../core/keys';
import { collisionFilterOf } from '../../core/model/collisionLayers';
import type { Vec2 } from '../../core/model/Properties';
@@ -24,6 +24,9 @@ import { bakePlayback, validBake } from '../../core/music/bake';
import { clipFx, fxVoiceOf, sequenceNeeds } from '../../core/music/needs';
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
import { type AutomationLane, laneOf, laneRange, targetNamed } from '../../core/music/automation';
import { nextSpot, type Quantize, quantizeSpots } from '../../core/music/beats';
import { heldSongMarkers } from '../../core/music/markers';
import {
dbToGain,
songLength,
@@ -31,8 +34,12 @@ import {
songSequence,
songTempo,
SOUND_ITEM_MAX_DB,
TRACK_MAX_DB,
TRACK_MIN_DB,
trackBus,
} from '../../core/music/song';
import type { SequencePlay } from '../../core/music/playbacks';
import type { LandingAt, SequencePlay } from '../../core/music/playbacks';
import { findSongMarker } from '../../core/properties/assetRef';
import { songBuses } from '../../core/music/songBuses';
import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer';
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
@@ -42,8 +49,11 @@ import type { World } from '../World';
import type { AudioSink, NoteTiming } from './Audio';
import type { Instance } from './behaviorPulses';
import type { Input } from './Input';
import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses';
import type { PlayedEnds } from './playedEnds';
import type { RecordingSink } from '../../core/recording/recording';
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
import type { VideoSystem } from './VideoSystem';
export interface TimeScaled {
scale: number;
@@ -57,12 +67,16 @@ export interface ContextSources {
project: Project;
input: Input;
collisions: CollisionSource | null;
/** What counts and steers the videos the sprites wear (#410); absent, nothing does. */
videos?: VideoSystem | null;
audio: AudioSink;
clock: TimeScaled | null;
/** Where a recording of the game view goes (#405); only a played game has one. */
recorder: RecordingSink | null;
/** Where `Break` pauses; only the editor's Play has one. */
breakpoint: Breakpoint | null;
/** What the run played, to be told when it ends (#373); absent, nothing is. */
ends?: PlayedEnds;
shared: SharedState;
/** A graph moved one of this instance's machines; its events follow between exec flows. */
stateChanged(machineId: string, from: string, to: string): void;
@@ -191,23 +205,94 @@ export function behaviorContext(
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo, bus),
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams, bus) =>
playNote(
steerVideo: (target, steer) => {
const found = resolve(target);
const said = found && run.videos ? run.videos.steer(world, found, steer, entity) : null;
if (said) world.log.push(said);
},
videoReading: (target) => {
const found = resolve(target);
return (found && run.videos?.reading(world, found)) ?? null;
},
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) => {
const voice = playSound(
run,
assetId,
volume,
loop,
detached ? '' : entity.id,
fx,
fxParams,
tempo,
bus,
syncedStart(run, sync)?.at,
);
run.ends?.sound(voice, assetId);
return voice;
},
playNote: (
instrumentId,
note,
velocity,
volume,
duration,
detached,
params,
tempo,
fx,
fxParams,
bus,
sync,
) => {
const synced = syncedStart(run, sync);
return playNote(
run,
instrumentId,
{ note, velocity, volume, duration },
detached ? '' : entity.id,
params,
{ tempo, bus: instrumentBus(project, instrumentId, bus) },
{ tempo, bus: instrumentBus(project, instrumentId, bus), ...(synced && { at: synced.at }) },
fxVoiceOf(project, instrumentFx(project, instrumentId, fx), tempo, fxParams),
),
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams, bus),
);
},
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) => {
const id = playPattern(
run,
patternId,
volume,
loop,
detached ? '' : entity.id,
fx,
fxParams,
bus,
syncedStart(run, sync),
);
run.ends?.playing(id);
return id;
},
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams, bus),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => {
const id = playSong(run, songId, {
volume,
fadeIn,
loop,
owner: detached ? '' : entity.id,
fx,
fxParams,
bus,
live,
});
run.ends?.playing(id);
return id;
},
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize),
setSongLane: (playback, track, lane, value, fade, quantize) =>
setSongLane(run, world, playback, track, lane, value, heldFade(fade), quantize),
releaseSongLane: (playback, track, lane, fade) =>
releaseSongLane(run, world, playback, track, lane, heldFade(fade)),
switchSong: (playback, songId, marker, quantize, fade) =>
switchSong(run, world, playback, songId, marker, quantize, heldFade(fade)),
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
soundLength: (assetId) => {
@@ -235,6 +320,9 @@ export function behaviorContext(
const reached = reachedBus(project, bus);
return reached ? run.audio.mixVolume(reached) : 0;
},
songPosition: (playback, song, pattern) =>
positionOf(heardPlayings(project, run.audio), playback, song, pattern),
playing: (id) => id > 0 && (run.audio.playing?.(id) ?? false),
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
addJoint: (assetId, owner, connected, at) => {
const on = resolve(owner);
@@ -305,6 +393,7 @@ function playSound(
fxParams: FxParams,
tempo: number,
bus: string,
at?: number,
): number {
const ref = findSound(project, assetId);
if (!ref?.asset.file) return 0;
@@ -318,9 +407,31 @@ function playSound(
owner,
fx ?? DRY_VOICE,
playedBus(project, bus, ref.asset.id),
at,
);
}
/** Where a sound, note or pattern synced to a playing starts (#338), and the tempo that one plays at. */
interface SyncedStart {
at: number;
tempo: number;
}
/**
* The start of a stinger (#338): on the spot `sync.quantize` names of the playing it is synced to,
* as that is heard. Null where it is not synced, or nothing of that number is heard: it starts at once.
*/
function syncedStart(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
sync: MusicSync,
): SyncedStart | null {
if (!(sync.playback > 0)) return null;
const playing = heardPlayings(project, audio).find((p) => p.id === sync.playback);
if (!playing) return null;
const at = audio.landOn?.(sync.playback, landing(sync.quantize, playing)) ?? null;
return at === null ? null : { at, tempo: playing.timeline.tempo };
}
/** The sound FX a note of that instrument goes through, by the pick of a node (#352); '' for none. */
export function instrumentFx(project: Project, instrumentId: string, picked: string): string {
return pickedFx(picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {});
@@ -392,7 +503,9 @@ function heardOn(route: MixRoute): Pick<SequencePlay, 'bus' | 'sends'> {
* A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or
* round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner
* of the playing; its bake (#340) takes the volume where it still holds, and is played instead.
* Returns the number of the playing, 0 where there is no pattern or nothing to hear.
* `synced` starts it in time with another playing (#338), at that one's tempo: its bake is of its
* own tempo, so only that plays it. Returns the number of the playing, 0 where there is no pattern
* or nothing to hear.
*/
function playPattern(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
@@ -403,6 +516,7 @@ function playPattern(
fxId: string,
fxParams: FxParams,
bus: string,
synced: SyncedStart | null = null,
): number {
const asset = findAssetOf(project, patternId, 'pattern');
if (!asset) return 0;
@@ -411,9 +525,10 @@ function playPattern(
if (!instrument || sequence.notes.length === 0) return 0;
const level = heldLevel(volume);
const length = patternLength(asset);
const tempo = patternTempo(asset.props);
const own = patternTempo(asset.props);
const tempo = synced?.tempo ?? own;
const fx = fxVoiceOf(project, pickedFx(fxId, asset.props), tempo, fxParams);
const bake = validBake(project, asset);
const bake = tempo === own ? validBake(project, asset) : null;
return audio.playSequence(
patternVoiceName(patternId),
{
@@ -426,53 +541,240 @@ function playPattern(
...heardOn(playedRoute(project, bus, patternId)),
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
...(bake && { bake: { file: bake.file, gain: bake.peak * level, playback: bakePlayback(bake, loop) } }),
...(synced && { at: synced.at }),
},
owner,
);
}
/** How a Play song asked for a song (#338): what a Switch song plays the next one by. */
interface SongAsk {
volume: number;
fadeIn: number;
loop: boolean;
owner: string;
fx: string;
fxParams: FxParams;
bus: string;
/** Its notes, even where it has a bake: its tracks can be turned then. */
live: boolean;
}
/** What each playing of a song was asked for, by its number, in the sink it plays in. */
const songAsks = new WeakMap<AudioSink, Map<number, SongAsk>>();
/** So many asks are kept before those of playings gone are let go. */
const ASKS_KEPT = 64;
function keepAsk(audio: AudioSink, id: number, ask: SongAsk): void {
let asks = songAsks.get(audio);
if (!asks) songAsks.set(audio, (asks = new Map<number, SongAsk>()));
if (asks.size >= ASKS_KEPT) {
const playing = new Set((audio.playingTimes?.() ?? []).map((time) => time.id));
for (const known of asks.keys()) if (!playing.has(known)) asks.delete(known);
}
asks.set(id, ask);
}
/**
* A song played by a game (#336): its tracks at their level, pan, mute and solo, at its tempo. With
* `loop` it plays from its start and then runs round the loop of its ruler, else once to its end.
* `volume` and `fadeIn` are the level of the whole playing. Its bake (#340) is played instead while
* it holds. Returns the number of the playing, 0 where there is no song or nothing in it to play.
* it holds, unless `live` (#338). Returns the number of the playing, 0 where there is no song or
* nothing in it to play.
*/
function playSong(
function playSong(sources: Pick<ContextSources, 'project' | 'audio'>, songId: string, ask: SongAsk): number {
const play = songPlay(sources, songId, ask, 0);
const id = play ? sources.audio.playSequence(songVoiceName(songId), play, ask.owner) : 0;
if (id !== 0) keepAsk(sources.audio, id, ask);
return id;
}
/**
* What a song plays as, asked for so (see `playSong`), from `from` ticks on (#338): a bake plays only
* from the start, so one asked to start further in plays its notes.
*/
function songPlay(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
songId: string,
volume: number,
fadeIn: number,
loop: boolean,
owner: string,
fxId: string,
fxParams: FxParams,
bus: string,
): number {
ask: SongAsk,
from: number,
): SequencePlay | null {
const song = findAssetOf(project, songId, 'song');
if (!song) return 0;
if (!song) return null;
const sequence = songSequence(project, song);
const tempo = songTempo(song.props);
const fx = fxVoiceOf(project, pickedFx(fxId, song.props), tempo, fxParams);
const bake = validBake(project, song);
return audio.playSequence(
songVoiceName(songId),
{
sequence,
tempo,
length: songLength(song),
loop: loop ? songLoop(song) : null,
needs: sequenceNeeds(project, sequence),
volume: heldLevel(volume),
fadeIn: heldFade(fadeIn),
buses: songBuses(project, song),
...(fx && { fx }),
...heardOn(playedRoute(project, bus, songId)),
strike: (note) => strikeNote({ project, audio }, note, owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, loop) } }),
},
owner,
const fx = fxVoiceOf(project, pickedFx(ask.fx, song.props), tempo, ask.fxParams);
const bake = ask.live || from > 0 ? null : validBake(project, song);
return {
sequence,
tempo,
length: songLength(song),
loop: ask.loop ? songLoop(song) : null,
needs: sequenceNeeds(project, sequence),
volume: heldLevel(ask.volume),
fadeIn: heldFade(ask.fadeIn),
buses: songBuses(project, song),
...(fx && { fx }),
...heardOn(playedRoute(project, ask.bus, songId)),
strike: (note) => strikeNote({ project, audio }, note, ask.owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, ask.loop) } }),
...(from > 0 && { from }),
};
}
/** Where a change to a playing lands (#338): as soon as it can be heard, or on the next spot `quantize` names. */
function landing(quantize: Quantize, playing: HeardPlaying): LandingAt {
return ({ time, startedAt, soonest }) => {
if (quantize === 'now' || startedAt === null) return soonest;
const spots = quantizeSpots(quantize, playing.timeline.meter, playing.markers);
const next = nextSpot({ ...time, meter: playing.timeline.meter }, soonest - startedAt, spots);
return next === null ? soonest : startedAt + next;
};
}
/** The song playing numbered `playback` as it is heard now; null for none, or a pattern. */
function heardSong(project: Project, audio: AudioSink, playback: number): HeardPlaying | null {
if (!(playback > 0)) return null;
return heardPlayings(project, audio).find((p) => p.id === playback && p.kind === 'song') ?? null;
}
/** Turns a track of a playing song (#338); where it plays its bake there is nothing to turn, and the log says so. */
function setSongTrack(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
world: World,
playback: number,
track: string,
volumeDb: number,
fade: number,
quantize: Quantize,
): void {
const playing = heardSong(project, audio, playback);
if (!playing || track === '') return;
const db = Number.isFinite(volumeDb) ? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, volumeDb)) : 0;
const done = audio.setSongTrack?.(
playback,
trackBus(playing.asset, track),
dbToGain(db),
fade,
landing(quantize, playing),
);
if (done === 'baked') {
const name = findAssetOf(project, playing.asset, 'song')?.name ?? playing.asset;
world.log.push(`Set song track: song "${name}" plays its bake; switch on "live tracks" in Play song`);
}
}
/**
* The lane `lane` names of a track of the song playing numbered `playback` (#338), with what its bus
* goes by; null where nothing of it plays or the song has no such track. A track without that lane
* is logged by `node`.
*/
function songLaneOf(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
world: World,
node: string,
playback: number,
track: string,
lane: string,
): { playing: HeardPlaying; song: string; bus: string; lane: AutomationLane } | null {
const playing = heardSong(project, audio, playback);
const song = playing && findAssetOf(project, playing.asset, 'song');
const held = song?.tracks.find((t) => t.id === track);
if (!playing || !song || !held) return null;
const target = targetNamed(lane);
const found = target && laneOf(held, target);
if (!found) {
world.log.push(`${node}: track "${held.name}" of song "${song.name}" has no lane "${lane}"`);
return null;
}
return { playing, song: song.name, bus: trackBus(song.id, track), lane: found };
}
/** The log of a song played from its bake, which has no lanes to steer (#338). */
function bakedLane(world: World, node: string, song: string): void {
world.log.push(`${node}: song "${song}" plays its bake; switch on "live tracks" in Play song`);
}
/** Holds a lane of a track of a playing song on `value` (#338), held to its range. */
function setSongLane(
sources: Pick<ContextSources, 'project' | 'audio'>,
world: World,
playback: number,
track: string,
lane: string,
value: number,
fade: number,
quantize: Quantize,
): void {
const found = songLaneOf(sources, world, 'Set song lane', playback, track, lane);
if (!found) return;
const { min, max } = laneRange(found.lane);
const held = Math.max(min, Math.min(max, Number.isFinite(value) ? value : 0));
const done = sources.audio.holdSongLane?.(
playback,
found.bus,
found.lane.target,
held,
fade,
landing(quantize, found.playing),
);
if (done === 'baked') bakedLane(world, 'Set song lane', found.song);
}
/** Lets a held lane of a track of a playing song go (#338), back to its curve over `fade` seconds. */
function releaseSongLane(
sources: Pick<ContextSources, 'project' | 'audio'>,
world: World,
playback: number,
track: string,
lane: string,
fade: number,
): void {
const found = songLaneOf(sources, world, 'Release song lane', playback, track, lane);
if (!found) return;
const done = sources.audio.releaseSongLane?.(playback, found.bus, found.lane.target, fade);
if (done === 'baked') bakedLane(world, 'Release song lane', found.song);
}
/**
* Plays another song, or the same one from a marker (#338), in place of a playing song: asked for
* as that one was, where `quantize` lands for the one heard now. A marker the song does not have is
* logged, and nothing changes.
*/
function switchSong(
sources: Pick<ContextSources, 'project' | 'audio'>,
world: World,
playback: number,
songId: string,
markerId: string,
quantize: Quantize,
fade: number,
): void {
const { project, audio } = sources;
const playing = heardSong(project, audio, playback);
if (!playing) return;
const target = songId || playing.asset;
const song = findAssetOf(project, target, 'song');
if (!song) return;
const marker = markerId ? heldSongMarkers(song.markers).find((m) => m.id === markerId) : undefined;
if (markerId && !marker) {
const named = findSongMarker(project, markerId)?.name ?? markerId;
world.log.push(`Switch song: song "${song.name}" has no marker "${named}"`);
return;
}
const ask = songAsks.get(audio)?.get(playback) ?? {
volume: 1,
fadeIn: 0,
loop: true,
owner: '',
fx: '',
fxParams: {},
bus: '',
live: false,
};
const play = songPlay(sources, target, ask, marker?.tick ?? 0);
if (play) audio.switchSequence?.(playback, songVoiceName(target), play, landing(quantize, playing), fade);
}
/**
@@ -491,8 +793,15 @@ export function strikeNote(
duration: note.duration,
};
const timing = { at: note.at, tempo: note.tempo, ...(note.bus && { bus: note.bus }) };
const fx = fxVoiceOf(sources.project, instrumentFx(sources.project, note.instrument, ''), note.tempo);
return playNote(sources, note.instrument, struck, owner, {}, timing, fx);
// What the lanes of its track start it with (#337) reaches its instrument and its sound FX alike.
const params = note.params ?? {};
const fx = fxVoiceOf(
sources.project,
instrumentFx(sources.project, note.instrument, ''),
note.tempo,
params,
);
return playNote(sources, note.instrument, struck, owner, params, timing, fx);
}
/** A clip of a song (#334): its sound at its own volume times the item's, through its sound FX. */
@@ -509,7 +818,7 @@ export function playClip(
return audio.playClip(
playedSoundOf(ref),
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
clipFx(project, clip.fx, clip.tempo),
clipFx(project, clip.fx, clip.tempo, clip.params),
);
}
+21
View File
@@ -11,6 +11,7 @@ import { parentChain } from '../../core/model/walk';
import type { Entity } from '../Entity';
import type { AnimationSystem } from './AnimationSystem';
import type { CompositionSystem } from './CompositionSystem';
import type { VideoSystem } from './VideoSystem';
import type { Input } from './Input';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
@@ -250,6 +251,26 @@ export function compositionFinishedPulses(
});
}
/**
* A behavior hears the videos of the sprites at its owner or below it, and those it steered itself
* wherever they stand – the rule of `On animation finished`. Video names the video, or one clip of it.
*/
export function videoFinishedPulses(instance: Instance, videos: VideoSystem | null): Pulse[] {
const owner = instance.entity.parent;
const finished = (videos?.finished ?? []).filter(
({ entity, steeredBy }) =>
!owner ||
steeredBy.includes(instance.entity) ||
[...parentChain(entity, (e) => e.parent)].includes(owner),
);
return instance.interpreter.eventNodes('OnVideoFinished').flatMap((node) => {
const wanted = textOf(node.params.video ?? '');
return finished
.filter(({ videoId, ref }) => wanted === '' || wanted === videoId || wanted === ref)
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
});
}
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
const outputs: Record<string, Value> = {
+201
View File
@@ -0,0 +1,201 @@
/**
* What a game hears of its music (#338): the songs and patterns playing, each with its time, and the
* beats, bars and markers each has passed since the frame before – what `On beat`, `On bar`,
* `On song marker` and `Song position` go by. A composition's playings are none of them.
*/
import { asNumber, type GraphNode, type Value } from '../../core/graph';
import { textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import {
heardPosition,
type HeardPosition,
newestPassed,
passedBar,
type PassedBeat,
type PassedMarker,
passedBeat,
passedMarkers,
type Timeline,
} from '../../core/music/beats';
import { heldSongMarkers, type SongMarker } from '../../core/music/markers';
import { patternMeter } from '../../core/music/pattern';
import { songMeter } from '../../core/music/song';
import { COMMON_TIME } from '../../core/music/time';
import type { AudioSink } from './Audio';
import type { Instance, Pulse } from './behaviorPulses';
/** A playing of a song or a pattern, as a game follows it. */
export interface HeardPlaying {
id: number;
/** Told apart from the playing of its number a Switch song took over from (#338). */
key: number;
kind: 'song' | 'pattern';
/** The id of the song or pattern. */
asset: string;
timeline: Timeline;
/** Seconds into its playing time it is heard now; null before it starts. */
heard: number | null;
markers: readonly SongMarker[];
}
/** The song or pattern a playing stands for, by its name (`song:<id>`); null for anything else. */
export function playedAsset(name: string): { kind: 'song' | 'pattern'; asset: string } | null {
const split = name.indexOf(':');
const kind = name.slice(0, split);
return kind === 'song' || kind === 'pattern' ? { kind, asset: name.slice(split + 1) } : null;
}
/** The playings of songs and patterns, oldest first. */
export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[] {
return (audio.playingTimes?.() ?? []).flatMap((time): HeardPlaying[] => {
const played = playedAsset(time.name);
if (!played) return [];
const { kind, asset: id } = played;
const song = kind === 'song' ? findAssetOf(project, id, 'song') : undefined;
const pattern = kind === 'pattern' ? findAssetOf(project, id, 'pattern') : undefined;
const meter = song ? songMeter(song.props) : pattern ? patternMeter(pattern.props) : COMMON_TIME;
const { tempo, from, loop, length } = time;
return [
{
id: time.id,
key: time.key,
kind,
asset: id,
timeline: { tempo, meter, from, loop, length },
heard: time.heard,
markers: song ? heldSongMarkers(song.markers) : [],
},
];
});
}
/** What a playing passed since the frame before. */
export interface MusicPassed {
playing: HeardPlaying;
beat: PassedBeat | null;
bar: PassedBeat | null;
markers: PassedMarker[];
}
/**
* Follows the playings from frame to frame: each frame what each passed since the last one. One
* first seen is told what it passed no more than the limit ago, its first beat among it – so is one
* a Switch song started in place of another, under the same number.
*/
export class MusicFollower {
private before = new Map<number, number>();
follow(playings: readonly HeardPlaying[]): MusicPassed[] {
const next = new Map<number, number>();
const passed = playings.map((playing) => {
const { key, heard, timeline } = playing;
const before = this.before.get(key) ?? null;
if (heard === null) return { playing, beat: null, bar: null, markers: [] };
next.set(key, heard);
return {
playing,
beat: passedBeat(timeline, before, heard),
bar: passedBar(timeline, before, heard),
markers: passedMarkers(timeline, before, heard, playing.markers),
};
});
this.before = next;
return passed;
}
}
/** The playing a node's "playback" input names; null where it is not connected. */
export type PlaybackOf = (instance: Instance, node: GraphNode) => Value;
/** Whether a node hears that playing: by its number, else by the song and the pattern picked. */
export function hears(
node: GraphNode,
wanted: Value,
playing: Pick<HeardPlaying, 'id' | 'kind' | 'asset'>,
keys: readonly string[],
): boolean {
if (wanted !== null) return asNumber(wanted) === playing.id;
const picked = keys.map((key) => ({ key, id: textOf(node.params[key] ?? '') })).filter((p) => p.id !== '');
return picked.length === 0 || picked.some((p) => p.key === playing.kind && p.id === playing.asset);
}
export function beatPulses(
instance: Instance,
passed: readonly MusicPassed[],
playbackOf: PlaybackOf,
): Pulse[] {
return instance.interpreter.eventNodes('OnBeat').flatMap((node) => {
const wanted = playbackOf(instance, node);
return passed.flatMap(({ playing, beat }) =>
beat && hears(node, wanted, playing, ['song', 'pattern'])
? [
{
instance,
node,
outputs: { playback: playing.id, bar: beat.bar, beat: beat.beat, late: beat.late },
},
]
: [],
);
});
}
export function barPulses(
instance: Instance,
passed: readonly MusicPassed[],
playbackOf: PlaybackOf,
): Pulse[] {
return instance.interpreter.eventNodes('OnBar').flatMap((node) => {
const wanted = playbackOf(instance, node);
return passed.flatMap(({ playing, bar }) =>
bar && hears(node, wanted, playing, ['song', 'pattern'])
? [{ instance, node, outputs: { playback: playing.id, bar: bar.bar, late: bar.late } }]
: [],
);
});
}
/** Of the markers a playing passed, the last spot of those the node hears: every marker on it. */
export function markerPulses(
instance: Instance,
passed: readonly MusicPassed[],
playbackOf: PlaybackOf,
): Pulse[] {
return instance.interpreter.eventNodes('OnSongMarker').flatMap((node) => {
const wanted = playbackOf(instance, node);
const marker = textOf(node.params.marker ?? '');
return passed.flatMap(({ playing, markers }) => {
if (markers.length === 0 || !hears(node, wanted, playing, ['song'])) return [];
const heard = newestPassed(markers.filter((m) => marker === '' || m.id === marker));
return heard.map((m) => ({
instance,
node,
outputs: { playback: playing.id, marker: m.id, name: m.name, late: m.late },
}));
});
});
}
/**
* Where the playing a `Song position` asks for stands: the one numbered `playback`, else the one
* started last of the song or pattern picked, of every one where none is.
*/
export function positionOf(
playings: readonly HeardPlaying[],
playback: number | null,
song: string,
pattern: string,
): HeardPosition | null {
const picked = [
...(song ? [{ kind: 'song', id: song }] : []),
...(pattern ? [{ kind: 'pattern', id: pattern }] : []),
];
const fits = (p: HeardPlaying) =>
playback !== null
? p.id === playback
: picked.length === 0 || picked.some((pick) => pick.kind === p.kind && pick.id === p.asset);
const playing = playings
.filter(fits)
.reduce<HeardPlaying | null>((last, p) => (!last || p.id > last.id ? p : last), null);
return playing ? heardPosition(playing.timeline, playing.heard) : null;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* What a run played and heard the end of (#373): the sounds of `Play sound` and the playings of
* `Play pattern` and `Play song`, each told once – what `On sound finished` and `On music finished`
* go by. A run keeps one through its scenes, so a song that outlives a scene is heard ending in the
* next; what another run or a tab of the editor played is none of its business.
*/
import { asNumber, type GraphNode } from '../../core/graph';
import { textOf } from '../../core/model/Properties';
import type { EndedPlay } from './Audio';
import type { Instance, Pulse } from './behaviorPulses';
import { hears, type PlaybackOf, playedAsset } from './musicPulses';
/** A sound or a playing of this run that ended, and whether a stop ended it. */
export interface Finished {
id: number;
kind: 'sound' | 'song' | 'pattern';
/** The sound, song or pattern: of a playing the one heard last, after a Switch song too. */
asset: string;
stopped: boolean;
}
export class PlayedEnds {
/** What each number this run played stands for: its sound, `''` for a playing. */
private readonly played = new Map<number, string>();
sound(voice: number, soundId: string): void {
if (voice > 0) this.played.set(voice, soundId);
}
playing(id: number): void {
if (id > 0) this.played.set(id, '');
}
/** Of `ended`, those this run played and not told yet, oldest first. */
follow(ended: readonly EndedPlay[]): Finished[] {
return ended.flatMap(({ id, name, stopped }): Finished[] => {
const sound = this.played.get(id);
if (sound === undefined) return [];
this.played.delete(id);
if (sound !== '') return [{ id, kind: 'sound', asset: sound, stopped }];
const played = playedAsset(name);
return played ? [{ id, ...played, stopped }] : [];
});
}
}
export function musicFinishedPulses(
instance: Instance,
finished: readonly Finished[],
playbackOf: PlaybackOf,
): Pulse[] {
return instance.interpreter.eventNodes('OnMusicFinished').flatMap((node) => {
const wanted = playbackOf(instance, node);
return finished.flatMap(({ id, kind, asset, stopped }) =>
kind !== 'sound' && hears(node, wanted, { id, kind, asset }, ['song', 'pattern'])
? [{ instance, node, outputs: { playback: id, stopped } }]
: [],
);
});
}
/** By its number where `voice` is connected, else by the Sound picked, every one where none is. */
export function soundFinishedPulses(
instance: Instance,
finished: readonly Finished[],
voiceOf: PlaybackOf,
): Pulse[] {
return instance.interpreter.eventNodes('OnSoundFinished').flatMap((node: GraphNode) => {
const wanted = voiceOf(instance, node);
const sound = textOf(node.params.sound ?? '');
return finished.flatMap(({ id, kind, asset, stopped }) =>
kind === 'sound' && (wanted !== null ? asNumber(wanted) === id : sound === '' || sound === asset)
? [{ instance, node, outputs: { voice: id, stopped } }]
: [],
);
});
}
+321 -29
View File
@@ -22,6 +22,9 @@ import { BAKE_RATE } from '../core/music/bake';
import {
type BakePlay,
type BusLevel,
CUT,
type HeardTime,
type LandingAt,
Playbacks,
type SequenceNeeds,
type SequencePlay,
@@ -30,7 +33,13 @@ import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
import type { SendTap } from '../core/graph/Graph';
import { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer';
import { limiterLatency } from '../core/soundfx/dynamics';
import type { ScheduledClip, ScheduledNote, ScheduledRamp } from '../core/music/Scheduler';
import { type AutomationTarget, paramOf, reaches } from '../core/music/automation';
import type {
ScheduledAutomation,
ScheduledClip,
ScheduledNote,
ScheduledRamp,
} from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
import { playedPlan, type StreamedFile, type StreamPlan } from '../core/music/streams';
@@ -38,7 +47,13 @@ import { STREAM_RATE } from '../core/music/streamQueue';
import { type CutRange, cutChannels, cutRange, fadeGain, parseCutName } from '../core/model/sound';
import { shifterLatency } from '../core/soundfx/pitchPlayer';
import { REVERB_MAX_DECAY } from '../core/soundfx/reverb';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import {
type AudioSink,
type ClipTiming,
ENDED_KEPT,
type EndedPlay,
type NoteTiming,
} from '../runtime/systems/Audio';
import type { AcidOptions, ChipOptions, PitchOptions, StreamOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
import {
@@ -48,11 +63,13 @@ import {
FADE,
type Follow,
followCurve,
followMarks,
fadeOut,
gainNode,
GLIDE,
glideKnobs,
heldBack,
holdParam,
type Knob,
levelsOf,
move,
@@ -190,6 +207,8 @@ interface Voice {
released: boolean;
/** Seconds a stop that lets it ring fades it out over: a bake (#340), whose notes are one sound. */
ringFade: number;
/** A stop reached it, or another took its place (#373), rather than it ending by itself. */
stopped: boolean;
}
/**
@@ -276,6 +295,8 @@ interface Pending {
bus: string;
fx: Layered;
timed: { params: FxParams; time: number; ease: EaseShare }[];
/** The marks of parameter lanes (#337) planned for it meanwhile, taken up once it starts. */
lanes: { param: string; automation: ScheduledAutomation }[];
/** Stopped by its number before it started. */
stopped: boolean;
/** A note released before it started: it starts and is released at once, so a one-shot is heard. */
@@ -354,6 +375,8 @@ export class SoundLibrary implements AudioSink {
private readonly failed = new Set<string>();
private voices: Voice[] = [];
private pending: Pending[] = [];
/** The knobs a parameter's lane (#337) has planned for: the next window ramps on from there. */
private readonly followed = new WeakSet<Knob>();
/**
* The last note of each Acid (#421), by who plays it and the Acid: when it starts, until when it
* is held, its frequency and voice, and what cuts it short – each of the notes that started then.
@@ -406,8 +429,13 @@ export class SoundLibrary implements AudioSink {
level: (id, gain, at, seconds) => this.level(id, gain, at, seconds),
after: (at, then) => void this.after(at, then),
closed: () => this.sweep(),
ended: (id, name, stopped) => this.keepEnd({ id, name, stopped }),
playBaked: (id, bake, at) => this.playBaked(id, bake, at),
});
/** The voices of `play` and the playings that ended lately, oldest first (#373). */
private readonly ends: EndedPlay[] = [];
/** The voices of `play` whose end is still to be told. */
private readonly told = new Set<number>();
/** The playings whose buses are still there: a master each, and the tracks of a song into it. */
private readonly groups = new Set<number>();
/** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */
@@ -486,13 +514,22 @@ export class SoundLibrary implements AudioSink {
/**
* The number is handed out at once, before the bytes are there: a graph can tune a voice in the
* same frame it asked for it, and the voice then starts with those values.
* same frame it asked for it, and the voice then starts with those values. `at` starts it at that
* second of the audio clock (#338); bytes that come later start it then.
*/
play(file: string, volume: number, loop: boolean, owner = '', fx: FxVoice = DRY_VOICE, bus = ''): number {
play(
file: string,
volume: number,
loop: boolean,
owner = '',
fx: FxVoice = DRY_VOICE,
bus = '',
at = 0,
): number {
if (file === '' || this.hasFailed(file)) return 0;
const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO };
const stream = this.streamed(file);
return this.begin({
const id = this.begin({
id: 0,
file,
files: [file],
@@ -501,10 +538,12 @@ export class SoundLibrary implements AudioSink {
owner,
fx: layered,
duration: 0,
at: 0,
at,
bus,
...(stream && { stream }),
});
this.told.add(id);
return id;
}
/**
@@ -573,14 +612,43 @@ export class SoundLibrary implements AudioSink {
/**
* Sets the level and the pan of a track of a song (#334) in a quick glide, so a change reaches
* what is sounding on it already. A bus is made on its first use and kept.
* what is sounding on it already. A bus is made on its first use and kept. What is `automated`
* (#337) glides there once, and is its lane's from then on.
*/
setBus(name: string, gain: number, pan: number): void {
setBus(name: string, gain: number, pan: number, automated: readonly AutomationTarget[] = []): void {
const bus = this.bus(name);
const now = this.audio().currentTime;
bus.set = { gain: Math.max(0, gain), pan: Math.max(-1, Math.min(1, pan)) };
bus.gain.gain.setTargetAtTime(Math.max(0, gain), now, GLIDE / 3);
bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3);
const glide = (target: AutomationTarget, param: AudioParam, value: number) => {
const ruled = automated.includes(target);
// Its lane takes it from here (#337); one let go hands it back from where the lane left it.
if (ruled && bus.automated.has(target)) return;
if (!ruled && bus.automated.has(target)) holdParam(param, now);
param.setTargetAtTime(value, now, GLIDE / 3);
};
glide('volume', bus.gain.gain, bus.set.gain);
glide('pan', bus.pan.pan, bus.set.pan);
bus.automated = new Set(automated);
}
/**
* A bus taken along its automation over one window (#337): ramped to each mark, or jumped there.
* A fresh start drops what was planned for it from its first mark on.
*/
automateBus(name: string, automation: ScheduledAutomation): void {
const named = paramOf(automation.target);
if (named !== null) return this.automateParam(name, named, automation);
const bus = this.buses.get(name);
const first = automation.marks[0];
if (!bus || !first) return;
const param = automation.target === 'volume' ? bus.gain.gain : bus.pan.pan;
const now = this.audio().currentTime;
if (automation.fresh) holdParam(param, Math.max(now, first.at));
for (const mark of automation.marks) {
const at = Math.max(now, mark.at);
if (mark.jump) param.setValueAtTime(mark.value, at);
else param.linearRampToValueAtTime(mark.value, at);
}
}
/**
@@ -590,7 +658,7 @@ export class SoundLibrary implements AudioSink {
*/
async setBuses(levels: readonly BusLevel[]): Promise<void> {
const waits: Promise<void>[] = [];
for (const { name, gain, pan, into, sends, fx } of levels) {
for (const { name, gain, pan, into, sends, fx, automated } of levels) {
if (into !== undefined) this.bus(into);
const bus = this.bus(name, into ?? null);
if (sends)
@@ -599,12 +667,76 @@ export class SoundLibrary implements AudioSink {
sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })),
);
else this.route(name, bus, into ?? null);
this.setBus(name, gain, pan);
this.setBus(name, gain, pan, automated);
waits.push(this.setFx(bus, fx ?? null));
this.keepParams(bus, fx ?? null, automated ?? []);
}
await Promise.all(waits);
}
/**
* A parameter taken along its lane over one window (#337): on the sound FX of the track's bus and of
* every bus inside it, and on every voice heard inside it – playing, or still waiting on its bytes.
* Each mark is read through the chain, so a knob that is not the parameter itself follows too; one
* without a parameter of that name stays where it is.
*/
private automateParam(name: string, param: string, automation: ScheduledAutomation): void {
if (automation.marks.length === 0) return;
const now = this.audio().currentTime;
for (const [busName, bus] of this.buses) {
const fx = bus.fx;
if (!fx || !reaches(name, busName)) continue;
const values = automation.marks.map((mark) => ({ ...fx.params, [param]: mark.value }));
if (bus.chain) {
const levels = values.map((params) => levelsOf(fxChainOf(fx.plan, params, fx.tempo)));
followMarks(bus.chain.knobs, levels, automation.marks, now, automation.fresh, this.followed);
}
bus.fx = { ...fx, params: values.at(-1)! };
}
for (const voice of this.voices)
if (voice.bus !== '' && reaches(name, voice.bus)) this.followVoice(voice, param, automation);
for (const waiting of this.pending)
if (waiting.bus !== '' && reaches(name, waiting.bus)) waiting.lanes.push({ param, automation });
}
/**
* A voice along a parameter's lane: a note's instrument and its own sound FX (`fx:`, see
* `strikeNote`) alike, as `Set sound FX parameters` reaches both.
*/
private followVoice(voice: Voice, param: string, automation: ScheduledAutomation): void {
const { layers, tempo } = voice.fx;
const values = automation.marks.map((mark) => ({
...voice.fx.params,
[param]: mark.value,
[`fx:${param}`]: mark.value,
}));
const levels = values.map((params) => {
const out: number[] = [];
for (const plan of layers) levelsOf(fxChainOf(plan, params, tempo), out);
return out;
});
const now = this.audio().currentTime;
followMarks(voice.knobs, levels, automation.marks, now, automation.fresh, this.followed);
voice.fx = { ...voice.fx, params: values.at(-1)! };
}
/**
* A sound FX set again as it was (#337): a parameter goes to what it is set to now – one a lane let
* go back to where it stands without one –, but one a lane rules is the lane's.
*/
private keepParams(bus: Bus, fx: FxVoice | null, automated: readonly AutomationTarget[]): void {
const held = bus.fx;
if (!fx || !held || held === fx || held.plan !== fx.plan) return;
const ruled = new Set(automated.flatMap((target) => paramOf(target) ?? []));
const params: Record<string, number> = { ...fx.params };
for (const key of ruled) if (held.params[key] !== undefined) params[key] = held.params[key];
const keys = new Set([...Object.keys(params), ...Object.keys(held.params)]);
if ([...keys].every((key) => params[key] === held.params[key])) return;
bus.fx = { ...held, params };
const levels = levelsOf(fxChainOf(held.plan, params, held.tempo));
if (bus.chain) glideKnobs(bus.chain.knobs, levels, this.audio().currentTime, 0, 'linear');
}
/**
* Lets these buses go once what is on them now has faded (#346): their chains would otherwise run
* on for nothing. One used again before then stays.
@@ -639,7 +771,8 @@ export class SoundLibrary implements AudioSink {
/**
* What each track's bus was last set to, with whether its sound FX is there and how late, and the
* level it has now, ramps and fades included (test hook).
* level it has now, ramps and fades included – `turned` what its input is at now, where a game turned
* it (#338) (test hook).
*/
busLevels(): {
name: string;
@@ -650,11 +783,13 @@ export class SoundLibrary implements AudioSink {
fx: boolean;
latency: number;
sends?: { to: string; tap: SendTap; gain: number; delay: number }[];
turned?: number;
}[] {
return [...this.buses].map(([name, bus]) => ({
name,
...bus.set,
level: bus.gain.gain.value,
...((bus.steer !== 1 || bus.input.gain.value !== 1) && { turned: bus.input.gain.value }),
into: bus.sends ? null : bus.into,
fx: bus.chain !== null,
latency: bus.chain?.latency ?? 0,
@@ -856,6 +991,9 @@ export class SoundLibrary implements AudioSink {
gain: context.createGain(),
pan: context.createStereoPanner(),
set: { gain: 1, pan: 0 },
steer: 1,
steerEnd: 0,
automated: new Set(),
into: target,
sends: null,
fx: null,
@@ -1233,23 +1371,123 @@ export class SoundLibrary implements AudioSink {
/**
* A sequence without notes or clips plays nothing, and is given no number. Each playing is heard
* through a master of its own, silent until it starts (#336): its level and its fades. The tracks
* of a song go into it, each set as it was given; a note or clip naming no track goes straight in.
* through a master of its own, silent until it starts (#336): its level and its fades.
*/
playSequence(name: string, play: SequencePlay, owner = ''): number {
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
const id = this.nextId++;
this.sequences.start(id, name, this.routed(id, play), owner);
return id;
}
/**
* Plays `play` in place of the playing numbered `id` (#338), from the second `at` names, as
* `Playbacks.switchTo` does: on buses of its own, its tracks turned as a game turned those of the
* same name, and owned by whoever owns the playing. Says whether there was such a playing.
*/
switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean {
const playings = this.sequences.named(id);
const from = playings.find((playing) => playing.until === null) ?? playings[0];
if (!from || (play.sequence.notes.length === 0 && !play.sequence.clips?.length)) return false;
const key = this.nextId++;
const routed = this.routed(key, play);
for (const [busName, bus] of this.buses) {
const track = trackOf(from.key, busName);
const into = track === null ? undefined : this.buses.get(groupBus(key, track));
if (!into) continue;
into.steer = bus.steer;
into.input.gain.value = bus.steer;
}
this.sequences.switchTo(id, key, name, routed, from.owner, at, fade);
return true;
}
/** Where a stinger in time with the playing numbered `id` starts (#338), see `Playbacks.landOn`. */
landOn(id: number, at: LandingAt): number | null {
return this.sequences.landOn(id, at);
}
/**
* Turns one track of a playing (#338) to `gain` over `fade` seconds from the second `at` names, on
* top of its own level: the one heard now, and one waiting to take over from it alike. A bake has
* no tracks to turn.
*/
setSongTrack(
id: number,
track: string,
gain: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none' {
const clock = this.sequences.clockOf(id);
if (!clock) return 'none';
if (clock.baked) return 'baked';
const when = Math.max(this.audio().currentTime, at(clock));
let found = false;
for (const playing of this.sequences.named(id)) {
const bus = this.buses.get(groupBus(playing.key, track));
if (!bus) continue;
found = true;
const from = bus.steer;
bus.steer = Math.max(0, gain);
// Where the last turn is through by then, nothing is planned past `when`, and Chrome's
// cancelAndHoldAtTime holds nothing: the ramp would run from that turn on, seconds early.
if (when < bus.steerEnd) holdParam(bus.input.gain, when);
else bus.input.gain.setValueAtTime(from, when);
bus.steerEnd = when + Math.max(CUT, fade);
bus.input.gain.linearRampToValueAtTime(bus.steer, bus.steerEnd);
}
return found ? 'set' : 'none';
}
/**
* Holds the lane of `target` of one track of a playing (#338) on `value`, in its units, from the
* second `at` names on, reached over `fade` seconds, see `Playbacks.holdLane`. A bake has no lanes.
*/
holdSongLane(
id: number,
track: string,
target: AutomationTarget,
value: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none' {
const clock = this.sequences.clockOf(id);
if (!clock) return 'none';
if (clock.baked) return 'baked';
const when = Math.max(this.audio().currentTime, at(clock));
return this.sequences.holdLane(id, track, target, value, when, fade) ? 'set' : 'none';
}
/** Lets a held lane of one track of a playing go as soon as can be heard (#338), back to its curve over `fade` seconds. */
releaseSongLane(
id: number,
track: string,
target: AutomationTarget,
fade: number,
): 'set' | 'baked' | 'none' {
const clock = this.sequences.clockOf(id);
if (!clock) return 'none';
if (clock.baked) return 'baked';
return this.sequences.releaseLane(id, track, target, clock.soonest, fade) ? 'set' : 'none';
}
/**
* The buses a playing is heard through (#336), `key` its master's: its tracks into it, each set as
* it was given; a note or clip naming no track goes straight in. Silent until it starts.
*/
private routed(key: number, play: SequencePlay): SequencePlay {
// A bake whose file could not be had is no bake: the notes are played instead (#340).
const { bake: asked, ...rest } = play;
const bake = asked && !this.hasFailed(asked.file) ? asked : null;
const buses = bake ? [] : (play.buses ?? []);
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends);
this.open(key, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends);
const tracks = new Set(buses.map((bus) => bus.name));
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
const into = (bus: string | undefined) => groupBus(key, bus !== undefined && tracks.has(bus) ? bus : '');
const { strike, strikeClip } = play;
const chains = [...buses.flatMap((bus) => bus.fx ?? []), ...(play.fx ? [play.fx] : [])];
const needs = bake ? { layers: [], files: [bake.file] } : play.needs;
const routed: SequencePlay = {
return {
...rest,
...(bake && { bake }),
needs: { ...needs, layers: [...needs.layers, ...chains.map((fx) => fx.plan)] },
@@ -1259,11 +1497,12 @@ export class SoundLibrary implements AudioSink {
}),
// Only a track's: the master of the playing is its level and its fades.
ramp: (ramp) => {
if (tracks.has(ramp.bus)) this.rampBus(groupBus(id, ramp.bus), ramp);
if (tracks.has(ramp.bus)) this.rampBus(groupBus(key, ramp.bus), ramp);
},
automate: (automation) => {
if (tracks.has(automation.bus)) this.automateBus(groupBus(key, automation.bus), automation);
},
};
this.sequences.start(id, name, routed, owner);
return id;
}
stopSequence(name: string, letRing = false, fade = 0): void {
@@ -1303,6 +1542,11 @@ export class SoundLibrary implements AudioSink {
}));
}
/** Every pattern and song playing, with how far into it is heard now (#338). */
playingTimes(): HeardTime[] {
return this.sequences.times();
}
/**
* What goes into the master of a playing, taken off before its level into `to` (#340): what a bake
* keeps. Says whether there was such a playing.
@@ -1356,6 +1600,7 @@ export class SoundLibrary implements AudioSink {
vocoders: 0,
released: false,
ringFade: BAKE_LET_RING,
stopped: false,
};
const going = new Set(voice.sources);
voice.finish = (source) => {
@@ -1556,7 +1801,7 @@ export class SoundLibrary implements AudioSink {
private sweep(): void {
if (this.groups.size === 0) return;
for (const id of [...this.groups]) {
if (!this.groups.has(id) || this.sequences.has(id)) continue;
if (!this.groups.has(id) || this.sequences.holds(id)) continue;
const on = (bus: string) => bus === groupBus(id, '') || bus.startsWith(`${groupBus(id, '')}:`);
if (this.voices.some((voice) => on(voice.bus))) continue;
if (this.pending.some((waiting) => !waiting.stopped && on(waiting.bus))) continue;
@@ -1660,6 +1905,7 @@ export class SoundLibrary implements AudioSink {
bus: start.bus ?? '',
fx: { ...start.fx, params: { ...start.fx.params } },
timed: [],
lanes: [],
stopped: false,
released: false,
};
@@ -1674,12 +1920,15 @@ export class SoundLibrary implements AudioSink {
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip);
if (silent || waiting.stopped) return;
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
const cancelled = waiting.stopped || this.cancelsFor(start.file, start.owner) !== askedAt;
if (silent || cancelled) return this.voiceEnded(id, start.file, true);
const started: Start = { ...asked, fx: waiting.fx };
if (stream?.failed) delete started.stream;
this.start(started);
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
const voice = this.voices.find((other) => other.id === id);
if (voice)
for (const { param, automation } of waiting.lanes) this.followVoice(voice, param, automation);
if (waiting.released) this.release(id);
});
return id;
@@ -1696,7 +1945,12 @@ export class SoundLibrary implements AudioSink {
return true;
}
const voice = this.voices.find((other) => other.id === id);
if (!voice) return this.sequences.has(id) && this.tuneMaster(id, params, time, ease);
// Every playing of that number (#338): one taking over from another is tuned alike.
if (!voice) {
const playings = this.sequences.named(id);
for (const playing of playings) this.tuneMaster(playing.key, params, time, ease);
return playings.length > 0;
}
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime;
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
@@ -1709,10 +1963,9 @@ export class SoundLibrary implements AudioSink {
* The sound FX of a pattern or song playing (#346), on its master; one still loading starts with
* the values. Says it was reached, whether or not it has a sound FX.
*/
private tuneMaster(id: number, params: FxParams, time: number, ease: EaseShare): boolean {
const master = this.buses.get(groupBus(id, ''));
private tuneMaster(key: number, params: FxParams, time: number, ease: EaseShare): void {
const master = this.buses.get(groupBus(key, ''));
if (master) this.tuneBus(master, params, time, ease);
return true;
}
private tuneBus(bus: Bus, params: FxParams, time: number, ease: EaseShare): void {
@@ -1762,7 +2015,10 @@ export class SoundLibrary implements AudioSink {
stopVoice(id: number, letRing = false): void {
this.sequences.stopId(id, letRing);
const waiting = this.pending.find((other) => other.id === id);
if (waiting) waiting.stopped = true;
if (waiting) {
waiting.stopped = true;
this.voiceEnded(id, waiting.file, true);
}
this.silence(
this.voices.filter((voice) => voice.id === id),
letRing,
@@ -2144,6 +2400,7 @@ export class SoundLibrary implements AudioSink {
private silence(going: Voice[], letRing = false): void {
this.stopped += going.filter((voice) => !(letRing && voice.ringing)).length;
for (const voice of going) {
voice.stopped = true;
if (letRing && voice.ringFade > 0) {
this.cut(voice, voice.ringFade);
continue;
@@ -2172,6 +2429,7 @@ export class SoundLibrary implements AudioSink {
for (const voice of [...older.filter((v) => v.released), ...older.filter((v) => !v.released)]) {
if (count <= MAX_VOCODERS) return;
count -= vocodersOf(voice);
voice.stopped = true;
this.cut(voice);
}
}
@@ -2255,6 +2513,24 @@ export class SoundLibrary implements AudioSink {
voice.quiet = null;
this.voices = this.voices.filter((other) => other !== voice);
if (voice.bus !== '') this.sweep();
this.voiceEnded(voice.id, voice.file, voice.stopped);
}
/** Keeps an end for `endings` (#373), letting the oldest go past `ENDED_KEPT`. */
private keepEnd(end: EndedPlay): void {
this.ends.push(end);
if (this.ends.length > ENDED_KEPT) this.ends.shift();
}
/** The end of a voice: kept where `play` started it, and only once. */
private voiceEnded(id: number, name: string, stopped: boolean): void {
if (this.told.delete(id)) this.keepEnd({ id, name, stopped });
}
endings(): readonly EndedPlay[] {
// A playing heard to its end goes once it is looked for.
this.sequences.current();
return this.ends;
}
/**
@@ -2389,6 +2665,7 @@ export class SoundLibrary implements AudioSink {
vocoders: wiring.vocoders,
released: false,
ringFade: 0,
stopped: false,
};
const going = new Set(sources);
voice.finish = (source) => {
@@ -3021,6 +3298,12 @@ function groupBus(id: number, track: string): string {
return track === '' ? `play:${id}` : `play:${id}:${track}`;
}
/** The track a bus of the playing `key` is (#338); null for its master and any other bus. */
function trackOf(key: number, bus: string): string | null {
const head = `${groupBus(key, '')}:`;
return bus.startsWith(head) ? bus.slice(head.length) : null;
}
/**
* A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan,
* then the bus it goes into or straight out – or each of its sends (#350); `set` is what level and
@@ -3031,6 +3314,15 @@ interface Bus {
gain: GainNode;
pan: StereoPannerNode;
set: { gain: number; pan: number };
/**
* What a game turns a track of its playing to (#338), on its input: on top of its level, before its
* sound FX and every send, what goes into it from other tracks too.
*/
steer: number;
/** The second of the audio clock the last turn of `steer` is through at; 0 for none. */
steerEnd: number;
/** What a lane of automation sets instead (#337), where `setBus` leaves it alone. */
automated: Set<AutomationTarget>;
/** Where it goes as a whole while it has no sends; null straight out. */
into: string | null;
sends: BusSendNodes[] | null;
+15 -9
View File
@@ -35,7 +35,7 @@ import { CameraShade, shadedPicture } from './cameraShade';
import { snapPlacement } from './pixelGrid';
import { cameraSees } from '../core/model/renderLayers';
import { drivenVideoKey, isVideoKey } from '../core/model/video';
import { videoDrivenOf } from '../core/properties/videos';
import { videoDrivenOf, videoSteeredOf } from '../core/properties/videos';
import type { HeardVideo } from './SteadySound';
import { type VideoHearing, videoLibrary } from './VideoLibrary';
@@ -881,19 +881,25 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
// A video (#402) moves by itself like a Time shader does.
if (wornShader(item.props, shaders)?.time || (item.texture && isVideoKey(item.texture.file)))
this.animated = true;
const drawn = this.wearVideo(item);
let drawn = this.wearVideo(item);
let ghost = this.ghostSprites.get(item.id);
// A driven video (#404) is left out until it has its first picture: its plain colour flashed
// white at the start of a run and at a cut. So is one brought in while it still seeks, once the
// view has held its last frame long enough (`holdsForVideo`).
const key = drawn !== item ? (drawn.texture?.file ?? '') : null;
if (
let key = drawn !== item ? (drawn.texture?.file ?? '') : null;
const ready = (at: string) =>
videoLibrary.hasPicture(at) && (this.videosShown.get(item.id) === at || videoLibrary.showsAsked(at));
// A free video a behavior just took over (#410) shows the shared picture until its own player has one.
const bridged =
key !== null &&
!(
videoLibrary.hasPicture(key) &&
(this.videosShown.get(item.id) === key || videoLibrary.showsAsked(key))
)
) {
!ready(key) &&
videoSteeredOf(item.props) &&
videoLibrary.hasPicture(item.texture!.file);
if (bridged) {
drawn = item;
key = null;
}
if (key !== null && !ready(key)) {
sprite.visible = false;
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
+46 -8
View File
@@ -620,6 +620,18 @@ export function fadeOut(param: AudioParam, now: number, seconds = FADE): void {
param.linearRampToValueAtTime(0, now + seconds);
}
/** Keeps a level where it is at `at` and drops what was planned for it after that. */
export function holdParam(param: AudioParam, at: number): void {
// Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand.
const held = param as Omit<AudioParam, 'cancelAndHoldAtTime'> &
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at);
else {
param.cancelScheduledValues(at);
param.setValueAtTime(param.value, at);
}
}
/**
* Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped
* first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio`
@@ -635,14 +647,7 @@ export function move(
ease: EaseShare = 'linear',
): void {
const from = param.value;
// Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand.
const held = param as Omit<AudioParam, 'cancelAndHoldAtTime'> &
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now);
else {
param.cancelScheduledValues(now);
param.setValueAtTime(from, now);
}
holdParam(param, now);
const logarithmic = ratio && from > 0 && to > 0;
if (time <= 0) param.setTargetAtTime(to, now, GLIDE);
else if (ease === 'step') param.setValueAtTime(to, now + time);
@@ -670,6 +675,39 @@ export function glideKnobs(
return moved;
}
/**
* Takes knobs along the marks of a parameter's lane (#337): `levels` are the knobs' levels at each
* mark, in the order of `marks`. A knob that stays where it is all the way is left alone. Each other
* one is ramped to its level at each mark, or jumped there; its first mark is a jump where nothing
* has been planned for it by a lane yet – `followed` says which have –, or where the playing has
* just started (`fresh`), which drops what was planned for it from there on.
*/
export function followMarks(
knobs: readonly Knob[],
levels: readonly (readonly number[])[],
marks: readonly { at: number; jump: boolean }[],
now: number,
fresh: boolean,
followed: WeakSet<Knob>,
): void {
knobs.forEach((knob, i) => {
const param = knob.param;
const way = levels.map((level) => level[i]);
if (!param || way.every((level) => level === undefined || level === knob.target)) return;
const restart = fresh || !followed.has(knob);
followed.add(knob);
marks.forEach((mark, m) => {
const level = way[m];
if (level === undefined) return;
const at = Math.max(now, mark.at);
if (m === 0 && fresh) holdParam(param, at);
if (mark.jump || (m === 0 && restart)) param.setValueAtTime(level, at);
else param.linearRampToValueAtTime(level, at);
});
knob.target = way.at(-1) ?? knob.target;
});
}
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
const CURVE_RATE = 200;
+1
View File
@@ -83,6 +83,7 @@ export class Music {
),
play: (note) => this.playOne(note),
playClip: (clip) => this.playClip(clip),
automate: (automation) => soundLibrary.automateBus(automation.bus, automation),
playing: (voice) => soundLibrary.playing(voice),
stop: (voices) => {
for (const voice of voices) soundLibrary.stopVoice(voice);
+37
View File
@@ -77,4 +77,41 @@ describe('AudioSwitch (#423)', () => {
expect(live.calls).toEqual(['sequence composition:c', 'stopPlayback 1']);
expect(aside.calls).toEqual(['sequence composition:c']);
});
it('lands a stinger only on the clock of the sink it plays in (#338)', () => {
const live = { ...sinkFrom(1, 5), landOn: () => 6 };
const aside = { ...sinkFrom(DIVERTED_IDS, 0.25), landOn: () => 0.5 };
const audio = new AudioSwitch(live);
const song = audio.playSequence('song:s', {} as never, '');
expect(audio.landOn(song, () => 0)).toBe(6);
audio.divert(aside);
// The song plays on live, the stinger would play diverted: the two clocks do not meet.
expect(audio.landOn(song, () => 0)).toBeNull();
const there = audio.playSequence('song:s', {} as never, '');
expect(audio.landOn(there, () => 0)).toBe(0.5);
});
it('tells what ended in both sinks, and asks each voice of the one that numbered it (#373)', () => {
const live = {
...sinkFrom(1),
endings: () => [{ id: 1, name: 'music', stopped: false }],
playing: (id: number) => id === 2,
};
const aside = {
...sinkFrom(DIVERTED_IDS),
endings: () => [{ id: DIVERTED_IDS, name: 'hit', stopped: true }],
playing: (id: number) => id === DIVERTED_IDS + 1,
};
const audio = new AudioSwitch(live);
expect(audio.endings().map((end) => end.id)).toEqual([1]);
audio.divert(aside);
expect(audio.endings().map((end) => end.id)).toEqual([1, DIVERTED_IDS]);
expect([2, 3, DIVERTED_IDS, DIVERTED_IDS + 1].map((id) => audio.playing(id))).toEqual([
true,
false,
false,
true,
]);
audio.restore();
expect(audio.playing(DIVERTED_IDS + 1)).toBe(false);
});
});
+310
View File
@@ -0,0 +1,310 @@
/**
* Automation of the parameters of a song's track (#337, stage 3): a lane `param:<name>` with a range
* of its own, the value each note and clip starts with, the sound FX of the track and its items set
* where the lane starts, and which parameters a track has to offer.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { findAssetOf } from '../../src/core/model/Project';
import { chipPresetGraph } from '../../src/core/instrument/chipPresets';
import {
type AutomationLane,
heldLanes,
laneMarks,
laneRange,
paramsAt,
reaches,
type SequenceLane,
} from '../../src/core/music/automation';
import {
type ScheduledClip,
type ScheduledNote,
Scheduler,
type SchedulerHost,
} from '../../src/core/music/Scheduler';
import { newItem, songSequence, trackBus } from '../../src/core/music/song';
import { songBuses } from '../../src/core/music/songBuses';
import { defaultParamRange, trackParams } from '../../src/core/music/trackParams';
import { strikeNote } from '../../src/runtime/systems/behaviorContext';
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { graphEditor, makeProject, type ProjectFixture } from '../helpers';
const Q = 480;
const BAR = 4 * Q;
describe('a lane of a parameter (#337)', () => {
it('is held to a name without a colon, its points to its own range, and kept without a point', () => {
expect(
heldLanes([
{
target: 'param:cut',
min: 100,
max: 5000,
points: [
{ tick: 0, value: 20000 },
{ tick: 10, value: 50 },
],
},
{ target: 'param:', points: [{ tick: 0, value: 1 }] },
{ target: 'param:a:b', points: [{ tick: 0, value: 1 }] },
{ target: 'param:mix', min: 1, max: 1, points: [{ tick: 0, value: 3 }] },
{ target: 'param:empty', min: -1, max: 1, points: [] },
{ target: 'param:bare', points: [] },
]),
).toEqual([
{
target: 'param:cut',
min: 100,
max: 5000,
points: [
{ tick: 0, value: 5000 },
{ tick: 10, value: 100 },
],
},
// A range that is none is the one a lane without a range has: 0 to 1.
{ target: 'param:mix', points: [{ tick: 0, value: 1 }] },
{ target: 'param:empty', min: -1, max: 1, points: [] },
{ target: 'param:bare', points: [] },
]);
expect(laneRange({ target: 'param:mix' })).toEqual({ min: 0, max: 1 });
expect(laneRange('volume')).toEqual({ min: -60, max: 12 });
});
it('reaches its track and every bus inside it, and starts what is heard there at its value', () => {
expect(reaches('song:s:t', 'song:s:t')).toBe(true);
expect(reaches('song:s:t', 'song:s:t:#items')).toBe(true);
expect(reaches('song:s:t', 'song:s:t2:#items')).toBe(false);
const lanes: SequenceLane[] = [
{
bus: 'song:s:t',
target: 'param:cut',
min: 0,
max: 10,
points: [
{ tick: 0, value: 0 },
{ tick: 100, value: 20 },
],
},
{ bus: 'song:s:t', target: 'volume', points: [{ tick: 0, value: -6 }] },
{ bus: 'song:s:u', target: 'param:other', points: [{ tick: 0, value: 1 }] },
];
expect(paramsAt(lanes, 'song:s:t:#items', 25)).toEqual({ cut: 5 });
// Past its range the way is held to it.
expect(paramsAt(lanes, 'song:s:t:item', 90)).toEqual({ cut: 10 });
expect(paramsAt(lanes, 'song:s:x:#items', 25)).toBeUndefined();
expect(paramsAt(lanes, undefined, 25)).toBeUndefined();
});
it('is read every step even where it runs straight, as a knob may not be the parameter itself', () => {
const lane: SequenceLane = {
bus: 'b',
target: 'param:cut',
min: 0,
max: 1000,
points: [
{ tick: 0, value: 0 },
{ tick: 960, value: 1000 },
],
};
const marks = laneMarks(lane, 0, null, 0, 480, true, 9.6);
expect(marks).toHaveLength(51);
// In the units of the parameter, not as a gain.
for (const mark of marks) expect(mark.value).toBeCloseTo((1000 * mark.at) / 960, 9);
const held = laneMarks({ ...lane, points: [{ tick: 0, value: 300 }] }, 0, null, 0, 480, true, 9.6);
expect(held).toEqual([
{ at: 0, value: 300, jump: true },
{ at: 480, value: 300, jump: false },
]);
});
});
describe('the scheduler and a parameter lane (#337)', () => {
it('starts each note and clip of the track at the value the lane has where it starts', () => {
const notes: ScheduledNote[] = [];
const clips: ScheduledClip[] = [];
let time = 0;
const host: SchedulerHost = {
now: () => time,
latency: () => 0,
play: (note) => (notes.push(note), 1),
playClip: (clip) => (clips.push(clip), 2),
playing: () => true,
stop: () => {},
wake: () => () => {},
};
const lane: SequenceLane = {
bus: 'song:s:t',
target: 'param:cut',
min: 0,
max: 1000,
points: [
{ tick: 0, value: 0 },
{ tick: 1000, value: 1000 },
],
};
const scheduler = new Scheduler(
host,
() => ({
notes: [
{ tick: 200, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t:#items' },
{ tick: 210, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:u:#items' },
],
clips: [{ tick: 100, length: 500, sound: 's', fx: '', offset: 0, volume: 1, bus: 'song:s:t:#items' }],
lanes: [lane],
}),
{ tempo: 120 },
);
scheduler.start(300);
time = 0.5;
scheduler.pump();
expect(notes).toEqual([]);
// Started inside the clip: it is taken up 200 ticks in, at the value there.
expect(clips.map((clip) => clip.params)).toEqual([{ cut: 300 }]);
scheduler.start(150);
time = 1;
scheduler.pump();
expect(notes.map((note) => [note.bus, note.params])).toEqual([
['song:s:t:#items', { cut: 200 }],
['song:s:u:#items', undefined],
]);
});
});
/** A sound FX "Filter" with a parameter `cut` (default 800) into its gain. */
function makeFilter(f: ProjectFixture): SoundFxAsset {
const command = new CreateAssetCommand('soundfx', 'Filter');
f.stack.execute(command);
const fx = command.asset as SoundFxAsset;
const edit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const gain = edit.addNode('SoundFxGain');
const output = edit.addNode('SoundFxOutput');
input.ports = [{ key: 'cut', kind: 'number', default: 800 }];
edit.connect(input, 'out', gain, 'in');
edit.connect(input, 'cut', gain, 'gain');
edit.connect(gain, 'out', output, 'in');
return fx;
}
/** A song "Tune" with a pattern of one note on its one track, played by a chip instrument with a parameter `tone`. */
function makeTune() {
const f = makeProject();
const song = new CreateAssetCommand('song', 'Tune');
const pattern = new CreateAssetCommand('pattern', 'Riff');
const chip = new CreateAssetCommand('instrument', 'Chip');
for (const command of [song, pattern, chip]) f.stack.execute(command);
const instrument = chip.asset as InstrumentAsset;
instrument.graph = chipPresetGraph('pickup', () => 0);
instrument.graph.nodes.push({
id: 'params',
type: 'InstrumentParameters',
x: 0,
y: 0,
params: {},
ports: [
{ key: 'tone', kind: 'number', default: 0.25 },
{ key: 'cut', kind: 'number', default: 5 },
],
});
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', instrument.id));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 }]),
);
const tune = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset;
const trackId = tune().tracks[0]!.id;
f.stack.execute(
new SetSongCommand(song.asset.id, {
items: [newItem({ track: trackId, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR })],
}),
);
const setTrack = (fields: Partial<SongAsset['tracks'][number]>) =>
f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, ...fields }] }));
return { ...f, tune, trackId, setTrack, instrument, patternId: pattern.asset.id };
}
describe('a song with a parameter lane (#337)', () => {
const cutLane: AutomationLane = {
target: 'param:cut',
min: 0,
max: 2000,
points: [
{ tick: 0, value: 400 },
{ tick: BAR, value: 1600 },
],
};
it('plays it on its track, and sets the sound FX of the track where it starts', () => {
const f = makeTune();
const fx = makeFilter(f);
f.setTrack({ fx: fx.id, automation: [cutLane] });
expect(songSequence(f.project, f.tune()).lanes).toEqual([
{ bus: trackBus(f.tune().id, f.trackId), ...cutLane },
]);
const [track] = songBuses(f.project, f.tune());
expect(track).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } });
// Without a point it rules nothing, and is not played.
f.setTrack({ automation: [{ ...cutLane, points: [] }] });
expect(songSequence(f.project, f.tune()).lanes).toBeUndefined();
expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated');
expect(songBuses(f.project, f.tune())[0]!.fx!.params).toEqual({});
});
it('sets the sound FX of a pattern item on the track the same way, which leaves it to the lane', () => {
const f = makeTune();
const fx = makeFilter(f);
const item = f.tune().items[0]!;
f.stack.execute(new SetSongCommand(f.tune().id, { items: [{ ...item, fx: fx.id }] }));
f.setTrack({ automation: [cutLane] });
const own = songBuses(f.project, f.tune()).find((bus) => bus.name.endsWith(item.id));
expect(own).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } });
});
it('offers the parameters of the track, its items and their instruments, each name once', () => {
const f = makeTune();
const fx = makeFilter(f);
expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([
{ name: 'tone', default: 0.25 },
{ name: 'cut', default: 5 },
]);
f.setTrack({ fx: fx.id });
// The track's own first: its `cut` is the one whose default is named.
expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([
{ name: 'cut', default: 800 },
{ name: 'tone', default: 0.25 },
]);
});
it('starts a new lane at a range round the default, on a round number', () => {
expect(defaultParamRange(0.25)).toEqual({ min: 0, max: 1 });
expect(defaultParamRange(800)).toEqual({ min: 0, max: 2000 });
expect(defaultParamRange(1.5)).toEqual({ min: 0, max: 5 });
expect(defaultParamRange(-3)).toEqual({ min: -10, max: 10 });
expect(defaultParamRange(Number.NaN)).toEqual({ min: 0, max: 1 });
});
it('hands a note its values for its instrument and its own sound FX alike', () => {
const f = makeTune();
const fx = makeFilter(f);
f.stack.execute(new SetAssetPropertyCommand(f.instrument.id, 'fx', fx.id));
const struck: { params: Record<string, number> }[] = [];
const audio: AudioSink = {
...SILENT_AUDIO,
playNote: (_name, _layers, params) => (struck.push({ params: { ...params } }), 1),
};
const note: ScheduledNote = {
at: 0,
duration: 0.1,
note: 60,
velocity: 1,
instrument: f.instrument.id,
tempo: 120,
params: { cut: 1200 },
};
expect(strikeNote({ project: f.project, audio }, note, '')).toBe(1);
expect(struck[0]!.params).toMatchObject({ cut: 1200, 'fx:cut': 1200 });
});
});
+403
View File
@@ -0,0 +1,403 @@
/**
* Automation of a song (#337): lanes of a track's volume and pan, their value along the song, and
* how the scheduler takes a bus along them on the audio clock – in the Song tab, in a game, in a
* composition and in a bake alike.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetCompositionCommand } from '../../src/core/commands/compositionCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { CompositionAsset, SongAsset } from '../../src/core/model/Asset';
import { curveLookup } from '../../src/core/model/curve';
import { findAssetOf } from '../../src/core/model/Project';
import {
type AutomationLane,
heldLanes,
laneMarks,
laneValueAt,
laneValueBefore,
type SequenceLane,
type SequenceLanePoint,
sequenceLaneOf,
unrolledLane,
} from '../../src/core/music/automation';
import { bakePrint } from '../../src/core/music/bake';
import { audioItemBus, compositionPlayings } from '../../src/core/music/compositionAudio';
import { dbToGain } from '../../src/core/music/level';
import {
LEAD,
type ScheduledAutomation,
Scheduler,
type SchedulerHost,
} from '../../src/core/music/Scheduler';
import { heldSongTracks, newItem, songSequence, trackBus } from '../../src/core/music/song';
import { songBuses } from '../../src/core/music/songBuses';
import { makeProject, roundTrip } from '../helpers';
const Q = 480;
const BAR = 4 * Q;
const pan = (points: SequenceLanePoint[]): SequenceLane => ({ bus: 'b', target: 'pan', points });
/** The marks with their ticks and values to a millionth, so a sum of floats reads as what it is. */
const rounded = (marks: { at: number; value: number; jump: boolean }[]) =>
marks.map(({ at, value, jump }) => [Math.round(at * 1e6) / 1e6, Math.round(value * 1e6) / 1e6, jump]);
describe('the lanes of a track (#337)', () => {
it('are held to a target each, points in whole ticks from 0 with values in range, in tick order', () => {
expect(
heldLanes([
{
target: 'pan',
points: [
{ tick: 960.4, value: 3 },
{ tick: 0, value: -0.5, ease: 'sine-in' },
{ tick: -1, value: 0 },
{ tick: 10, value: Number.NaN },
],
},
{ target: 'pan', points: [{ tick: 0, value: 1 }] },
{ target: 'volume', points: [] },
{ target: 'speed', points: [{ tick: 0, value: 1 }] },
'volume',
{
target: 'volume',
points: [
{ tick: 5, value: 0, ease: 'wobbly' },
{ tick: 0, value: -100, ease: 'linear' },
],
},
]),
).toEqual([
{
target: 'pan',
points: [
{ tick: 0, value: -0.5, ease: 'sine-in' },
{ tick: 960, value: 1 },
],
},
{
target: 'volume',
points: [
{ tick: 0, value: -60 },
{ tick: 5, value: 0 },
],
},
]);
expect(heldLanes('x')).toEqual([]);
});
it('are kept with their track when the project is saved, and a track without one has none', () => {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
f.stack.execute(create);
const song = () => findAssetOf(f.project, create.asset.id, 'song') as SongAsset;
const lane: AutomationLane = { target: 'volume', points: [{ tick: 0, value: -6, ease: 'expo-out' }] };
const [first] = song().tracks;
f.stack.execute(
new SetSongCommand(song().id, {
tracks: [
{ ...first!, automation: [lane] },
{ ...first!, id: 'plain', automation: [] },
],
}),
);
const saved = roundTrip(f.project).assets.find((a) => a.id === song().id) as SongAsset;
expect(saved.tracks.map((t) => t.automation)).toEqual([[lane], undefined]);
expect(Object.keys(heldSongTracks([{ id: 'x' }])[0]!)).not.toContain('automation');
});
it('runs from each point to the next along its easing, holds outside them, and jumps where two meet', () => {
const points: SequenceLanePoint[] = [
{ tick: 0, value: 0 },
{ tick: 100, value: 1, ease: 'step' },
{ tick: 200, value: 0, ease: 'sine-in' },
{ tick: 300, value: 1 },
{ tick: 300, value: -1 },
];
const at = (tick: number) => laneValueAt(points, tick);
const before = (tick: number) => laneValueBefore(points, tick);
expect([at(-5), at(50), at(100), before(100)]).toEqual([0, 0.5, 1, 1]);
// A step holds up to the next point, which it jumps to.
expect([at(150), before(200), at(200)]).toEqual([1, 1, 0]);
expect(at(250)).toBeCloseTo(1 - Math.cos(Math.PI / 4), 12);
expect(before(300)).toBeCloseTo(1, 12);
expect([at(300), at(400)]).toEqual([-1, -1]);
});
it('eases along a Curve asset it names, and linear once that is gone', () => {
const f = makeProject();
f.project.assets.push({
id: 'cv',
type: 'curve',
name: 'Knee',
props: {},
points: [
{ x: 0, y: 0 },
{ x: 0.5, y: 0.9 },
{ x: 1, y: 1 },
],
});
const lane: AutomationLane = {
target: 'pan',
points: [
{ tick: 0, value: 0, ease: 'curve:cv' },
{ tick: 100, value: 1 },
],
};
const curves = curveLookup(f.project);
expect(laneValueAt(sequenceLaneOf(lane, 'b', curves).points, 25)).toBeCloseTo(0.45, 9);
f.project.assets.pop();
expect(laneValueAt(sequenceLaneOf(lane, 'b', curveLookup(f.project)).points, 25)).toBe(0.25);
});
});
describe('a lane over a window of the scheduler (#337)', () => {
const sweep = pan([
{ tick: 0, value: -1 },
{ tick: 960, value: 1 },
]);
it('is one ramp where the bus can ramp along it: a straight pan', () => {
expect(rounded(laneMarks(sweep, 0, null, 0, 480, true, 9.6))).toEqual([
[0, -1, true],
[480, 0, false],
]);
// Going on, it starts where the last window ended, as a ramp.
expect(rounded(laneMarks(sweep, 0, null, 480, 600, false, 9.6))).toEqual([
[480, 0, false],
[600, 0.25, false],
]);
});
it('is read every step where the bus cannot: decibels in a gain, or an easing', () => {
const fade: SequenceLane = {
bus: 'b',
target: 'volume',
points: [
{ tick: 0, value: -60 },
{ tick: 960, value: 0 },
],
};
const marks = laneMarks(fade, 0, null, 0, 480, true, 9.6);
// 0 and 480, and the 49 readings between.
expect(marks).toHaveLength(51);
expect(marks[0]).toEqual({ at: 0, value: 0, jump: true });
for (const mark of marks) expect(mark.value).toBeCloseTo(dbToGain(-60 + (60 * mark.at) / 960), 12);
expect(marks.slice(1).every((mark, i) => !mark.jump && mark.at > marks[i]!.at)).toBe(true);
const eased = pan([
{ tick: 0, value: 0, ease: 'sine-in' },
{ tick: 100, value: 1 },
]);
expect(laneMarks(eased, 0, null, 0, 100, true, 25).map((mark) => mark.at)).toEqual([0, 25, 50, 75, 100]);
});
it('jumps back with the playhead at the end of a loop, and where a step lands', () => {
expect(rounded(laneMarks(sweep, 0, { start: 0, end: 960 }, 480, 1440, false, 9.6))).toEqual([
[480, 0, false],
[960, 1, false],
[960, -1, true],
[1440, 0, false],
]);
const step = pan([
{ tick: 0, value: -1, ease: 'step' },
{ tick: 100, value: 1 },
]);
expect(rounded(laneMarks(step, 0, null, 0, 200, true, 9.6))).toEqual([
[0, -1, true],
[100, 1, true],
[200, 1, false],
]);
});
});
describe('a lane round the loop of a song inside a composition (#337)', () => {
it('is cut at the loop end and starts again there, each round on from where it was', () => {
const points: SequenceLanePoint[] = [
{ tick: 0, value: -1 },
{ tick: 960, value: 1 },
];
const unrolled = unrolledLane(points, { start: 0, end: 960 }, 480, 1440, 9.6);
expect(unrolled).toEqual([
{ tick: 480, value: 0 },
{ tick: 960, value: 1 },
{ tick: 960, value: -1 },
{ tick: 1440, value: 0 },
]);
expect(laneValueAt(unrolled, 700)).toBeCloseTo(laneValueAt(points, 700), 12);
expect(laneValueAt(unrolled, 1200)).toBeCloseTo(laneValueAt(points, 240), 12);
});
it('keeps a whole easing as it is, and reads one cut in the middle every step', () => {
const eased: SequenceLanePoint[] = [
{ tick: 0, value: 0, ease: 'sine-in' },
{ tick: 960, value: 1 },
];
expect(unrolledLane(eased, null, 0, 1920, 96)).toEqual([
{ tick: 0, value: 0, ease: 'sine-in' },
{ tick: 960, value: 1 },
{ tick: 1920, value: 1 },
]);
const cut = unrolledLane(eased, null, 480, 960, 96);
expect(cut.map((p) => p.tick)).toEqual([480, 576, 672, 768, 864, 960]);
for (const point of cut) {
expect(point.ease).toBeUndefined();
expect(point.value).toBeCloseTo(laneValueAt(eased, point.tick), 12);
}
});
});
/** A song "Tune" with a one-bar pattern of one note on its track, and a composition "Cut" at 10 fps. */
function makeTune() {
const f = makeProject();
const song = new CreateAssetCommand('song', 'Tune');
const pattern = new CreateAssetCommand('pattern', 'Riff');
const piano = new CreateAssetCommand('instrument', 'Piano');
const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 });
for (const command of [song, pattern, piano, comp]) f.stack.execute(command);
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 }]),
);
const tune = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset;
const trackId = tune().tracks[0]!.id;
f.stack.execute(
new SetSongCommand(song.asset.id, {
items: [newItem({ track: trackId, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR })],
}),
);
const automate = (...automation: AutomationLane[]) =>
f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, automation }] }));
const cut = () => findAssetOf(f.project, comp.asset.id, 'composition') as CompositionAsset;
return { ...f, tune, trackId, automate, cut };
}
describe('a song with automation (#337)', () => {
it('plays its lanes on the buses of its tracks, which start where the lanes do', () => {
const f = makeTune();
expect(songSequence(f.project, f.tune()).lanes).toBeUndefined();
expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated');
f.automate({
target: 'volume',
points: [
{ tick: 0, value: -12 },
{ tick: BAR, value: 0 },
],
});
expect(songSequence(f.project, f.tune()).lanes).toEqual([
{
bus: trackBus(f.tune().id, f.trackId),
target: 'volume',
points: [
{ tick: 0, value: -12 },
{ tick: BAR, value: 0 },
],
},
]);
// The lane rules the volume, the fader the pan.
expect(songBuses(f.project, f.tune())[0]).toMatchObject({
gain: dbToGain(-12),
pan: 0,
automated: ['volume'],
});
});
it('is a bake of its own, and one without automation keeps the print it had', () => {
const f = makeTune();
const plain = bakePrint(f.project, f.tune());
const sweep = (to: number): AutomationLane => ({
target: 'pan',
points: [
{ tick: 0, value: 0.5 },
{ tick: BAR / 2, value: to },
],
});
f.automate(sweep(1));
const panned = bakePrint(f.project, f.tune());
expect(panned).not.toBe(plain);
// Only later in the song: where its tracks start is the same.
f.automate(sweep(-1));
expect(bakePrint(f.project, f.tune())).not.toBe(panned);
f.automate();
expect(bakePrint(f.project, f.tune())).toBe(plain);
});
it('plays its lanes in a composition on the buses of its item, round its loop, in milliseconds', () => {
const f = makeTune();
f.automate({
target: 'pan',
points: [
{ tick: 0, value: -1 },
{ tick: BAR, value: 1 },
],
});
// From 1 s for 3 s, half a second into the song: it goes round its two-second bar once.
const item = {
id: 'it',
track: f.cut().tracks[0]!.id,
start: 10,
length: 30,
offset: 0.5,
audio: { kind: 'song' as const, asset: f.tune().id, volume: 0, bus: '' },
};
f.stack.execute(new SetCompositionCommand(f.cut().id, { items: [item] }));
const [playing] = compositionPlayings(f.project, f.cut());
expect(playing!.sequence.lanes).toEqual([
{
bus: `${audioItemBus(f.cut().id, 'it')}:${f.trackId}`,
target: 'pan',
points: [
{ tick: 1000, value: -0.5 },
{ tick: 2500, value: 1 },
{ tick: 2500, value: -1 },
{ tick: 4000, value: 0.5 },
],
},
]);
expect(playing!.buses.map((bus) => bus.name)).toContain(`${audioItemBus(f.cut().id, 'it')}:${f.trackId}`);
});
});
describe('the scheduler and a lane (#337)', () => {
it('hands each window to the host on the audio clock, the first fresh, each going on from the last', () => {
const automated: ScheduledAutomation[] = [];
let time = 10;
let pump: (() => void) | null = null;
const host: SchedulerHost = {
now: () => time,
latency: () => 0,
play: () => 0,
automate: (automation) => void automated.push(automation),
playing: () => false,
stop: () => {},
wake: (wake) => ((pump = wake), () => (pump = null)),
};
const lane = {
...pan([
{ tick: 0, value: -1 },
{ tick: BAR, value: 1 },
]),
bus: 'song:s:t',
};
const scheduler = new Scheduler(host, () => ({ notes: [], lanes: [lane] }), { tempo: 120 });
scheduler.start();
for (let i = 0; i < 8; i++) {
time += 0.025;
pump!();
}
expect(automated.map((a) => [a.bus, a.target, a.fresh])).toEqual([
['song:s:t', 'pan', true],
...Array.from({ length: automated.length - 1 }, () => ['song:s:t', 'pan', false]),
]);
expect(automated[0]!.marks[0]).toEqual({ at: 10 + LEAD, value: -1, jump: true });
automated.slice(1).forEach((window, i) => {
expect(window.marks[0]!.at).toBeCloseTo(automated[i]!.marks.at(-1)!.at, 12);
expect(window.marks[0]!.jump).toBe(false);
});
// Two seconds a bar: from -1 by 1 a second.
for (const { marks } of automated)
for (const mark of marks) expect(mark.value).toBeCloseTo(-1 + (mark.at - 10 - LEAD), 9);
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* Where a playing is heard, and the beats, bars and markers it passed (#338): a frame looks now and
* then, so what passed since is told late – and too late, or more than the last, not at all.
*/
import { describe, expect, it } from 'vitest';
import {
heardPosition,
heldQuantize,
lateLimit,
newestPassed,
nextSpot,
passedBar,
passedBeat,
passedMarkers,
quantizeSpots,
type Timeline,
} from '../../src/core/music/beats';
import { COMMON_TIME } from '../../src/core/music/time';
const Q = 480;
const BAR = 4 * Q;
/** 120 BPM in 4/4: a beat is half a second, a bar two. Intro bar 1, then round bar 2. */
const LOOPED: Timeline = {
tempo: 120,
meter: COMMON_TIME,
from: 0,
loop: { start: BAR, end: 2 * BAR },
length: 2 * BAR,
};
const ONCE: Timeline = { ...LOOPED, loop: null };
const beatAt = (t: Timeline, before: number | null, now: number) => {
const beat = passedBeat(t, before, now);
return beat && [beat.bar, beat.beat, +beat.late.toFixed(6)];
};
describe('where a playing is heard', () => {
it('counts bar and beat from 1, the fraction into the beat, the seconds into the song', () => {
expect(heardPosition(LOOPED, 0)).toMatchObject({
bar: 1,
beat: 1,
fraction: 0,
seconds: 0,
playing: true,
});
expect(heardPosition(LOOPED, 1.25)).toMatchObject({ bar: 1, beat: 3, fraction: 0.5, seconds: 1.25 });
});
it('goes back round the loop with the playhead', () => {
// 5.25 s in: the intro took 2, the loop round 2 more, so a quarter second into the second round.
expect(heardPosition(LOOPED, 5.25)).toMatchObject({ bar: 2, beat: 3, fraction: 0.5, seconds: 3.25 });
});
it('stands where it starts before its first note, at its end after it, playing neither time', () => {
expect(heardPosition(LOOPED, null)).toMatchObject({ bar: 1, beat: 1, playing: false });
expect(heardPosition(LOOPED, -0.03)).toMatchObject({ bar: 1, beat: 1, playing: false });
expect(heardPosition(ONCE, 4.5)).toMatchObject({ bar: 3, beat: 1, seconds: 4, playing: false });
});
it('takes the beat from the line of the meter: an eighth in 6/8', () => {
const six: Timeline = { ...ONCE, meter: { beats: 6, unit: 8 } };
// An eighth is a quarter second at 120 quarters a minute; a bar of six is 1.5 s.
expect(heardPosition(six, 1.625)).toMatchObject({ bar: 2, beat: 1, fraction: 0.5 });
expect(lateLimit(six)).toBe(0.125);
expect(lateLimit(LOOPED)).toBe(0.15);
});
});
describe('the beats it passed', () => {
it('tells a beat passed since the last frame, with how late', () => {
expect(beatAt(LOOPED, 0.49, 0.51)).toEqual([1, 2, 0.01]);
expect(beatAt(LOOPED, 0.51, 0.53)).toBeNull();
});
it('tells the very first beat to a playing seen for the first time', () => {
expect(beatAt(LOOPED, null, 0.02)).toEqual([1, 1, 0.02]);
expect(beatAt(LOOPED, -0.01, 0.02)).toEqual([1, 1, 0.02]);
});
it('still tells a beat a slipped frame passed, but not one more than the limit ago', () => {
expect(beatAt(LOOPED, 0.4, 0.6)).toEqual([1, 2, 0.1]);
expect(beatAt(LOOPED, 0.4, 0.7)).toBeNull();
});
it('tells only the last of several passed in one frame, and nothing after a long stall', () => {
expect(beatAt(LOOPED, 0, 1.6)).toEqual([1, 4, 0.1]);
expect(beatAt(LOOPED, 0, 10.3)).toBeNull();
});
it('tells the loop’s start each time round', () => {
// 4 s in, the intro and one round are over: the head is back at bar 2.
expect(beatAt(LOOPED, 3.99, 4.01)).toEqual([2, 1, 0.01]);
expect(passedBar(LOOPED, 5.99, 6.02)).toMatchObject({ bar: 2, beat: 1 });
});
it('counts beats on the ruler’s grid where the loop starts between them', () => {
const offBeat: Timeline = { ...LOOPED, loop: { start: BAR + Q / 2, end: 2 * BAR } };
// The second round starts 4 s in at a quarter past bar 2's first beat: its next beat is 0.25 s later.
expect(beatAt(offBeat, 3.99, 4.01)).toBeNull();
expect(beatAt(offBeat, 4.2, 4.3)).toEqual([2, 2, 0.05]);
});
it('tells nothing at the end of one that plays once, nor after it', () => {
expect(beatAt(ONCE, 3.95, 4.05)).toBeNull();
expect(passedBar(ONCE, 3.95, 4.05)).toBeNull();
expect(beatAt(ONCE, 3.45, 3.55)).toEqual([2, 4, 0.05]);
});
it('tells a bar only on its first beat', () => {
expect(passedBar(LOOPED, 0.49, 0.51)).toBeNull();
expect(passedBar(LOOPED, 1.99, 2.01)).toMatchObject({ bar: 2, beat: 1 });
});
});
describe('the markers it passed', () => {
const markers = [
{ id: 'a', name: 'Verse', tick: BAR },
{ id: 'b', name: 'Also verse', tick: BAR },
{ id: 'c', name: 'Turn', tick: 2 * BAR - 10 },
];
it('tells every marker on the tick passed, each time round the loop', () => {
const passed = passedMarkers(LOOPED, 1.99, 2.01, markers);
expect(passed.map((m) => [m.id, m.name, +m.late.toFixed(6)])).toEqual([
['a', 'Verse', 0.01],
['b', 'Also verse', 0.01],
]);
expect(passedMarkers(LOOPED, 3.99, 4.01, markers).map((m) => m.id)).toEqual(['a', 'b']);
});
it('of markers passed across the loop’s end, keeps the last spot only', () => {
// "Turn" is 10 ticks before the end, heard 3.9896 s in; the loop's start follows at 4 s.
const passed = passedMarkers(LOOPED, 3.98, 4.02, markers);
expect(passed.map((m) => m.id)).toEqual(['a', 'b', 'c']);
expect(newestPassed(passed).map((m) => m.id)).toEqual(['a', 'b']);
expect(newestPassed(passed.filter((m) => m.id === 'c')).map((m) => m.id)).toEqual(['c']);
});
});
describe('where a change to the music lands (#338)', () => {
const markers = [
{ id: 'a', name: 'Verse', tick: BAR + Q },
{ id: 'b', name: 'Late', tick: 2 * BAR - Q },
];
const at = (t: Timeline, elapsed: number, quantize: 'beat' | 'bar' | 'marker', list = markers) =>
nextSpot(t, elapsed, quantizeSpots(quantize, t.meter, list));
it('on the next beat or bar, one it stands on being the one', () => {
expect(at(LOOPED, 0.2, 'beat')).toBeCloseTo(0.5, 9);
expect(at(LOOPED, 0.5, 'beat')).toBeCloseTo(0.5, 9);
expect(at(LOOPED, 0.2, 'bar')).toBeCloseTo(2, 9);
});
it('round the loop: past its last bar the next one is its start come round again', () => {
// Played time 3.9 s is the end of bar 2; the loop's start follows at 4 s.
expect(at(LOOPED, 3.9, 'bar')).toBeCloseTo(4, 9);
expect(at(LOOPED, 5.9, 'bar')).toBeCloseTo(6, 9);
});
it('on the next marker, round the loop too, and on the next bar in a song without markers', () => {
expect(at(LOOPED, 0, 'marker')).toBeCloseTo(2.5, 9);
expect(at(LOOPED, 2.6, 'marker')).toBeCloseTo(3.5, 9);
expect(at(LOOPED, 3.6, 'marker')).toBeCloseTo(4.5, 9);
expect(at(LOOPED, 0.2, 'marker', [])).toBeCloseTo(2, 9);
});
it('from where a playing started, a marker its start lies past is not come to again', () => {
expect(at({ ...LOOPED, from: BAR + 2 * Q }, 0, 'marker')).toBeCloseTo(0.5, 9);
expect(at({ ...ONCE, from: BAR + 2 * Q }, 0, 'marker', [markers[0]!])).toBeNull();
});
it('nowhere at or past the end of one that ends: the change then lands at once', () => {
expect(at(ONCE, 3.2, 'bar')).toBeNull();
expect(at(ONCE, 3.2, 'beat')).toBeCloseTo(3.5, 9);
});
it('takes what it does not know for the next bar', () => {
expect(heldQuantize('now')).toBe('now');
expect(heldQuantize('soon')).toBe('bar');
expect(heldQuantize(undefined)).toBe('bar');
});
});
+14
View File
@@ -258,6 +258,20 @@ describe('the sequences of a composition', () => {
]);
});
it('play a pattern with its swing once, at the composition’s own ticks (#372)', () => {
const f = makeCut();
f.stack.execute(new SetAssetPropertyCommand(f.patternId, 'swing', 100));
f.stack.execute(
new SetPatternNotesCommand(f.patternId, [
{ id: 'n1', tick: Q / 2, length: Q / 2, note: 62, velocity: 1 },
]),
);
f.lay(f.audioItem('p', 'pattern', 0, 10));
const [swung] = compositionPlayings(f.project, f.cut())[0]!.sequence.notes;
// The second eighth heard at 1/3 s instead of 1/4 s, for 1/6 s; nothing left to swing it again.
expect([swung!.tick, swung!.length, 'late' in swung!]).toEqual([333, 167, false]);
});
it('give a song item its own copy of the song’s buses, sending into the item’s', () => {
const f = makeCut();
f.lay(f.audioItem('s', 'song', 0, 20), f.audioItem('t', 'song', 0, 20));
+1 -1
View File
@@ -104,7 +104,7 @@ describe('the order rule', () => {
const events = BUILT_IN_NODES.filter(
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
);
expect(events).toHaveLength(19);
expect(events).toHaveLength(25);
for (const def of events) {
expect(
def.params.map((p) => p.key),
+269
View File
@@ -0,0 +1,269 @@
/**
* A game takes a lane of a song over (#338): Set song lane holds it on a value of its own, Release
* song lane takes it back to its curve. The way of a hold in playing time, and how the scheduler
* plans it in place of the lane's own points.
*/
import { describe, expect, it } from 'vitest';
import type { SequenceLane } from '../../src/core/music/automation';
import {
heldMarks,
heldParamsAt,
heldValueAt,
holdSpans,
type LaneHold,
type LanePlaying,
} from '../../src/core/music/laneHolds';
import {
LEAD,
type ScheduledAutomation,
type ScheduledNote,
Scheduler,
type SchedulerHost,
} from '../../src/core/music/Scheduler';
import type { Sequence } from '../../src/core/music/sequence';
const Q = 480;
const BAR = 4 * Q;
/** 120 BPM: 960 ticks a second, played from the start, its clock at 0 where playing time is. */
const PLAYING: LanePlaying = { from: 0, loop: null, elapsed: (at) => at * 960 };
/** From -1 at the start to 1 a bar (two seconds) later: a second of playing time moves it by 1. */
const PAN: SequenceLane = {
bus: 'song:s:t',
target: 'pan',
points: [
{ tick: 0, value: -1 },
{ tick: BAR, value: 1 },
],
};
const hold = (fields: Partial<LaneHold> = {}): LaneHold => ({
at: 1,
fade: 0.5,
start: 0,
value: 0.5,
release: null,
...fields,
});
const rounded = (marks: { at: number; value: number; jump: boolean }[]) =>
marks.map(({ at, value, jump }) => [Math.round(at * 1e6) / 1e6, Math.round(value * 1e6) / 1e6, jump]);
describe('the way of a held lane (#338)', () => {
it('goes from where the lane was to the value over its fade and stays; before it the lane is its own', () => {
const held = hold();
expect(holdSpans([held], PLAYING)).toEqual([{ hold: held, start: 960, end: Infinity }]);
expect([480, 960, 1200, 1440, 5000].map((t) => heldValueAt(PAN, [held], PLAYING, t))).toEqual([
-0.5, 0, 0.25, 0.5, 0.5,
]);
});
it('let go, goes back over its fade to where the curve is by then, and follows the curve from there', () => {
// Let go at 2 s over 1 s: back to the curve at 3 s, which is past the last point, at 1.
const held = hold({ release: { at: 2, fade: 1 } });
expect(holdSpans([held], PLAYING)).toEqual([{ hold: held, start: 960, end: 2880 }]);
expect([1920, 2400, 2880, 3000].map((t) => heldValueAt(PAN, [held], PLAYING, t))).toEqual([
0.5, 0.75, 1, 1,
]);
});
it('let go in the middle of its fade, goes back from where the fade had got to', () => {
const held = hold({ fade: 1, release: { at: 1.5, fade: 0 } });
expect(heldValueAt(PAN, [held], PLAYING, 1439)).toBeCloseTo(479 / 1920, 9);
// At 1.5 s the curve is at 0.5 itself.
expect(heldValueAt(PAN, [held], PLAYING, 1440)).toBe(0.5);
expect(heldValueAt(PAN, [held], PLAYING, 1500)).toBeCloseTo(0.5625, 9);
});
it('is marked in place of the lane: its own points before, the way of the hold from a jump on', () => {
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 0, 1920, true, 9.6))).toEqual([
[0, -1, true],
[960, 0, false],
[960, 0, true],
[1440, 0.5, false],
[1920, 0.5, false],
]);
// A window going on from the last one jumps where the hold begins, all the same.
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 900, 1000, false, 9.6))).toEqual([
[900, -0.0625, false],
[960, 0, false],
[960, 0, true],
[1000, 0.041667, false],
]);
// A window that starts inside the hold goes on from where the last one ended, without a jump.
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 1200, 1300, false, 9.6))).toEqual([
[1200, 0.25, false],
[1300, 0.354167, false],
]);
});
it('let go, the lane is its own again from a jump where the way back ends', () => {
const held = hold({ release: { at: 1.5, fade: 0.25 } });
expect(rounded(heldMarks(PAN, [held], PLAYING, 1400, 2000, false, 9.6))).toEqual([
[1400, 0.458333, false],
[1440, 0.5, false],
[1680, 0.75, false],
[1680, 0.75, true],
[1920, 1, false],
[2000, 1, false],
]);
});
it('a later hold takes over where it lands, the one before ruling until then; one let go leaves the lane between', () => {
const first = hold({ fade: 0, release: { at: 2, fade: 0 } });
const second = hold({ at: 1.5, fade: 0, start: 0.5, value: -0.5 });
expect(holdSpans([first, second], PLAYING).map((s) => [s.start, s.end])).toEqual([
[960, 1440],
[1440, Infinity],
]);
expect([1200, 1440, 2400].map((t) => heldValueAt(PAN, [first, second], PLAYING, t))).toEqual([
0.5, -0.5, -0.5,
]);
const gap = hold({ at: 3, fade: 0, start: 1, value: 0 });
expect([1200, 2400, 3000].map((t) => heldValueAt(PAN, [first, gap], PLAYING, t))).toEqual([0.5, 1, 0]);
});
it('a held parameter gives what starts inside its track the held value, the others are the lanes’', () => {
const cutoff: SequenceLane = {
bus: 'song:s:t',
target: 'param:cutoff',
points: [
{ tick: 0, value: 0 },
{ tick: BAR, value: 1 },
],
};
const drive: SequenceLane = { ...cutoff, target: 'param:drive' };
const holds = new Map([[`song:s:t\nparam:cutoff`, [hold({ fade: 0, value: 0.9 })]]]);
expect(heldParamsAt([cutoff, drive], holds, PLAYING, 'song:s:t:item', 1200, 1200)).toEqual({
cutoff: 0.9,
drive: 0.625,
});
// Before the hold lands, and on a bus outside the track, it is the lane's.
expect(heldParamsAt([cutoff], holds, PLAYING, 'song:s:t', 480, 480)).toEqual({ cutoff: 0.25 });
expect(heldParamsAt([cutoff], holds, PLAYING, 'song:s:other', 1200, 1200)).toBeUndefined();
});
});
describe('the scheduler and a held lane (#338)', () => {
/** A clock at 10 s moved by hand, every lane window the host is handed, every note struck. */
function playing(sequence: Sequence) {
const automated: ScheduledAutomation[] = [];
const struck: ScheduledNote[] = [];
let pump: (() => void) | null = null;
const clock = { time: 10 };
const host: SchedulerHost = {
now: () => clock.time,
latency: () => 0,
play: (note) => (struck.push(note), 0),
automate: (automation) => void automated.push(automation),
playing: () => false,
stop: () => {},
wake: (wake) => ((pump = wake), () => (pump = null)),
};
const scheduler = new Scheduler(host, () => sequence, { tempo: 120 });
const run = (seconds: number) => {
for (let t = 0; t < seconds - 1e-9; t += 0.025) {
clock.time += 0.025;
pump?.();
}
};
return { scheduler, automated, struck, run, clock, host };
}
/** Where the lane is at a second of the clock, played from 10 s + LEAD on. */
const panAt = (at: number) => -1 + (at - 10 - LEAD);
const onSpot = (at: number, spot: number) => Math.abs(at - spot) < 1e-9;
const marksFrom = (automated: ScheduledAutomation[], from = 0) =>
automated.slice(from).flatMap((a) => a.marks);
it('holds from where it lands, what was planned past it planned again; let go, it follows its curve again', () => {
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
scheduler.start();
run(0.1);
const before = automated.length;
expect(scheduler.holdLane('song:s:t', 'pan', 0.5, 10.2, 0)).toBe(true);
// Planned up to 10.2 s and further already: planned again from there, a fresh window.
const replanned = automated[before]!;
expect([replanned.fresh, replanned.marks[0]]).toEqual([true, { at: 10.2, value: 0.5, jump: true }]);
run(0.5);
for (const mark of marksFrom(automated, before)) expect(mark.value).toBe(0.5);
const held = automated.length;
expect(scheduler.releaseLane('song:s:t', 'pan', 10.7, 0)).toBe(true);
expect(automated[held]!.fresh).toBe(true);
expect(automated[held]!.marks[0]).toMatchObject({ at: 10.7, jump: true });
run(0.5);
for (const mark of marksFrom(automated, held)) expect(mark.value).toBeCloseTo(panAt(mark.at), 9);
});
it('held again for a later spot, stays held on the old value until then', () => {
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
scheduler.start();
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
run(0.2);
// As a node does on every beat while a key is held: each time for the next bar.
scheduler.holdLane('song:s:t', 'pan', -0.5, 10.61, 0);
const before = automated.length;
run(0.8);
// On a spot itself a stretch ends at the old value and the next jumps from there: both are right.
const marks = marksFrom(automated, before - 1).filter(
(mark) => mark.at > 10.1 && !onSpot(mark.at, 10.61),
);
for (const mark of marks) expect(mark.value).toBe(mark.at < 10.61 ? 0.5 : -0.5);
expect(marks.some((mark) => mark.at < 10.61) && marks.some((mark) => mark.at > 10.61)).toBe(true);
});
it('let go before a later hold lands, that one is never heard and the one before goes back', () => {
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
scheduler.start();
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
scheduler.holdLane('song:s:t', 'pan', -0.5, 10.61, 0);
scheduler.releaseLane('song:s:t', 'pan', 10.31, 0);
run(1);
for (const mark of marksFrom(automated).filter((m) => m.at > 10.1 && !onSpot(m.at, 10.31)))
expect(mark.value).toBeCloseTo(mark.at < 10.31 ? 0.5 : panAt(mark.at), 9);
});
it('says where it has no such lane, and a release where none is held', () => {
const { scheduler } = playing({ notes: [], lanes: [PAN] });
scheduler.start();
expect(scheduler.holdLane('song:s:t', 'volume', 0, 10.2, 0)).toBe(false);
expect(scheduler.holdLane('song:s:other', 'pan', 0, 10.2, 0)).toBe(false);
expect(scheduler.releaseLane('song:s:t', 'pan', 10.2, 0)).toBe(false);
});
it('let go before it lands, is never heard', () => {
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
scheduler.start();
scheduler.holdLane('song:s:t', 'pan', 0.5, 11, 0);
// Not on the edge of a window: one starts at 10.3 s.
scheduler.releaseLane('song:s:t', 'pan', 10.31, 0);
run(1.5);
for (const mark of marksFrom(automated)) expect(mark.value).toBeCloseTo(panAt(mark.at), 9);
// Each window once, in the order of the clock.
for (const { marks } of automated)
marks.forEach((mark, i) => i > 0 && expect(mark.at).toBeGreaterThanOrEqual(marks[i - 1]!.at));
});
it('a note that starts while a parameter is held starts at the held value', () => {
const cutoff: SequenceLane = { ...PAN, target: 'param:cutoff', points: [{ tick: 0, value: 0.25 }] };
const note = { tick: Q, length: Q, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' };
const { scheduler, struck, run } = playing({ notes: [note], lanes: [cutoff] });
scheduler.start();
scheduler.holdLane('song:s:t', 'param:cutoff', 0.9, 10.1, 0);
run(1);
expect(struck.map((n) => n.params)).toEqual([{ cutoff: 0.9 }]);
});
it('one taking over keeps what the other holds, from its first window on', () => {
const { scheduler } = playing({ notes: [], lanes: [PAN] });
scheduler.start();
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
const next = playing({ notes: [], lanes: [PAN] });
next.clock.time = 12;
next.scheduler.keepHolds(scheduler);
next.scheduler.start();
const [first] = next.automated;
expect([first?.fresh, first?.marks[0]]).toEqual([true, { at: 12 + LEAD, value: 0.5, jump: true }]);
for (const mark of first!.marks) expect(mark.value).toBe(0.5);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* A game follows its music (#338): On beat, On bar and On song marker run as the songs and patterns
* it plays pass them, as heard, and Song position says where one stands.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { HeardTime } from '../../src/core/music/playbacks';
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { heardPlayings, positionOf } from '../../src/runtime/systems/musicPulses';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
const Q = 480;
const BAR = 4 * Q;
/** A song with two markers on bar 2 and one in its middle, a pattern, and a behavior to listen. */
function listening() {
const f = makeSpriteScene('Box');
const create = (type: 'song' | 'pattern', name: string) => {
const command = new CreateAssetCommand(type, name);
f.stack.execute(command);
return command.asset.id;
};
const song = create('song', 'Theme');
const pattern = create('pattern', 'Riff');
f.stack.execute(new SetAssetPropertyCommand(pattern, 'sign', '3/4'));
f.stack.execute(
new SetSongMarkersCommand(song, [
{ id: 'verse', name: 'Verse', tick: BAR },
{ id: 'also', name: 'Also', tick: BAR },
{ id: 'half', name: 'Half', tick: BAR / 2 },
]),
);
const behavior = makeBehavior(f, 'Logic');
place(f, behavior.id, { x: 0, y: 0 });
const edit = graphEditor(f, behavior);
/** An event that logs `prefix` and two of its outputs, run together. */
const logs = (type: string, params: Record<string, string>, prefix: string, a: string, b: string) => {
const event = edit.addNode(type, params);
const join = edit.addNode('Join');
const first = edit.addNode('Join', { a: prefix });
edit.connect(event, a, first, 'b');
edit.connect(first, 'text', join, 'a');
edit.connect(event, b, join, 'b');
const log = edit.addNode('Log');
edit.connect(event, 'exec', log, 'exec');
edit.connect(join, 'text', log, 'message');
return event;
};
return { ...f, song, pattern, edit, logs };
}
/** A sink whose playings are heard where a test puts them. */
function heardAudio(song: string, pattern: string) {
const times: HeardTime[] = [
{
id: 7,
key: 7,
name: `song:${song}`,
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: BAR, end: 2 * BAR },
length: 2 * BAR,
},
{
id: 8,
key: 8,
name: `pattern:${pattern}`,
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: 0, end: 3 * Q },
length: 3 * Q,
},
{
id: 9,
key: 9,
name: 'composition:c',
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: null,
length: 2 * BAR,
},
];
return {
audio: { ...SILENT_AUDIO, playingTimes: () => times },
at(heard: number | null, ...ids: number[]) {
for (const time of times) if (ids.length === 0 || ids.includes(time.id)) time.heard = heard;
},
};
}
describe('On beat and On bar', () => {
it('run on every beat and bar a song plays, as heard, counting from 1', () => {
const f = listening();
f.logs('OnBeat', { song: f.song }, 'beat ', 'bar', 'beat');
f.logs('OnBar', { song: f.song }, 'bar ', 'bar', 'late');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect(world.log).toEqual([]);
at(0.01, 7);
frame();
expect(world.log).toEqual(['beat 11', 'bar 10.01']);
// A frame the audio clock did not move in: nothing new was heard.
frame();
expect(world.log).toHaveLength(2);
at(0.3, 7);
frame();
at(0.52, 7);
frame();
expect(world.log.slice(2)).toEqual(['beat 12']);
// Round the loop: the intro and one round are over 4 s in, the head is back on bar 2.
at(3.98, 7);
frame();
at(4, 7);
frame();
expect(world.log.slice(3)).toEqual(['beat 21', 'bar 20']);
});
it('tell nothing a stalled frame passed too long ago, and of several only the last', () => {
const f = listening();
f.logs('OnBeat', {}, 'beat ', 'bar', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.6, 7);
frame();
expect(world.log).toEqual(['beat 12']);
at(1.55, 7);
frame();
at(3.3, 7);
frame();
expect(world.log.slice(1)).toEqual(['beat 14']);
});
it('start over with a song a Switch song plays under the same number (#338)', () => {
const f = listening();
f.logs('OnBeat', {}, 'beat ', 'bar', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(3.01, 7);
frame();
expect(world.log).toEqual(['beat 23']);
// Switched to: the same number on buses of its own, its first beat heard a moment ago.
const switched = audio.playingTimes().find((time) => time.id === 7)!;
switched.key = 70;
at(0.01, 7);
frame();
expect(world.log.slice(1)).toEqual(['beat 11']);
});
it('hear the song or pattern picked, every one where none is, a composition never', () => {
const f = listening();
f.logs('OnBeat', {}, 'any ', 'playback', 'beat');
f.logs('OnBeat', { pattern: f.pattern }, 'pattern ', 'playback', 'beat');
f.logs('OnBeat', { song: f.song }, 'song ', 'playback', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.01);
frame();
expect(world.log).toEqual(['any 71', 'any 81', 'pattern 81', 'song 71']);
});
it('hear only the playing "playback" names where it is connected', () => {
const f = listening();
const beat = f.logs('OnBeat', {}, 'beat ', 'playback', 'beat');
const number = f.edit.addNode('Number', { value: 8 });
f.edit.connect(number, 'value', beat, 'playback');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.01);
frame();
expect(world.log).toEqual(['beat 81']);
});
});
describe('On song marker', () => {
it('runs for every marker on the tick passed, or for the one picked', () => {
const f = listening();
f.logs('OnSongMarker', { song: f.song }, 'any ', 'name', 'marker');
f.logs('OnSongMarker', { marker: 'also' }, 'picked ', 'name', 'playback');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.98, 7);
frame();
at(1.01, 7);
frame();
expect(world.log).toEqual(['any Halfhalf']);
at(2.01, 7);
frame();
expect(world.log.slice(1)).toEqual(['any Verseverse', 'any Alsoalso', 'picked Also7']);
});
});
describe('Song position', () => {
it('answers where the playing picked stands, the one started last where several are', () => {
const f = listening();
const { audio, at } = heardAudio(f.song, f.pattern);
at(1.25, 7);
at(0.625, 8);
const playings = heardPlayings(f.project, audio);
// The pattern is in 3/4 and runs round its one bar of 1.5 s.
expect(playings.map((p) => [p.kind, p.timeline.meter.beats])).toEqual([
['song', 4],
['pattern', 3],
]);
expect(positionOf(playings, null, f.song, '')).toMatchObject({
bar: 1,
beat: 3,
fraction: 0.5,
seconds: 1.25,
playing: true,
});
// None picked: the pattern, started after the song.
expect(positionOf(playings, null, '', '')).toMatchObject({ beat: 2, fraction: 0.25, seconds: 0.625 });
expect(positionOf(playings, 7, '', f.pattern)).toMatchObject({ seconds: 1.25 });
expect(positionOf(playings, 3, '', '')).toBeNull();
at(1.75, 8);
expect(positionOf(heardPlayings(f.project, audio), null, '', f.pattern)).toMatchObject({
bar: 1,
beat: 1,
fraction: 0.5,
});
});
});
+154
View File
@@ -2007,6 +2007,44 @@ const CASES: Record<string, Case[]> = {
after: () => expect(director.composition?.stopped).toBe(true),
},
],
PlayVideo: [
{
name: 'plays on the video of the object it is given (#410)',
inputs: { object: director },
next: 'exec',
after: (ctx) => expect(ctx.steered).toEqual([{ target: director.id, steer: { kind: 'play' } }]),
},
{
name: 'unconnected, it plays the video of the owner',
inputs: {},
next: 'exec',
after: (ctx) => expect(ctx.steered).toEqual([{ target: ctx.owner!.id, steer: { kind: 'play' } }]),
},
],
PauseVideo: [
{
name: 'holds the video of the object it is given (#410)',
inputs: { object: director },
next: 'exec',
after: (ctx) => expect(ctx.steered).toEqual([{ target: director.id, steer: { kind: 'pause' } }]),
},
{
name: 'without an owner and no object it asks nothing',
inputs: {},
context: { owner: null },
next: 'exec',
after: (ctx) => expect(ctx.steered).toEqual([]),
},
],
SeekVideo: [
{
name: 'sends the video of the object to the time it is given (#410)',
inputs: { object: director, time: 0.5 },
next: 'exec',
after: (ctx) =>
expect(ctx.steered).toEqual([{ target: director.id, steer: { kind: 'seek', time: 0.5 } }]),
},
],
PlaySound: [
{
name: 'no sound is a no-op that still continues the flow',
@@ -2526,6 +2564,116 @@ const CASES: Record<string, Case[]> = {
outputs: { volume: 0.4 },
},
],
SongPosition: [
{
name: 'answers where the playing stands, by its number where "playback" is connected (#338)',
context: {
songPosition: (playback: number | null, song: string, pattern: string) =>
playback === 4 && song === 's' && pattern === ''
? { bar: 3, beat: 2, fraction: 0.25, seconds: 5.125, tick: 0, playing: true }
: null,
},
inputs: { playback: 4, song: 's', pattern: '' },
outputs: { bar: 3, beat: 2, fraction: 0.25, seconds: 5.125, playing: true },
},
{
name: 'asks by the song and pattern alone where "playback" is not connected, all 0 for none',
context: {
songPosition: (playback: number | null) =>
playback === null ? null : { bar: 1, beat: 1, fraction: 0, seconds: 0, tick: 0, playing: true },
},
inputs: { song: 's' },
outputs: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false },
},
],
MusicPlaying: [
{
name: 'answers whether the playing is still to be heard (#373)',
inputs: { playback: 4 },
outputs: { playing: true },
},
{
name: 'off for one that has ended, and where "playback" is not connected',
inputs: { playback: 5 },
outputs: { playing: false },
},
{ name: 'unconnected is 0, which nothing plays under', inputs: {}, outputs: { playing: false } },
],
SoundPlaying: [
{
name: 'answers whether the voice is still to be heard (#373)',
inputs: { voice: 6 },
outputs: { playing: true },
},
{ name: 'off for a voice that has ended', inputs: { voice: 7 }, outputs: { playing: false } },
],
VideoTime: [
{
name: 'answers where the video of the object stands (#410)',
context: {
videoReading: (target: { id: string }) =>
target.id === director.id ? { time: 0.75, length: 2, playing: true } : null,
},
inputs: { object: director },
outputs: { time: 0.75, length: 2, playing: true },
},
{
name: 'all 0 for a sprite without a video, the owner where unconnected',
context: { videoReading: () => null },
inputs: {},
outputs: { time: 0, length: 0, playing: false },
},
],
SetSongTrack: [
{
name: 'turns the track of the playing it is given, on the next bar where nothing else is picked (#338)',
inputs: { playback: 4, track: 't1', volume: -12, fade: 1 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -12 dB fade 1 bar']),
},
{
name: 'a quantize it does not know is the next bar',
inputs: { playback: 4, track: 't1', volume: -60, fade: 0, quantize: 'soon' },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -60 dB fade 0 bar']),
},
],
SetSongLane: [
{
name: 'holds the lane of the track of the playing it is given, on the next bar where nothing else is picked (#338)',
inputs: { playback: 4, track: 't1', lane: 'cutoff', value: 0.5, fade: 2 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['lane 4 t1 cutoff 0.5 fade 2 bar']),
},
{
name: 'where its quantize says',
inputs: { playback: 4, track: 't1', lane: 'volume', value: -6, fade: 0, quantize: 'beat' },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['lane 4 t1 volume -6 fade 0 beat']),
},
],
ReleaseSongLane: [
{
name: 'lets the lane of the track of the playing it is given go (#338)',
inputs: { playback: 4, track: 't1', lane: 'pan', fade: 1.5 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['release 4 t1 pan fade 1.5']),
},
],
SwitchSong: [
{
name: 'switches the playing it is given, in a cut on the next bar by default (#338)',
inputs: { playback: 4 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['switch 4 to (same) at (start) bar fade 0']),
},
{
name: 'to the song and marker it picks, where its quantize says',
inputs: { playback: 4, song: 'boss', marker: 'm2', quantize: 'marker', fade: 2 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['switch 4 to boss at m2 marker fade 2']),
},
],
};
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
@@ -2557,6 +2705,12 @@ const EVENT_NODES: Record<string, string> = {
OnMouseWheel: 'clip-areas.test.ts',
OnAnimationFinished: 'animation.test.ts',
OnCompositionFinished: 'composition-play.test.ts',
OnVideoFinished: 'video-steer.test.ts',
OnBeat: 'music-events.test.ts',
OnBar: 'music-events.test.ts',
OnSongMarker: 'music-events.test.ts',
OnMusicFinished: 'played-ends.test.ts',
OnSoundFinished: 'played-ends.test.ts',
OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts',
};
+2
View File
@@ -55,6 +55,8 @@ describe('pattern asset (#333)', () => {
'grid',
'sign',
'tempo',
'swing',
'swingGrid',
'fx',
]);
});
+385
View File
@@ -0,0 +1,385 @@
/**
* A game hears of the end of what it played (#373): a playing of a pattern or song tells its sink
* once nothing of it is left, the sink keeps it, and On music finished and On sound finished run for
* what the run itself played – once, with "stopped" where a stop ended it.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
import { INTERVAL } from '../../src/core/music/Scheduler';
import { GameRun } from '../../src/runtime/GameRun';
import { loadPhysics, readyPhysics } from '../../src/runtime/physics/engine';
import { type AudioSink, type EndedPlay, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { Input } from '../../src/runtime/systems/Input';
import { PlayedEnds } from '../../src/runtime/systems/playedEnds';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
const Q = 480;
/** An audio clock moved by hand, its notes over as soon as they are struck; what ends is kept. */
function fakeHost() {
const host = {
time: 0,
pumps: new Set<() => void>(),
loading: [] as (() => void)[],
timers: [] as { at: number; then: () => void }[],
ended: [] as EndedPlay[],
};
const playbacks = new Playbacks({
now: () => host.time,
latency: () => 0,
playing: () => false,
stop: () => {},
wake: (pump) => {
host.pumps.add(pump);
return () => host.pumps.delete(pump);
},
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
level: () => {},
after: (at, then) => host.timers.push({ at, then }),
ended: (id, name, stopped) => host.ended.push({ id, name, stopped }),
});
const run = (seconds: number) => {
const end = host.time + seconds;
while (host.time + INTERVAL <= end + 1e-9) {
host.time += INTERVAL;
for (const pump of [...host.pumps]) pump();
for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) {
host.timers.splice(host.timers.indexOf(timer), 1);
timer.then();
}
}
};
const loaded = async () => {
for (const resolve of host.loading.splice(0)) resolve();
await Promise.resolve();
};
return { host, playbacks, run, loaded };
}
/** A bar of four quarters at 120 BPM: two seconds long. */
function bar(loop: boolean): SequencePlay {
let next = 100;
return {
sequence: {
notes: [0, 1, 2, 3].map((i) => ({
tick: i * Q,
length: Q / 2,
note: 60 + i,
velocity: 1,
instrument: 'p',
})),
},
tempo: 120,
length: 4 * Q,
loop: loop ? { start: 0, end: 4 * Q } : null,
needs: { layers: [], files: [] },
strike: () => next++,
};
}
describe('the end of a playing', () => {
it('is told once it is played to its end, once, not stopped', async () => {
const { host, playbacks, run, loaded } = fakeHost();
playbacks.start(7, 'pattern:a', bar(false), '');
await loaded();
// Heard from 0.05 s on, so over at 2.05 s; its last note is long gone and the rest planned by then.
run(2);
playbacks.current();
expect(host.ended).toEqual([]);
run(0.1);
playbacks.current();
playbacks.current();
expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: false }]);
});
it('with a loop never by itself, only by a stop – after its fade out', async () => {
const { host, playbacks, run, loaded } = fakeHost();
playbacks.start(7, 'song:a', bar(true), '');
await loaded();
run(9);
playbacks.current();
expect(host.ended).toEqual([]);
playbacks.stopNamed('song:a', false, 1);
run(0.9);
expect(host.ended).toEqual([]);
run(0.2);
expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]);
});
it('stopped while its files load, by its owner, it is told as stopped', () => {
const { host, playbacks } = fakeHost();
playbacks.start(7, 'pattern:a', bar(false), 'logic');
playbacks.stopOwner('logic');
expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: true }]);
});
it('of a switch only once the number is over, by the song heard last (#338)', async () => {
const { host, playbacks, run, loaded } = fakeHost();
playbacks.start(7, 'song:a', bar(true), '');
await loaded();
run(1);
playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0);
await loaded();
run(1);
expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:b']);
expect(host.ended).toEqual([]);
playbacks.stopId(7);
expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]);
});
it('of a switch stopped while the two cross, by the one it switched to', async () => {
const { host, playbacks, run, loaded } = fakeHost();
playbacks.start(7, 'song:a', bar(true), '');
await loaded();
run(1);
playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 2);
await loaded();
run(0.5);
expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:a', 'song:b']);
playbacks.stopId(7);
expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]);
});
it('of a switch stopped before it is heard, by the song heard then', async () => {
const { host, playbacks, run, loaded } = fakeHost();
playbacks.start(7, 'song:a', bar(true), '');
await loaded();
run(1);
playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0);
playbacks.stopId(7);
expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]);
});
});
describe('PlayedEnds', () => {
it('tells only what the run played, each once, a playing by the song or pattern it ended as', () => {
const ends = new PlayedEnds();
ends.sound(3, 'blip');
ends.playing(4);
ends.playing(5);
ends.playing(0);
const log: EndedPlay[] = [
{ id: 2, name: 'x.ogg', stopped: false },
{ id: 3, name: 'b.ogg', stopped: true },
{ id: 4, name: 'song:boss', stopped: false },
{ id: 5, name: 'pattern:riff', stopped: true },
];
expect(ends.follow(log)).toEqual([
{ id: 3, kind: 'sound', asset: 'blip', stopped: true },
{ id: 4, kind: 'song', asset: 'boss', stopped: false },
{ id: 5, kind: 'pattern', asset: 'riff', stopped: true },
]);
expect(ends.follow(log)).toEqual([]);
});
});
/**
* A sink that plays its patterns and songs on real playings, on the hand-moved clock, and makes a
* voice of each sound that lasts until `endVoice` or a stop.
*/
function playingSink() {
const fake = fakeHost();
let next = 1;
const voices = new Map<number, string>();
const endVoice = (id: number, stopped: boolean) => {
const file = voices.get(id);
if (file === undefined) return;
voices.delete(id);
fake.host.ended.push({ id, name: file, stopped });
};
const audio: AudioSink = {
...SILENT_AUDIO,
play: (file) => {
voices.set(next, file);
return next++;
},
playSequence: (name, play, owner) => {
const id = next++;
fake.playbacks.start(id, name, play, owner);
return id;
},
stopSequence: (name, letRing, fade) => fake.playbacks.stopNamed(name, letRing, fade),
stopPlayback: (id, fade) => fake.playbacks.stopId(id, false, fade),
stop: (file) => {
if (file === '') fake.playbacks.stopNamed('');
for (const [id, of] of voices) if (file === '' || of === file) endVoice(id, true);
},
stopVoice: (id) => endVoice(id, true),
stopOwner: (owner) => fake.playbacks.stopOwner(owner),
playing: (id) => voices.has(id) || fake.playbacks.has(id),
endings: () => (fake.playbacks.current(), fake.host.ended),
};
return { ...fake, audio, endVoice };
}
/** A song and a pattern, each played without a loop on start, and a behavior that logs what ends. */
function listening() {
const f = makeSpriteScene('Box');
const create = (type: 'song' | 'pattern', name: string) => {
const command = new CreateAssetCommand(type, name);
f.stack.execute(command);
return command.asset.id;
};
const song = create('song', 'Theme');
const pattern = create('pattern', 'Riff');
const blip = new CreateSoundCommand('Blip', `${'a1'.repeat(32)}.wav`);
f.stack.execute(blip);
const behavior = makeBehavior(f, 'Logic');
place(f, behavior.id, { x: 0, y: 0 });
const edit = graphEditor(f, behavior);
/** An event that logs `prefix`, its number and "stopped", run together. */
const logs = (type: string, params: Record<string, string>, prefix: string, number: string) => {
const event = edit.addNode(type, params);
const join = edit.addNode('Join');
const first = edit.addNode('Join', { a: prefix });
edit.connect(event, number, first, 'b');
edit.connect(first, 'text', join, 'a');
edit.connect(event, 'stopped', join, 'b');
const log = edit.addNode('Log');
edit.connect(event, 'exec', log, 'exec');
edit.connect(join, 'text', log, 'message');
return event;
};
return { ...f, song, pattern, sound: blip.asset.id, edit, logs };
}
describe('On music finished', () => {
it('runs once when a song without a loop is over, not stopped; one with a loop never by itself', async () => {
const f = listening();
const start = f.edit.addNode('OnStart');
const once = f.edit.addNode('PlaySong', { song: f.song, loop: false });
const round = f.edit.addNode('PlaySong', { song: f.song, loop: true });
f.edit.connect(start, 'exec', once, 'exec');
f.edit.connect(once, 'exec', round, 'exec');
f.logs('OnMusicFinished', {}, 'end ', 'playback');
const { audio, playbacks, run, loaded } = playingSink();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
const [first, second] = playbacks.current().map((p) => p.id);
await loaded();
const length = playbacks.current()[0]!.time.length / (2 * Q);
run(length - 0.5);
frame();
expect(world.log).toEqual([]);
run(1);
frame();
frame();
expect(world.log).toEqual([`end ${first}false`]);
run(3 * length);
frame();
expect(world.log).toHaveLength(1);
expect(playbacks.has(second!)).toBe(true);
});
it('runs with "stopped" after Stop song, and hears only what its Song, Pattern or "playback" picks', async () => {
const f = listening();
const start = f.edit.addNode('OnStart');
const song = f.edit.addNode('PlaySong', { song: f.song, loop: true });
const again = f.edit.addNode('PlaySong', { song: f.song, loop: true });
f.edit.connect(start, 'exec', song, 'exec');
f.edit.connect(song, 'exec', again, 'exec');
f.logs('OnMusicFinished', { pattern: f.pattern }, 'pattern ', 'playback');
f.logs('OnMusicFinished', { song: f.song }, 'song ', 'playback');
// The sink numbers the two playings 1 and 2.
const picked = f.logs('OnMusicFinished', {}, 'picked ', 'playback');
f.edit.connect(f.edit.addNode('Number', { value: 1 }), 'value', picked, 'playback');
const other = f.logs('OnMusicFinished', {}, 'other ', 'playback');
f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', other, 'playback');
const key = f.edit.addNode('OnKeyDown', { key: 's' });
const stop = f.edit.addNode('StopSong', { song: f.song });
f.edit.connect(key, 'exec', stop, 'exec');
const { audio, playbacks, loaded } = playingSink();
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
frame();
expect(playbacks.current().map((p) => p.id)).toEqual([1, 2]);
await loaded();
input.press('s');
frame();
frame();
expect(world.log).toEqual(['song 1true', 'song 2true', 'picked 1true', 'other 2true']);
});
});
describe('On sound finished', () => {
it('runs once for a sound this game played, by its Sound or its "voice", with "stopped" where stopped', () => {
const f = listening();
const start = f.edit.addNode('OnStart');
const first = f.edit.addNode('PlaySound', { sound: f.sound });
const second = f.edit.addNode('PlaySound', { sound: f.sound });
f.edit.connect(start, 'exec', first, 'exec');
f.edit.connect(first, 'exec', second, 'exec');
f.logs('OnSoundFinished', {}, 'any ', 'voice');
f.logs('OnSoundFinished', { sound: f.sound }, 'blip ', 'voice');
f.logs('OnSoundFinished', { sound: 'other' }, 'other ', 'voice');
const picked = f.logs('OnSoundFinished', {}, 'second ', 'voice');
f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', picked, 'voice');
f.logs('OnMusicFinished', {}, 'music ', 'playback');
const key = f.edit.addNode('OnKeyDown', { key: 's' });
const stop = f.edit.addNode('StopSound', { sound: f.sound });
f.edit.connect(key, 'exec', stop, 'exec');
const { audio, endVoice } = playingSink();
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
frame();
expect([audio.playing!(1), audio.playing!(2)]).toEqual([true, true]);
endVoice(1, false);
frame();
frame();
expect(world.log).toEqual(['any 1false', 'blip 1false']);
input.press('s');
frame();
frame();
expect(world.log.slice(2)).toEqual(['any 2true', 'blip 2true', 'second 2true']);
});
it('hears nothing a run did not play itself', () => {
const f = listening();
f.logs('OnSoundFinished', {}, 'any ', 'voice');
f.logs('OnMusicFinished', {}, 'music ', 'playback');
const { audio, playbacks } = playingSink();
audio.play('a.wav', 1, false, 'sound-preview');
playbacks.start(9, `song:${f.song}`, bar(false), 'pattern-preview');
playbacks.stopId(9);
audio.stopVoice(1);
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
frame();
expect(world.log).toEqual([]);
});
});
describe('a run', () => {
beforeAll(async () => {
await loadPhysics();
});
it('hears a sound it played in one scene end in the next', () => {
const f = listening();
const start = f.edit.addNode('OnStart');
const play = f.edit.addNode('PlaySound', { sound: f.sound, detached: true });
const load = f.edit.addNode('LoadScene');
f.edit.connect(start, 'exec', play, 'exec');
f.edit.connect(play, 'exec', load, 'exec');
const next = new CreateAssetCommand('scene', 'Level 2');
f.stack.execute(next);
load.params.scene = next.asset.id;
const hearing = makeBehavior(f, 'Ears');
f.stack.execute(new PlaceAssetCommand(next.asset.id, hearing.id, { x: 0, y: 0 }, ''));
const ears = graphEditor(f, hearing);
const log = ears.addNode('Log', { message: 'heard' });
ears.connect(ears.addNode('OnSoundFinished', { sound: f.sound }), 'exec', log, 'exec');
const { audio, endVoice } = playingSink();
const run = GameRun.start(f.project, f.sceneId, new Input(), {
audio,
physics: readyPhysics()!,
clock: { scale: 1 },
saves: { read: () => ({}), write: () => {} },
});
expect(run.tick(1 / 60, 1 / 60)).toBe('scene');
run.tick(1 / 60, 1 / 60);
endVoice(1, false);
run.tick(1 / 60, 1 / 60);
expect(run.world.log).toEqual(['heard']);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, DuplicateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { SongAsset } from '../../src/core/model/Asset';
import {
changedMarker,
heldSongMarkers,
newMarker,
nextMarkerName,
withoutMarker,
} from '../../src/core/music/markers';
import { assetRefLabel, findSongMarker, markerChoices } from '../../src/core/properties/assetRef';
import { makeProject, roundTrip } from '../helpers';
function makeSong() {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
f.stack.execute(create);
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
return { ...f, song };
}
describe('song markers (#338)', () => {
it('holds markers to whole ticks from 0, a name and ids of their own, in the order of their ticks', () => {
const held = heldSongMarkers([
{ id: 'b', name: ' Boss ', tick: 3840.4 },
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'a', name: '', tick: 1920 },
{ id: 'c', name: 'Before', tick: -1 },
{ id: 'd', name: 'Nowhere' },
'no marker',
null,
]);
expect(held.map((m) => [m.name, m.tick])).toEqual([
['Intro', 0],
['Marker 3', 1920],
['Boss', 3840],
]);
expect(new Set(held.map((m) => m.id)).size).toBe(3);
expect(held.find((m) => m.tick === 1920)!.id).not.toBe('a');
expect(heldSongMarkers(undefined)).toEqual([]);
});
it('keeps markers on one tick in the order they came', () => {
const held = heldSongMarkers([
{ id: 'x', name: 'Second', tick: 960 },
{ id: 'y', name: 'First', tick: 0 },
{ id: 'z', name: 'Third', tick: 960 },
]);
expect(held.map((m) => m.name)).toEqual(['First', 'Second', 'Third']);
});
it('names a new marker by the first number not taken', () => {
expect(nextMarkerName([])).toBe('Marker 1');
const markers = heldSongMarkers([
{ name: 'Marker 1', tick: 0 },
{ name: 'Marker 3', tick: 10 },
]);
expect(nextMarkerName(markers)).toBe('Marker 2');
const made = newMarker(markers, 479.6);
expect(made).toMatchObject({ name: 'Marker 2', tick: 480 });
expect(newMarker(markers, -5, ' Drop ')).toMatchObject({ name: 'Drop', tick: 0 });
});
it('changes or takes away one marker and leaves the others', () => {
const markers = heldSongMarkers([
{ id: 'a', name: 'A', tick: 0 },
{ id: 'b', name: 'B', tick: 960 },
]);
expect(changedMarker(markers, 'b', { tick: 1920 })).toEqual([markers[0], { ...markers[1], tick: 1920 }]);
expect(changedMarker(markers, 'a', { name: 'Start' })[0]).toEqual({ id: 'a', name: 'Start', tick: 0 });
expect(withoutMarker(markers, 'a')).toEqual([markers[1]]);
});
it('writes the markers as one step, leaves the key out without one and takes it back on undo', () => {
const f = makeSong();
expect(f.song().markers).toBeUndefined();
const one = [{ id: 'm', name: 'Drop', tick: 1920.2 }];
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(true);
f.stack.execute(new SetSongMarkersCommand(f.song().id, one, 'Add marker'));
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(false);
f.stack.execute(new SetSongMarkersCommand(f.song().id, [], 'Delete marker'));
expect('markers' in f.song()).toBe(false);
f.stack.undo();
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
f.stack.undo();
expect('markers' in f.song()).toBe(false);
});
it('keeps the markers through saving and loading', () => {
const f = makeSong();
const markers = [
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'b', name: 'Boss', tick: 7680 },
];
f.stack.execute(new SetSongMarkersCommand(f.song().id, markers));
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
expect(back.markers).toEqual(markers);
const none = makeSong();
const plain = roundTrip(none.project).assets.find((a) => a.id === none.song().id) as SongAsset;
expect('markers' in plain).toBe(false);
});
it('offers the markers of every song to a marker field, each read "Song / Marker"', () => {
const f = makeSong();
f.stack.execute(
new SetSongMarkersCommand(f.song().id, [
{ id: 'm2', name: 'Boss', tick: 1920 },
{ id: 'm1', name: 'Intro', tick: 0 },
]),
);
expect(markerChoices(f.project)).toEqual([
{ id: 'm1', label: 'Tune / Intro' },
{ id: 'm2', label: 'Tune / Boss' },
]);
expect(assetRefLabel(f.project, 'm2')).toBe('Tune / Boss');
expect(findSongMarker(f.project, 'm1')?.song.id).toBe(f.song().id);
expect(assetRefLabel(f.project, 'gone')).toBe('gone?');
});
it('gives the markers of a copy of a song ids of their own, so a node names one of them', () => {
const f = makeSong();
f.stack.execute(new SetSongMarkersCommand(f.song().id, [{ id: 'm1', name: 'Intro', tick: 0 }]));
const duplicate = new DuplicateAssetCommand(f.song().id);
f.stack.execute(duplicate);
const copy = f.project.assets.find((a) => a.id === duplicate.copyId) as SongAsset;
expect(copy.markers?.map((m) => m.name)).toEqual(['Intro']);
expect(copy.markers?.[0]?.id).not.toBe('m1');
expect(findSongMarker(f.project, 'm1')?.song.id).toBe(f.song().id);
});
});
+559 -7
View File
@@ -9,12 +9,25 @@ import {
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset';
import { SetSongCommand, SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { InstrumentAsset, PatternAsset, SongAsset } from '../../src/core/model/Asset';
import { bakePlayback, bakePrint, bakeSpans } from '../../src/core/music/bake';
import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
import { newItem, newTrack, trackBus } from '../../src/core/music/song';
import {
CUT,
goesBy,
type LandingAt,
type PlayingClock,
Playbacks,
type SequencePlay,
} from '../../src/core/music/playbacks';
import {
INTERVAL,
LEAD,
type ScheduledAutomation,
type ScheduledClip,
type ScheduledNote,
} from '../../src/core/music/Scheduler';
import { dbToGain, newItem, newTrack, trackBus } from '../../src/core/music/song';
import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
@@ -23,9 +36,10 @@ const Q = 480;
const BAR = 4 * Q;
/** An audio clock moved by hand, its timers run as it passes them. */
function fakeHost() {
function fakeHost(baked = false) {
const host = {
time: 0,
latency: 0,
sounding: new Set<number>(),
pumps: new Set<() => void>(),
loading: [] as (() => void)[],
@@ -35,7 +49,7 @@ function fakeHost() {
};
const playbacks = new Playbacks({
now: () => host.time,
latency: () => 0,
latency: () => host.latency,
playing: (voice) => host.sounding.has(voice),
stop: (voices) => {
for (const voice of voices) host.sounding.delete(voice);
@@ -48,6 +62,7 @@ function fakeHost() {
level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }),
after: (at, then) => host.timers.push({ at, then }),
closed: (id) => host.closed.push(id),
...(baked && { playBaked: () => 77 }),
});
const run = (seconds: number) => {
const end = host.time + seconds;
@@ -113,6 +128,65 @@ describe('the songs a game plays', () => {
expect(playbacks.current()[0]?.clips[0]).toMatchObject({ sound: 'drum', bus: 'track' });
});
it('tells how far into it is heard: from its first note, the output latency taken off (#338)', async () => {
const { host, playbacks, loaded } = fakeHost();
host.latency = 0.02;
playbacks.start(5, 'song:a', song([]), '');
expect(playbacks.times()).toEqual([
{
id: 5,
key: 5,
name: 'song:a',
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: BAR, end: 2 * BAR },
length: 2 * BAR,
},
]);
host.time = 1;
await loaded();
// Its first note is heard LEAD after it starts, and the speakers are 0.02 s behind the clock.
host.time = 3;
expect(playbacks.times()[0]!.heard).toBeCloseTo(3 - 0.02 - (1 + LEAD), 9);
});
it('asks no clock while nothing plays: that would make the audio context before a gesture (#338)', () => {
let asked = 0;
const playbacks = new Playbacks({
now: () => (asked++, 0),
latency: () => 0,
playing: () => false,
stop: () => {},
wake: () => () => {},
prepare: () => Promise.resolve(),
level: () => {},
after: () => {},
});
expect(playbacks.times()).toEqual([]);
expect(asked).toBe(0);
});
it('tells how far a baked one is heard the same way, from where its bake is heard (#338)', async () => {
const { host, playbacks, loaded } = fakeHost(true);
const bake = { file: 'b.ogg', gain: 1, playback: { end: null, until: 0, round: null } };
playbacks.start(5, 'song:a', song([], { bake }), '');
host.time = 1;
await loaded();
host.time = 2.5;
expect(playbacks.current()[0]?.baked).toBe(true);
expect(playbacks.times()[0]!.heard).toBeCloseTo(2.5 - (1 + LEAD), 9);
});
it('starts where it was asked to, as far further in as it waited on its files (#418)', async () => {
const { host, playbacks, loaded } = fakeHost();
playbacks.start(5, 'composition:c', song([], { from: 960 }), '');
host.time = 0.5;
await loaded();
expect(playbacks.times()[0]!.from).toBe(960 + 480);
});
it('rises from silence to its volume over its fade in, from its first note on', async () => {
const { host, playbacks, loaded } = fakeHost();
playbacks.start(5, 'song:a', song([], { volume: 0.5, fadeIn: 3 }), '');
@@ -193,6 +267,122 @@ describe('the songs a game plays', () => {
});
});
describe('a song switched to in place of another (#338)', () => {
/** Song a playing from 0.05 s on, as heard, and a clock it was asked for at 1 s. */
async function playingA() {
const fake = fakeHost();
const old: (ScheduledNote | ScheduledClip)[] = [];
fake.playbacks.start(5, 'song:a', song(old), '');
await fake.loaded();
fake.run(1);
return { ...fake, old };
}
const levelsOf = (host: ReturnType<typeof fakeHost>['host']) =>
host.levels.map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(6), seconds]);
it('is heard from where it lands, under the same number; the old one falls silent there in a cut and goes', async () => {
const { host, playbacks, run, loaded } = await playingA();
const fresh: (ScheduledNote | ScheduledClip)[] = [];
const asked: PlayingClock[] = [];
const twoBarsIn: LandingAt = (clock) => (asked.push(clock), clock.startedAt! + 2);
playbacks.switchTo(5, 9, 'song:b', song(fresh, { from: BAR }), '', twoBarsIn, 0);
await loaded();
expect(asked.map((c) => [c.startedAt, +c.now.toFixed(9), +c.soonest.toFixed(9)])).toEqual([
[LEAD, 1, 1 + LEAD],
]);
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 2 + LEAD, CUT],
[9, 1, 2 + LEAD, 0],
]);
// Until the clock gets there the old one is the one heard, from there on the new one.
expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 5]]);
run(1.1);
expect(playbacks.times().map((t) => [t.id, t.key, t.from, +t.heard!.toFixed(6)])).toEqual([
[5, 9, BAR, +(host.time - 2 - LEAD).toFixed(6)],
]);
expect([playbacks.has(5), host.closed]).toEqual([true, [5]]);
const [first] = fresh as ScheduledNote[];
expect([first?.note, first?.at]).toEqual([61, 2 + LEAD]);
});
it('over its fade the old one falls as the new one rises, and goes once it is silent', async () => {
const { host, playbacks, run, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:a', song([]), '', (clock) => clock.startedAt! + 2, 1.5);
await loaded();
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 2 + LEAD, 1.5],
[9, 1, 2 + LEAD, 1.5],
]);
// While the old one fades, only the new one is told: beats and the position go by it.
run(1.2);
expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 9]]);
run(1.2);
expect(host.closed).toEqual([]);
run(0.3);
expect(host.closed).toEqual([5]);
});
it('asked again before it lands, drops the one waiting, and the old one is heard on at its level', async () => {
const { host, playbacks, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
playbacks.switchTo(5, 10, 'song:c', song([]), '', (clock) => clock.startedAt! + 4, 0);
expect(host.closed).toEqual([9]);
expect(levelsOf(host).at(-1)).toEqual([5, 1, 1, 0]);
await loaded();
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 4 + LEAD, CUT],
[10, 1, 4 + LEAD, 0],
]);
expect(playbacks.named(5).map((p) => p.key)).toEqual([5, 10]);
});
it('a stop of its number stops the old one and the one waiting alike', async () => {
const { host, playbacks, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
playbacks.stopId(5);
expect([playbacks.has(5), host.closed.sort()]).toEqual([false, [5, 9]]);
});
it('where nothing of that number plays any more, starts as soon as it can', async () => {
const { host, playbacks, loaded } = fakeHost();
let asked = 0;
playbacks.switchTo(5, 9, 'song:b', song([]), '', () => (asked++, 99), 0);
await loaded();
expect([asked, levelsOf(host)]).toEqual([0, [[9, 1, LEAD, 0]]]);
});
});
describe('a stinger in time with a playing (#338)', () => {
it('lands where the playing heard now says, no sooner than something started now is heard', async () => {
const { playbacks, loaded, run } = fakeHost();
playbacks.start(5, 'song:a', song([]), '');
await loaded();
run(1);
const asked: PlayingClock[] = [];
expect(playbacks.landOn(5, (clock) => (asked.push(clock), clock.startedAt! + 2))).toBeCloseTo(
2 + LEAD,
9,
);
expect(asked.map((c) => [c.startedAt, +c.soonest.toFixed(9)])).toEqual([[LEAD, +(1 + LEAD).toFixed(9)]]);
expect(playbacks.landOn(5, () => 0.5)).toBeCloseTo(1 + LEAD, 9);
expect(playbacks.landOn(6, () => 2)).toBeNull();
});
it('a pattern asked to start there plays its first note there, its level rising from there', async () => {
const { host, playbacks, loaded, run } = fakeHost();
const notes: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(7, 'pattern:p', song(notes, { at: 2 }), '');
await loaded();
expect(host.levels.map(({ id, at }) => [id, at])).toEqual([[7, 2]]);
run(1);
expect(notes).toEqual([]);
run(1);
expect((notes as ScheduledNote[])[0]?.at).toBe(2);
});
});
/**
* A scene whose behavior plays a song on start: on its first track a two-note pattern at bar 1, on
* its second a sound at bar 2, the second track at -6 dB and panned left, the third soloed.
@@ -248,7 +438,17 @@ function recordingAudio() {
sequences: [] as { name: string; play: SequencePlay; owner: string }[],
notes: [] as { name: string; volume: number; owner: string; bus?: string; at?: number }[],
clips: [] as { file: string; clip: ClipTiming }[],
sounds: [] as { file: string; at?: number }[],
stopped: [] as string[],
play: ((file, _volume, _loop, _owner, _fx, _bus, at) => {
sink.sounds.push({ file, ...(at !== undefined && { at }) });
return 3;
}) satisfies AudioSink['play'],
/** A stinger lands on the clock of the playing it is synced to (#338), as a switch does. */
landOn: (id: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001];
return playing ? at(sink.clockOf(playing.play)) : null;
},
playSequence: (name: string, play: SequencePlay, owner: string) => {
sink.sequences.push({ name, play, owner });
return 1000 + sink.sequences.length;
@@ -260,6 +460,52 @@ function recordingAudio() {
playClip: (file: string, clip: ClipTiming) => (sink.clips.push({ file, clip }), 2),
stopSequence: (name: string, _letRing = false, fade = 0) => sink.stopped.push(`${name} (fade ${fade})`),
stopPlayback: (id: number, fade: number) => sink.stopped.push(`playback ${id} (fade ${fade})`),
/** Every playing heard 0.3 s into a clock that started it at 10 s (#338). */
clockOf: (play: SequencePlay): PlayingClock => ({
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
startedAt: 10,
now: 10.3,
soonest: 10.31,
}),
playingTimes: () =>
sink.sequences.map(({ name, play }, i) => ({
id: 1001 + i,
key: 1001 + i,
name,
heard: 0.3,
baked: play.bake !== undefined,
tempo: play.tempo,
from: 0,
loop: play.loop,
length: play.length,
})),
switched: [] as { id: number; name: string; play: SequencePlay; at: number; fade: number }[],
switchSequence: (id: number, name: string, play: SequencePlay, at: LandingAt, fade: number) => {
const from = sink.sequences[id - 1001]!.play;
sink.switched.push({ id, name, play, at: at(sink.clockOf(from)), fade });
return true;
},
turned: [] as { id: number; track: string; gain: number; fade: number; at: number }[],
setSongTrack: (id: number, track: string, gain: number, fade: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001]!.play;
if (playing.bake) return 'baked' as const;
sink.turned.push({ id, track, gain, fade, at: at(sink.clockOf(playing)) });
return 'set' as const;
},
lanes: [] as string[],
holdSongLane: (id: number, track: string, target: string, value: number, fade: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001]!.play;
if (playing.bake) return 'baked' as const;
sink.lanes.push(
`hold ${id} ${track} ${target} ${value} fade ${fade} at ${+at(sink.clockOf(playing)).toFixed(6)}`,
);
return 'set' as const;
},
releaseSongLane: (id: number, track: string, target: string, fade: number) => {
if (sink.sequences[id - 1001]!.play.bake) return 'baked' as const;
sink.lanes.push(`release ${id} ${track} ${target} fade ${fade}`);
return 'set' as const;
},
};
return sink;
}
@@ -368,3 +614,309 @@ describe('Stop song', () => {
]);
});
});
describe('Set song track and Switch song (#338)', () => {
/** 90 BPM: a beat is 2/3 s, a bar 8/3 s, played from 10 s on and heard 0.3 s in. */
const BEAT = 2 / 3;
it('turns a track of the playing on the next beat, in decibels on top of its level', () => {
const f = playingSong();
const turn = f.edit.addNode('SetSongTrack', {
track: f.tracks[1]!,
volume: -6,
fade: 0.5,
quantize: 'beat',
});
f.edit.connect(f.play, 'exec', turn, 'exec');
f.edit.connect(f.play, 'playback', turn, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.turned.map((t) => ({ ...t, at: +t.at.toFixed(9) }))).toEqual([
{
id: 1001,
track: trackBus(f.songId, f.tracks[1]!),
gain: dbToGain(-6),
fade: 0.5,
at: +(10 + BEAT).toFixed(9),
},
]);
});
it('a playing of its bake has no tracks: the log says to switch on "live tracks", which plays its notes', () => {
const baked = (live: boolean) => {
const f = playingSong({ live });
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
const file = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`;
song.bake = { file, print: bakePrint(f.project, song), peak: 0.5, bytes: 1, ...spans };
const turn = f.edit.addNode('SetSongTrack', { track: f.tracks[0]!, volume: -60, quantize: 'now' });
f.edit.connect(f.play, 'exec', turn, 'exec');
f.edit.connect(f.play, 'playback', turn, 'playback');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
return { audio, log: world.log };
};
const off = baked(false);
expect([off.audio.turned, off.log]).toEqual([
[],
['Set song track: song "Theme" plays its bake; switch on "live tracks" in Play song'],
]);
const on = baked(true);
expect([on.audio.sequences[0]?.play.bake, on.audio.turned[0], on.log]).toEqual([
undefined,
expect.objectContaining({ gain: 0, at: 10.31 }),
[],
]);
});
it('switches to a marker of the same song on the next marker, asked for as its Play song was', () => {
const f = playingSong();
f.stack.execute(new SetSongMarkersCommand(f.songId, [{ id: 'm1', name: 'Chorus', tick: BAR + Q }]));
const go = f.edit.addNode('SwitchSong', { marker: 'm1', quantize: 'marker', fade: 1.5 });
f.edit.connect(f.play, 'exec', go, 'exec');
f.edit.connect(f.play, 'playback', go, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [switched] = audio.switched;
// Bar 2 beat 2 is 2400 ticks in, 3 1/3 s at 90 BPM.
expect([switched?.id, switched?.name, switched?.fade, switched?.at]).toEqual([
1001,
`song:${f.songId}`,
1.5,
expect.closeTo(10 + 10 / 3, 9),
]);
expect(switched?.play).toMatchObject({ from: BAR + Q, volume: 0.8, loop: { start: BAR, end: 2 * BAR } });
expect(switched?.play.bake).toBeUndefined();
});
it('logs a marker the song does not have, and switches nothing', () => {
const f = playingSong();
const other = new CreateAssetCommand('song', 'Boss');
f.stack.execute(other);
f.stack.execute(new SetSongMarkersCommand(other.asset.id, [{ id: 'm9', name: 'Rage', tick: 0 }]));
const go = f.edit.addNode('SwitchSong', { marker: 'm9' });
f.edit.connect(f.play, 'exec', go, 'exec');
f.edit.connect(f.play, 'playback', go, 'playback');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect([audio.switched, world.log]).toEqual([[], ['Switch song: song "Theme" has no marker "Rage"']]);
});
});
describe('Set song lane and Release song lane (#338)', () => {
/** The song of `playingSong`, its first track with a lane of its volume and one of a parameter. */
function withLanes(params: Record<string, string | number | boolean> = {}) {
const f = playingSong(params);
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
const tracks = song.tracks.map((track, i) =>
i === 0
? {
...track,
automation: [
{
target: 'volume' as const,
points: [
{ tick: 0, value: 0 },
{ tick: BAR, value: -12 },
],
},
{ target: 'param:cutoff' as const, min: 100, max: 8000, points: [{ tick: 0, value: 400 }] },
],
}
: track,
);
f.stack.execute(new SetSongCommand(f.songId, { tracks }));
const steer = (type: string, fields: Record<string, string | number>) => {
const node = f.edit.addNode(type, { track: f.tracks[0]!, ...fields });
f.edit.connect(f.play, 'playback', node, 'playback');
return node;
};
return { ...f, song, steer };
}
it('holds a lane of a track on the next bar, held to its range, and lets it go', () => {
const f = withLanes();
const hold = f.steer('SetSongLane', { lane: 'volume', value: -80, fade: 0.5 });
const release = f.steer('ReleaseSongLane', { lane: 'volume', fade: 2 });
f.edit.connect(f.play, 'exec', hold, 'exec');
f.edit.connect(hold, 'exec', release, 'exec');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
// 90 BPM: the next bar is 8/3 s after the start at 10 s.
const bus = trackBus(f.songId, f.tracks[0]!);
expect(audio.lanes).toEqual([
`hold 1001 ${bus} volume -60 fade 0.5 at ${+(10 + 8 / 3).toFixed(6)}`,
`release 1001 ${bus} volume fade 2`,
]);
});
it('names a parameter with or without "param:"; a lane the track does not have is logged', () => {
const f = withLanes();
const bare = f.steer('SetSongLane', { lane: 'cutoff', value: 20000, quantize: 'now' });
const named = f.steer('SetSongLane', { lane: 'param:cutoff', value: 1000, quantize: 'now' });
const none = f.steer('SetSongLane', { lane: 'pan', value: 1 });
const gone = f.steer('ReleaseSongLane', { lane: 'drive' });
f.edit.connect(f.play, 'exec', bare, 'exec');
f.edit.connect(bare, 'exec', named, 'exec');
f.edit.connect(named, 'exec', none, 'exec');
f.edit.connect(none, 'exec', gone, 'exec');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
const bus = trackBus(f.songId, f.tracks[0]!);
const track = f.song.tracks[0]!.name;
expect([audio.lanes, world.log]).toEqual([
[
`hold 1001 ${bus} param:cutoff 8000 fade 1 at 10.31`,
`hold 1001 ${bus} param:cutoff 1000 fade 1 at 10.31`,
],
[
`Set song lane: track "${track}" of song "Theme" has no lane "pan"`,
`Release song lane: track "${track}" of song "Theme" has no lane "drive"`,
],
]);
});
it('a playing of its bake has no lanes: the log says to switch on "live tracks"', () => {
const f = withLanes();
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
f.song.bake = { file: 'theme.ogg', print: bakePrint(f.project, f.song), peak: 0.5, bytes: 1, ...spans };
const hold = f.steer('SetSongLane', { lane: 'volume', value: -6 });
const release = f.steer('ReleaseSongLane', { lane: 'volume' });
f.edit.connect(f.play, 'exec', hold, 'exec');
f.edit.connect(hold, 'exec', release, 'exec');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect([audio.lanes, world.log]).toEqual([
[],
[
'Set song lane: song "Theme" plays its bake; switch on "live tracks" in Play song',
'Release song lane: song "Theme" plays its bake; switch on "live tracks" in Play song',
],
]);
});
});
describe('a held lane of a playing (#338)', () => {
/** A pan lane from -1 to 1 over the first bar, each window it is planned in kept. */
function panned(windows: ScheduledAutomation[]): Partial<SequencePlay> {
const lane = {
bus: 'song:a:t',
target: 'pan' as const,
points: [
{ tick: 0, value: -1 },
{ tick: BAR, value: 1 },
],
};
return {
sequence: {
notes: [{ tick: 0, length: Q, note: 60, velocity: 1, instrument: 'piano' }],
lanes: [lane],
},
automate: (automation) => void windows.push(automation),
};
}
it('is held under the number of the playing, and kept by one that takes over from it', async () => {
const { playbacks, run, loaded } = fakeHost();
const old: ScheduledAutomation[] = [];
playbacks.start(5, 'song:a', song([], panned(old)), '');
await loaded();
run(0.5);
expect(playbacks.holdLane(5, 'song:a:t', 'pan', 0.5, 0.6, 0)).toBe(true);
expect(playbacks.holdLane(5, 'song:a:other', 'pan', 0.5, 0.6, 0)).toBe(false);
expect(playbacks.holdLane(6, 'song:a:t', 'pan', 0.5, 0.6, 0)).toBe(false);
const fresh: ScheduledAutomation[] = [];
playbacks.switchTo(5, 9, 'song:a', song([], panned(fresh)), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
run(2);
expect(fresh[0]?.marks[0]).toEqual({ at: 2 + LEAD, value: 0.5, jump: true });
for (const mark of fresh.flatMap((w) => w.marks)) expect(mark.value).toBe(0.5);
expect(playbacks.releaseLane(5, 'song:a:t', 'pan', 3, 0)).toBe(true);
});
it('a bake has no lanes to hold', async () => {
const { playbacks, loaded } = fakeHost(true);
const bake = { file: FILE, gain: 1, playback: { end: null, until: 2 * BAR, round: null } };
playbacks.start(5, 'song:a', song([], { ...panned([]), bake }), '');
await loaded();
expect(playbacks.holdLane(5, 'song:a:t', 'pan', 0.5, 1, 0)).toBe(false);
});
});
describe('Stingers: a sound, note or pattern synced to a playing (#338)', () => {
/** 90 BPM: a beat is 2/3 s, a bar 8/3 s, played from 10 s on and heard 0.3 s in. */
const BEAT = 2 / 3;
const assetOf = (f: ReturnType<typeof playingSong>, type: string) =>
f.project.assets.find((a) => a.type === type)!.id;
it('a pattern starts on the next beat of the song, at its tempo', () => {
const f = playingSong();
const sting = f.edit.addNode('PlayPattern', {
pattern: assetOf(f, 'pattern'),
loop: false,
quantize: 'beat',
});
f.edit.connect(f.play, 'exec', sting, 'exec');
f.edit.connect(f.play, 'playback', sting, 'sync');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [, stinger] = audio.sequences;
expect([stinger?.name, stinger?.play.tempo, stinger?.play.at]).toEqual([
`pattern:${assetOf(f, 'pattern')}`,
90,
expect.closeTo(10 + BEAT, 9),
]);
});
it('its bake is of its own tempo: at the song’s it plays its notes, at its own its bake', () => {
const bakedStinger = (tempo: number) => {
const f = playingSong();
const pattern = f.project.assets.find((a) => a.type === 'pattern') as PatternAsset;
f.stack.execute(new SetAssetPropertyCommand(pattern.id, 'tempo', tempo));
const spans = bakeSpans({ tempo, length: BAR, loop: { start: 0, end: BAR } });
pattern.bake = { file: 'riff.ogg', print: bakePrint(f.project, pattern), peak: 1, bytes: 1, ...spans };
const sting = f.edit.addNode('PlayPattern', { pattern: pattern.id, loop: false, quantize: 'beat' });
f.edit.connect(f.play, 'exec', sting, 'exec');
f.edit.connect(f.play, 'playback', sting, 'sync');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
return audio.sequences[1]?.play.bake?.file;
};
expect([bakedStinger(120), bakedStinger(90)]).toEqual([undefined, 'riff.ogg']);
});
it('a sound on the next bar, a note at once: as soon as it can be heard', () => {
const f = playingSong();
const sound = f.edit.addNode('PlaySound', { sound: assetOf(f, 'sound') });
const note = f.edit.addNode('PlayNote', { instrument: f.lead.id, duration: 0.5, quantize: 'now' });
f.edit.connect(f.play, 'exec', sound, 'exec');
f.edit.connect(sound, 'exec', note, 'exec');
f.edit.connect(f.play, 'playback', sound, 'sync');
f.edit.connect(f.play, 'playback', note, 'sync');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.sounds.map((s) => [s.file, s.at])).toEqual([[FILE, expect.closeTo(10 + 4 * BEAT, 9)]]);
expect(audio.notes.map((n) => n.at)).toEqual([10.31]);
});
it('unsynced, or synced to nothing heard, it starts at once, a pattern at its own tempo', () => {
const f = playingSong();
const own = f.edit.addNode('PlayPattern', { pattern: assetOf(f, 'pattern'), loop: false });
const lost = f.edit.addNode('PlayPattern', { pattern: assetOf(f, 'pattern'), loop: false, sync: 77 });
const sound = f.edit.addNode('PlaySound', { sound: assetOf(f, 'sound'), sync: 77 });
f.edit.connect(f.play, 'exec', own, 'exec');
f.edit.connect(own, 'exec', lost, 'exec');
f.edit.connect(lost, 'exec', sound, 'exec');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.sequences.slice(1).map((s) => [s.play.tempo, s.play.at])).toEqual([
[120, undefined],
[120, undefined],
]);
expect(audio.sounds).toEqual([{ file: FILE }]);
});
});
+206
View File
@@ -0,0 +1,206 @@
/**
* Swing (#372): the second eighth or sixteenth of each pair is heard later, the notes stay where they
* are written. Counted on 120 BPM, where an eighth is 0.25 s.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { PatternAsset, SongAsset } from '../../src/core/model/Asset';
import { findAssetOf } from '../../src/core/model/Project';
import { patternSequence, patternSwing, type PatternNote } from '../../src/core/music/pattern';
import {
type ScheduledNote,
Scheduler,
type SchedulerHost,
LEAD,
INTERVAL,
} from '../../src/core/music/Scheduler';
import { notesIn, type Sequence } from '../../src/core/music/sequence';
import { itemNotes, newItem, songSequence } from '../../src/core/music/song';
import { swungNote, swungTick } from '../../src/core/music/swing';
import { secondsOfTicks } from '../../src/core/music/time';
import { makeProject } from '../helpers';
const Q = 480;
const E = Q / 2;
const eighths = { pair: Q, bar: 4 * Q };
const note = (id: string, tick: number, length = E, n = 60): PatternNote => ({
id,
tick,
length,
note: n,
velocity: 1,
});
/** A pattern "Groove" of one bar with an instrument, its notes as given, swing as given. */
function makeGroove(notes: PatternNote[], swing?: number, grid?: string) {
const f = makeProject();
const ins = new CreateAssetCommand('instrument', 'Lead');
const pattern = new CreateAssetCommand('pattern', 'Groove');
f.stack.execute(ins);
f.stack.execute(pattern);
const set = (key: string, value: string | number) =>
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, key, value));
set('instrument', ins.asset.id);
set('bars', 1);
if (swing !== undefined) set('swing', swing);
if (grid !== undefined) set('swingGrid', grid);
f.stack.execute(new SetPatternNotesCommand(pattern.asset.id, notes));
const groove = () => findAssetOf(f.project, pattern.asset.id, 'pattern') as PatternAsset;
return { ...f, groove, set };
}
/** Plays a sequence on a scheduler at 120 BPM for `seconds`, and gives when each note was planned, from the start. */
function heard(sequence: Sequence, seconds: number, loop: { start: number; end: number } | null = null) {
const played: ScheduledNote[] = [];
const clock = { time: 10, pump: null as (() => void) | null };
const sink: SchedulerHost = {
now: () => clock.time,
latency: () => 0,
play: (scheduled) => played.push(scheduled),
playing: () => true,
stop: () => {},
wake: (pump) => {
clock.pump = pump;
return () => (clock.pump = null);
},
};
const scheduler = new Scheduler(sink, () => sequence, { tempo: 120, loop });
scheduler.start();
for (let t = 0; t < seconds; t += INTERVAL) {
clock.time += INTERVAL;
clock.pump?.();
}
return played.map((n) => ({ at: n.at - 10 - LEAD, duration: n.duration, note: n.note }));
}
describe('swing of a pattern (#372)', () => {
it('plays the second eighth of a pair 1/12 s late at 100 %, 1/24 s at 50 %, straight at 0 %', () => {
const notes = [note('a', 0), note('b', E), note('c', Q), note('d', Q + E)];
const at = (swing: number) =>
heard(patternSequence(makeGroove(notes, swing).groove()), 1).map((n) => n.at);
const straight = at(0);
expect(straight).toHaveLength(4);
[0, 0.25, 0.5, 0.75].forEach((s, i) => expect(straight[i]).toBeCloseTo(s, 9));
const full = at(100);
[0, 1 / 3, 0.5, 0.5 + 1 / 3].forEach((s, i) => expect(full[i]).toBeCloseTo(s, 9));
expect(full[1]! - straight[1]!).toBeCloseTo(1 / 12, 9);
const half = at(50);
expect(half[1]! - straight[1]!).toBeCloseTo(1 / 24, 9);
// Long – short: the first of a pair now lasts 1/3 s until the next, the second 1/6 s.
expect(full[1]! - full[0]!).toBeCloseTo(1 / 3, 9);
expect(full[2]! - full[1]!).toBeCloseTo(1 / 6, 9);
});
it('holds each note from its swung start to its swung end', () => {
const played = heard(patternSequence(makeGroove([note('a', 0), note('b', E)], 100).groove()), 1);
expect(played.map((n) => n.duration)).toEqual([secondsOfTicks(320, 120), secondsOfTicks(160, 120)]);
});
it('pairs sixteenths on a 1/16 swing grid', () => {
const sixteenth = Q / 4;
const g = makeGroove([note('a', sixteenth, sixteenth), note('b', E, sixteenth)], 100, '1/16');
const [first, second] = heard(patternSequence(g.groove()), 1).map((n) => n.at);
expect(first! - secondsOfTicks(sixteenth, 120)).toBeCloseTo(1 / 24, 9);
// The eighth starts a new pair of sixteenths: it stays.
expect(second).toBeCloseTo(0.25, 9);
});
it('moves notes inside a pair in proportion, and leaves the start of every pair', () => {
const swing = { amount: 1, ...eighths };
// First half stretched by 4/3, second squeezed by 2/3.
expect([0, 120, 240, 360, 480, 600].map((t) => swungTick(t, swing))).toEqual([
0, 160, 320, 400, 480, 640,
]);
expect(swungNote(120, 240, swing)).toEqual({ late: 40, length: 240 });
// A note over a whole pair keeps its length.
expect(swungNote(0, Q, swing)).toEqual({ late: 0, length: Q });
expect(swungTick(240, { ...swing, amount: 0 })).toBe(240);
});
it('counts pairs from each bar and plays a pair the bar cuts short straight: the last eighth of 7/8', () => {
const swing = { amount: 1, pair: Q, bar: 7 * E };
expect([E, 3 * E, 5 * E, 6 * E, 6 * E + 120, 7 * E, 8 * E].map((t) => swungTick(t, swing))).toEqual([
320,
3 * E + 80,
5 * E + 80,
6 * E,
6 * E + 120,
7 * E,
8 * E + 80,
]);
const g = makeGroove([], 100);
g.set('sign', '7/8');
expect(patternSwing(g.groove().props)).toEqual(swing);
});
it('holds the amount to 0 to 100 % and an unknown grid to 1/8; none plays as before, without `late`', () => {
const g = makeGroove([note('a', E)], 250, '1/3');
expect(patternSwing(g.groove().props)).toEqual({ amount: 1, ...eighths });
// As a file may hold it, past what the inspector lets through.
expect(patternSwing({ swing: 250, swingGrid: '1/16' })).toEqual({ amount: 1, pair: E, bar: 4 * Q });
g.set('swing', -20);
expect(patternSwing(g.groove().props)).toBeNull();
g.set('swing', 0);
// What a bake is printed from stays as it was before swing.
expect(patternSequence(g.groove()).notes).toEqual([
{ tick: E, length: E, note: 60, velocity: 1, instrument: expect.any(String) as string },
]);
});
it('keeps the note in its place in the sequence and its loop: only where it is heard moves', () => {
const g = makeGroove([note('a', 0), note('b', E)], 100);
const sequence = patternSequence(g.groove());
expect(sequence.notes.map((n) => [n.tick, n.late])).toEqual([
[0, undefined],
[E, 80],
]);
// Round a one-bar loop: every round the same, nothing given on as `late`.
const due = notesIn(sequence, 0, { start: 0, end: 4 * Q }, 0, 8 * Q);
expect(due.map((n) => [n.tick, n.at])).toEqual([
[0, 0],
[E, 320],
[0, 4 * Q],
[E, 4 * Q + 320],
]);
expect(due.some((n) => 'late' in n)).toBe(false);
// A window ending between a note's place and where it is heard still gives it, once.
expect(notesIn(sequence, 0, null, 0, E + 1).map((n) => n.at)).toEqual([0, 320]);
expect(notesIn(sequence, 0, null, E + 1, Q).map((n) => n.at)).toEqual([]);
});
it('starts a note a tick before the end of a loop cut in the middle of a pair, not past it', () => {
const g = makeGroove([note('a', E + 100, 40)], 100);
const sequence = patternSequence(g.groove());
// 340 is heard at 320 + 100 · 2/3 ≈ 386.7, behind a loop ending at 360.
const [first] = notesIn(sequence, 0, { start: 0, end: 360 }, 0, 360);
expect([first!.at, first!.length]).toEqual([359, 1]);
});
});
describe('swing of a pattern in a song (#372)', () => {
it('plays a pattern item with its pattern’s swing, round after round, drawn without', () => {
const g = makeGroove([note('a', 0), note('b', E)], 100);
const song = new CreateAssetCommand('song', 'Tune');
g.stack.execute(song);
const tune = () => findAssetOf(g.project, song.asset.id, 'song') as SongAsset;
const track = tune().tracks[0]!.id;
const item = newItem({ track, kind: 'pattern', asset: g.groove().id, tick: 0, length: 8 * Q });
g.stack.execute(new SetSongCommand(song.asset.id, { items: [item] }));
const notes = songSequence(g.project, tune()).notes;
expect(notes.map((n) => [n.tick, n.late ?? 0, n.length])).toEqual([
[0, 0, 320],
[E, 80, 160],
[4 * Q, 0, 320],
[4 * Q + E, 80, 160],
]);
expect(itemNotes(g.groove(), tune().items[0]!).map((n) => [n.tick, n.length, n.late])).toEqual([
[0, E, undefined],
[E, E, undefined],
[4 * Q, E, undefined],
[4 * Q + E, E, undefined],
]);
});
});
+310
View File
@@ -0,0 +1,310 @@
/**
* The time of the video a sprite wears, as a run counts it (#410): `On video finished`, `Video time`
* and the nodes that play, pause and seek it.
*/
import { describe, expect, it } from 'vitest';
import { CreateVideoCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
advanceVideoClock,
clipTimeOf,
newVideoClock,
steerVideoClock,
} from '../../src/core/model/videoClock';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { VideoSystem } from '../../src/runtime/systems/VideoSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
describe('the clock of a video', () => {
it('goes round with Loop and holds at the end without, past the end and before the start', () => {
expect(clipTimeOf(2.5, 2, true)).toBeCloseTo(0.5, 9);
expect(clipTimeOf(2.5, 2, false)).toBe(2);
expect(clipTimeOf(-1, 2, true)).toBe(0);
expect(clipTimeOf(Number.NaN, 2, false)).toBe(0);
// A length not known yet bounds only at the start.
expect(clipTimeOf(7, 0, false)).toBe(7);
});
it('reaches the end of a clip without Loop once a pass and stops there', () => {
const clock = newVideoClock('v');
const ends = [];
for (let i = 0; i < 30; i++) ends.push(advanceVideoClock(clock, 0.1, 2, false));
expect(ends.filter(Boolean)).toHaveLength(1);
expect(ends.indexOf(true)).toBe(19);
expect(clock).toMatchObject({ time: 2, playing: false });
});
it('never ends with Loop: it goes round', () => {
const clock = newVideoClock('v');
const ends = [];
for (let i = 0; i < 50; i++) ends.push(advanceVideoClock(clock, 0.1, 2, true));
expect(ends.some(Boolean)).toBe(false);
expect(clock.time).toBeCloseTo(1, 6);
expect(clock.playing).toBe(true);
});
it('a held clock does not move and does not end', () => {
const clock = { ...newVideoClock('v'), time: 1.95 };
steerVideoClock(clock, { kind: 'pause' }, 2, false);
expect(advanceVideoClock(clock, 0.1, 2, false)).toBe(false);
expect(clock.time).toBe(1.95);
});
it('a seek past the end goes round with Loop and holds the last picture without', () => {
const looping = newVideoClock('v');
steerVideoClock(looping, { kind: 'seek', time: 4.5 }, 2, true);
expect(looping.time).toBeCloseTo(0.5, 9);
const once = newVideoClock('v');
steerVideoClock(once, { kind: 'seek', time: 4.5 }, 2, false);
expect(once.time).toBe(2);
// Still playing, it is at its end: the next step tells so, once.
expect(advanceVideoClock(once, 0.1, 2, false)).toBe(true);
expect(advanceVideoClock(once, 0.1, 2, false)).toBe(false);
});
it('Play at the end of a clip without Loop starts it over, and it ends again', () => {
const clock = newVideoClock('v');
for (let i = 0; i < 25; i++) advanceVideoClock(clock, 0.1, 2, false);
steerVideoClock(clock, { kind: 'play' }, 2, false);
expect(clock).toMatchObject({ time: 0, playing: true });
const ends = [];
for (let i = 0; i < 25; i++) ends.push(advanceVideoClock(clock, 0.1, 2, false));
expect(ends.filter(Boolean)).toHaveLength(1);
});
});
interface Stage extends SceneFixture {
videoId: string;
/** The placed sprite wearing the video, and a second one wearing the same. */
screen: string;
other: string;
}
/** A sprite "Screen" wearing a video two seconds long, placed twice; Loop as asked. */
function stage(loop: boolean): Stage {
const f = makeSpriteScene('Screen');
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 2,
fps: 30,
audio: false,
});
f.stack.execute(video);
f.stack.execute(new SetAssetPropertyCommand(video.asset.id, 'loop', loop));
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', video.asset.id));
return {
...f,
videoId: video.asset.id,
screen: place(f, f.spriteId, { x: 0, y: 0 }),
other: place(f, f.spriteId, { x: 400, y: 0 }),
};
}
/** A behavior below `parent`: `On video finished` (Video as given) logs "done". */
function listener(s: Stage, parent: string, video = ''): ReturnType<typeof graphEditor> {
const behavior = makeBehavior(s, 'Watch');
place(s, behavior.id, { x: 0, y: 0 }, parent);
const edit = graphEditor(s, behavior);
const finished = edit.addNode('OnVideoFinished', { video });
const log = edit.addNode('Log', { message: 'done' });
edit.connect(finished, 'exec', log, 'exec');
return edit;
}
function play(s: Stage, steersVideos = true) {
const world = World.instantiate(s.project, s.sceneId);
const input = new Input();
addGameSystems(world, s.project, input, { steersVideos });
return {
world,
input,
at: (objectId: string) => world.findByObjectId(objectId)!,
done: () => world.log.filter((line) => line === 'done').length,
frame(times = 1) {
for (let i = 0; i < times; i++) {
world.tick(0.1);
input.endFrame();
}
},
};
}
describe('a video played in a game', () => {
it('tells once when a free video without Loop reaches its end, counted from when its sprite appeared', () => {
const s = stage(false);
listener(s, s.screen);
const run = play(s);
run.frame(19);
expect(run.done()).toBe(0);
run.frame();
expect(run.done()).toBe(1);
run.frame(30);
expect(run.done()).toBe(1);
// Counted only: a free sprite stays free.
expect(run.at(s.screen).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
});
it('never tells with Loop', () => {
const s = stage(true);
listener(s, s.screen);
const run = play(s);
run.frame(60);
expect(run.done()).toBe(0);
});
it('hears only the sprites below its owner, and the video it names', () => {
const s = stage(false);
listener(s, s.screen, 'some-other-video');
const run = play(s);
run.frame(25);
expect(run.done()).toBe(0);
const t = stage(false);
listener(t, t.screen, t.videoId);
const again = play(t);
again.frame(25);
// Two sprites wear it; the owner's is the one heard.
expect(again.done()).toBe(1);
});
it('a paused game holds the clock', () => {
const s = stage(false);
listener(s, s.screen);
const run = play(s);
run.frame(10);
run.world.paused = true;
run.frame(20);
expect(run.done()).toBe(0);
run.world.paused = false;
run.frame(10);
expect(run.done()).toBe(1);
});
it('pause, seek and play steer the owner’s video on a player of its own and leave the other one free', () => {
const s = stage(false);
const edit = listener(s, s.screen);
const pause = edit.addNode('PauseVideo');
const seek = edit.addNode('SeekVideo', { time: 0.5 });
const resume = edit.addNode('PlayVideo');
const p = edit.addNode('OnKeyDown', { key: 'p' });
const k = edit.addNode('OnKeyDown', { key: 'k' });
const g = edit.addNode('OnKeyDown', { key: 'g' });
edit.connect(p, 'exec', pause, 'exec');
edit.connect(k, 'exec', seek, 'exec');
edit.connect(g, 'exec', resume, 'exec');
const run = play(s);
run.frame(10);
run.input.press('p');
run.frame();
const screen = run.at(s.screen);
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('steered');
const held = screen.props[VIDEO_TIME_KEY] as number;
// The clock moved on with this frame before the behavior held it.
expect(held).toBeCloseTo(1.1, 6);
run.input.release('p');
run.frame(30);
expect(screen.props[VIDEO_TIME_KEY]).toBe(held);
expect(run.done()).toBe(0);
expect(run.at(s.other).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
run.input.press('k');
run.frame();
run.input.release('k');
expect(screen.props[VIDEO_TIME_KEY]).toBe(0.5);
run.input.press('g');
run.frame();
run.input.release('g');
run.frame(14);
expect(run.done()).toBe(0);
run.frame();
expect(run.done()).toBe(1);
expect(screen.props[VIDEO_TIME_KEY]).toBe(2);
});
it('a driven video is left alone, says so, and ends when its Video time passes the end', () => {
const s = stage(false);
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_CLOCK_KEY, 'driven'));
const edit = listener(s, s.screen);
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseVideo'), 'exec');
const run = play(s);
run.frame();
expect(run.world.log.filter((l) => l.includes('driven Video clock'))).toHaveLength(1);
const screen = run.at(s.screen);
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('driven');
screen.props[VIDEO_TIME_KEY] = 1.9;
run.frame();
expect(run.done()).toBe(0);
screen.props[VIDEO_TIME_KEY] = 2.05;
run.frame();
screen.props[VIDEO_TIME_KEY] = 2.3;
run.frame();
expect(run.done()).toBe(1);
});
it('a driven video already past its end when its sprite appears does not tell so', () => {
const s = stage(false);
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_CLOCK_KEY, 'driven'));
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_TIME_KEY, 5));
listener(s, s.screen);
const run = play(s);
run.frame(5);
expect(run.done()).toBe(0);
});
it('the editor run steers nothing and says so', () => {
const s = stage(false);
const edit = listener(s, s.screen);
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseVideo'), 'exec');
const run = play(s, false);
run.frame(3);
expect(run.world.log.filter((l) => l.includes('ignored in the editor run'))).toHaveLength(1);
expect(run.at(s.screen).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
});
it('Video time reads the clock: where it stands in the clip, its length, whether it moves', () => {
const s = stage(true);
const world = World.instantiate(s.project, s.sceneId);
const videos = new VideoSystem();
world.addSystem(videos);
for (let i = 0; i < 25; i++) world.tick(0.1);
const screen = world.findByObjectId(s.screen)!;
const reading = videos.reading(world, screen)!;
expect(reading.time).toBeCloseTo(0.5, 6);
expect(reading).toMatchObject({ length: 2, playing: true });
videos.steer(world, screen, { kind: 'pause' }, {});
world.tick(0.1);
expect(videos.reading(world, screen)!.playing).toBe(false);
screen.props.texture = '';
expect(videos.reading(world, screen)).toBeNull();
});
it('another video on a steered sprite starts its clock over, still steered; none at all frees it', () => {
const s = stage(false);
const world = World.instantiate(s.project, s.sceneId);
const videos = new VideoSystem();
world.addSystem(videos);
const screen = world.findByObjectId(s.screen)!;
world.tick(0.1);
videos.steer(world, screen, { kind: 'seek', time: 1.5 }, {});
const second = new CreateVideoCommand('Outro', FILE, {
width: 320,
height: 180,
duration: 3,
fps: 30,
audio: false,
});
s.stack.execute(second);
screen.props.texture = second.asset.id;
world.tick(0.1);
expect(screen.video).toMatchObject({ ref: second.asset.id, steered: true, playing: true });
expect(screen.props[VIDEO_TIME_KEY]).toBeCloseTo(0.1, 9);
screen.props.texture = '';
world.tick(0.1);
expect(screen.video).toBeUndefined();
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('free');
});
});
+172 -2
View File
@@ -1,12 +1,19 @@
import { afterEach, describe, expect, it } from 'vitest';
import { CreateSoundCommand } from '../../src/core/commands/assetCommands';
import type { CompositionAsset, PatternAsset, SongAsset, SoundAsset } from '../../src/core/model/Asset';
import type {
BehaviorAsset,
CompositionAsset,
PatternAsset,
SongAsset,
SoundAsset,
SoundFxAsset,
} from '../../src/core/model/Asset';
import { setSoundMeasure } from '../../src/core/model/composition';
import { ROUTING_ID } from '../../src/core/model/Project';
import { settleRouting } from '../../src/core/music/projectRouting';
import { TO_COMPOSITION_BUS } from '../../src/core/properties/buses';
import { runTool } from '../../src/editor/agent/agentTools';
import { makeSpriteScene, type SceneFixture } from '../helpers';
import { graphEditor, makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost';
/** The commands of #429 stage 5d: patterns, songs, sends, clips of sounds and compositions. */
@@ -217,6 +224,169 @@ describe('songs', () => {
expect(noFx.text).toContain('is no Sound FX asset');
});
it('adds, renames, moves and removes markers of a song (#338), and get_asset lists them', async () => {
const fixture = makeSpriteScene();
const { Theme } = await made(fixture, { Theme: 'song' });
const song = () => byId<SongAsset>(fixture, Theme);
const done = await run(fixture, [
{ command: 'add_song_marker', args: { asset: Theme, tick: 7680, name: 'Boss' }, as: 'boss' },
{ command: 'add_song_marker', args: { asset: Theme, tick: 0 } },
{ command: 'change_song_marker', args: { asset: Theme, marker: '$boss', tick: 3840 } },
]);
expect(done.result.isError).toBeUndefined();
expect(song().markers!.map(({ name, tick }) => [name, tick])).toEqual([
['Marker 1', 0],
['Boss', 3840],
]);
const listed = (await read(fixture, Theme)).markers as { id: string; name: string; tick: number }[];
expect(listed.map((m) => m.name)).toEqual(['Marker 1', 'Boss']);
const boss = song().markers![1]!.id;
const blank = await run(fixture, [
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: ' ' } },
]);
expect(blank.text).toContain('A marker needs a name');
const renamed = await run(fixture, [
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: 'Fight' } },
]);
expect(renamed.result.isError).toBeUndefined();
expect(song().markers![1]).toEqual({ id: boss, name: 'Fight', tick: 3840 });
const gone = await run(fixture, [
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
]);
expect(gone.result.isError).toBeUndefined();
expect(song().markers!.map((m) => m.name)).toEqual(['Marker 1']);
const missing = await run(fixture, [
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
]);
expect(missing.text).toContain(`Theme has no marker ${boss}`);
});
it('sets the volume lane of a track in tick order with its easings, refuses what is out of range, and clears it with []', async () => {
const fixture = makeSpriteScene();
const { Theme, Bend } = await made(fixture, { Theme: 'song', Bend: 'curve' });
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const set = await run(
fixture,
[
{
command: 'set_song_lane',
args: {
asset: Theme,
track,
target: 'volume',
points: [
{ tick: 1920, value: -24, ease: 'step' },
{ tick: 0, value: -6, ease: 'sine-in' },
{ tick: 1920, value: 0, ease: `curve:${Bend}` },
{ tick: 3840, value: -12, ease: 'linear' },
],
},
},
],
true,
);
expect(set.result.isError).toBeUndefined();
expect(set.shown).toEqual([{ kind: 'asset', asset: Theme, open: true }]);
// In tick order, two on one tick as given (a jump); linear is stored as no easing.
expect(song().tracks[0]!.automation).toEqual([
{
target: 'volume',
points: [
{ tick: 0, value: -6, ease: 'sine-in' },
{ tick: 1920, value: -24, ease: 'step' },
{ tick: 1920, value: 0, ease: `curve:${Bend}` },
{ tick: 3840, value: -12 },
],
},
]);
const lanes = (await read(fixture, Theme)).tracks as { automation?: unknown[] }[];
expect(lanes[0]!.automation).toHaveLength(1);
const point = (value: number, ease?: string) => [{ tick: 0, value, ...(ease && { ease }) }];
const refused = async (target: string, points: object[], extra: object = {}) =>
(
await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target, points, ...extra } },
])
).text;
expect(await refused('volume', point(13))).toContain('value at tick 0 has to be from -60 to 12');
expect(await refused('pan', point(-2))).toContain('from -1 to 1');
expect(await refused('volume', point(0, 'wobbly'))).toContain('"wobbly" is no easing');
expect(await refused('volume', point(0, 'curve:nope'))).toContain('nope is no Curve asset');
expect(await refused('loudness', point(0))).toContain('is no lane');
expect(await refused('volume', point(0), { min: 0, max: 1 })).toContain('Only a parameter lane');
expect(song().tracks[0]!.automation![0]!.points).toHaveLength(4);
const nothing = await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target: 'pan' } },
]);
expect(nothing.text).toContain('Give the points');
const cleared = await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target: 'volume', points: [] } },
]);
expect(cleared.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toBeUndefined();
});
it('adds a parameter lane only for a parameter on the track, round its default, and get_asset lists what a lane can move', async () => {
const fixture = makeSpriteScene();
const { Theme, Filter } = await made(fixture, { Theme: 'song', Filter: 'soundfx' });
const fx = byId<SoundFxAsset>(fixture, Filter);
const edit = graphEditor(fixture, fx as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const gain = edit.addNode('SoundFxGain');
const output = edit.addNode('SoundFxOutput');
input.ports = [{ key: 'cut', kind: 'number', default: 800 }];
edit.connect(input, 'out', gain, 'in');
edit.connect(input, 'cut', gain, 'gain');
edit.connect(gain, 'out', output, 'in');
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const lane = (args: object) => ({
command: 'set_song_lane',
args: { asset: Theme, track, target: 'param:cut', ...args },
});
const none = await run(fixture, [lane({})]);
expect(none.text).toContain('Nothing on Track 1 has a parameter to move');
expect(((await read(fixture, Theme)).tracks as object[])[0]).not.toHaveProperty('params');
await run(fixture, [{ command: 'change_song_track', args: { asset: Theme, track, fx: Filter } }]);
expect(((await read(fixture, Theme)).tracks as { params?: object }[])[0]!.params).toEqual([
{ name: 'cut', default: 800 },
]);
const other = await run(fixture, [{ ...lane({}), args: { asset: Theme, track, target: 'param:tone' } }]);
expect(other.text).toContain('Nothing on Track 1 has a parameter tone – it has cut');
// Without a range it gets one round the default, and keeps it without a point.
const added = await run(fixture, [lane({})]);
expect(added.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toEqual([{ target: 'param:cut', points: [], min: 0, max: 2000 }]);
const pointed = await run(fixture, [lane({ points: [{ tick: 0, value: 1500 }] })]);
expect(pointed.result.isError).toBeUndefined();
const over = await run(fixture, [lane({ points: [{ tick: 0, value: 2500 }] })]);
expect(over.text).toContain('from 0 to 2000');
// A narrower range holds the points it had.
await run(fixture, [lane({ min: 100, max: 1000 })]);
expect(song().tracks[0]!.automation).toEqual([
{ target: 'param:cut', points: [{ tick: 0, value: 1000 }], min: 100, max: 1000 },
]);
expect((await run(fixture, [lane({ min: 5 })])).text).toContain('Give min and max');
expect((await run(fixture, [lane({ min: 5, max: 5 })])).text).toContain('max has to be above min');
// A lane there is stays settable when its parameter is gone from the track.
await run(fixture, [{ command: 'change_song_track', args: { asset: Theme, track, fx: '' } }]);
expect((await run(fixture, [lane({ min: 0, max: 10 })])).result.isError).toBeUndefined();
expect((await run(fixture, [lane({ remove: true, min: 0 })])).text).toContain('remove takes no points');
const removed = await run(fixture, [lane({ remove: true })]);
expect(removed.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toBeUndefined();
expect((await run(fixture, [lane({ remove: true })])).text).toContain('Track 1 has no param:cut lane');
});
it('sets the send of a song link and of a master routing link, where a sound only sends post', async () => {
const fixture = makeSpriteScene();
const { Theme } = await made(fixture, { Theme: 'song' });
+7 -7
View File
@@ -41,7 +41,6 @@ import { MAX_PX_PER_TICK, tickToX, zoomTime } from '../../src/editor/music/timeV
const BAR = 1920;
const TRACKS = ['t1', 't2', 't3'];
const rows = new Map(TRACKS.map((id, i) => [id, i]));
const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false, fx: '' }));
function item(
id: string,
@@ -175,14 +174,14 @@ describe('where things are in the Song tab (#334)', () => {
const items = [item('a', 't1', 0, BAR), item('b', 't2', BAR, BAR)];
it('finds an item and which end of it is under the pointer', () => {
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' });
expect(itemAt(view, items, tracks, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' });
expect(itemAt(view, items, tracks, { x: 194, y: TRACK_HEIGHT * 1.5 })).toEqual({
expect(itemAt(view, items, rows, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' });
expect(itemAt(view, items, rows, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' });
expect(itemAt(view, items, rows, { x: 194, y: TRACK_HEIGHT * 1.5 })).toEqual({
id: 'b',
part: 'start',
});
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull();
expect(itemsWithin(view, items, tracks, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([
expect(itemAt(view, items, rows, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull();
expect(itemsWithin(view, items, rows, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([
'a',
'b',
]);
@@ -202,7 +201,8 @@ describe('where things are in the Song tab (#334)', () => {
expect(headPartAt(solo.x + 4, solo.y + 4)).toBe('solo');
expect(headPartAt(volume.x + 10, volume.y + 4)).toBe('volume');
expect(headPartAt(pan.x + 10, pan.y + 4)).toBe('pan');
expect(headPartAt(20, 8)).toBe('name');
expect(headPartAt(30, 8)).toBe('name');
expect(headPartAt(10, 12)).toBe('lanes');
expect(sliderValue(volume.x, volume, -60, 6, 0.5)).toBe(-60);
expect(sliderValue(volume.x + volume.w, volume, -60, 6, 0.5)).toBe(6);
expect(sliderValue(pan.x + pan.w / 2, pan, -1, 1, 0.05)).toBe(0);
+276
View File
@@ -0,0 +1,276 @@
import { describe, expect, it, vi } from 'vitest';
import type { AutomationPoint } from '../../src/core/music/automation';
import type { SongTrack } from '../../src/core/music/song';
import type { Project } from '../../src/core/model/Project';
import type { MenuEntry, MenuItem, MenuSubmenu } from '../../src/ui';
import { easingEntries } from '../../src/editor/song/easingMenu';
import {
addedPoint,
draggedPoint,
easedPoint,
lanePoints,
withLanePoints,
withoutLane,
withoutPoint,
withParamLane,
} from '../../src/editor/song/laneEdit';
import {
LANE_INSET,
laneKindOf,
laneLabel,
laneStep,
laneValue,
laneValueText,
laneWay,
laneY,
pointAt,
shownLanes,
songRows,
trackLanes,
trackRows,
} from '../../src/editor/song/laneView';
import { trackAtY, type SongView } from '../../src/editor/song/songView';
function track(id: string, automation?: SongTrack['automation']): SongTrack {
return {
id,
name: id,
volume: -6,
pan: 0,
mute: false,
solo: false,
fx: '',
...(automation && { automation }),
};
}
const VOLUME: AutomationPoint[] = [
{ tick: 0, value: -12 },
{ tick: 960, value: 0, ease: 'step' },
{ tick: 1920, value: -24 },
];
describe('the rows of the Song tab with automation lanes (#337)', () => {
const tracks = [track('a'), track('b', [{ target: 'pan', points: [{ tick: 0, value: 0.5 }] }]), track('c')];
it('shows the lanes with points until a track is opened or closed by hand', () => {
expect(shownLanes(tracks[1]!, new Map())).toEqual(['pan']);
expect(shownLanes(tracks[0]!, new Map())).toEqual([]);
// Opened by hand the lanes come in their own order, closed by hand even one with points goes.
expect(shownLanes(tracks[0]!, new Map([['a', ['pan', 'volume'] as const]]))).toEqual(['volume', 'pan']);
expect(shownLanes(tracks[1]!, new Map([['b', []]]))).toEqual([]);
});
it('puts each lane right under its track, and finds a track by the row of its own', () => {
const open = new Map([['a', ['volume', 'pan'] as const]]);
const rows = songRows(tracks, open);
expect(rows).toEqual([
{ track: 0, lane: null },
{ track: 0, lane: 'volume' },
{ track: 0, lane: 'pan' },
{ track: 1, lane: null },
{ track: 1, lane: 'pan' },
{ track: 2, lane: null },
]);
expect([...trackRows(tracks, rows)]).toEqual([
['a', 0],
['b', 3],
['c', 5],
]);
// A lane is as high as a track, so the rows stay evenly spaced.
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0, trackHeight: 40 };
expect(trackAtY(view, rows.length, 3 * 40 + 1)).toBe(3);
});
});
describe('the way of a lane (#337)', () => {
const view: SongView = { panX: 10, pxPerTick: 0.1, panY: 0, trackHeight: 52 };
it('draws a value from the bottom of its range to the top, and reads it back to a step', () => {
expect(laneY('volume', -60, 52)).toBe(52 - LANE_INSET);
expect(laneY('volume', 12, 52)).toBe(LANE_INSET);
expect(laneY('pan', 0, 52)).toBe(26);
// Past the range a value is drawn at the edge.
expect(laneY('pan', 3, 52)).toBe(LANE_INSET);
expect(laneValue('pan', 26, 52)).toBe(0);
expect(laneValue('volume', laneY('volume', -6.3, 52), 52)).toBeCloseTo(-6.3, 6);
expect(laneValue('volume', -100, 52)).toBe(12);
expect(laneValue('pan', laneY('pan', 0.333, 52), 52)).toBeCloseTo(0.33, 6);
});
it('finds the point under the pointer, the last drawn first', () => {
const points = [
{ tick: 0, value: 0 },
{ tick: 0, value: 0 },
{ tick: 960, value: 6 },
];
const y = laneY('volume', 0, 52);
expect(pointAt(view, 'volume', points, 10, y)).toBe(1);
expect(pointAt(view, 'volume', points, 10 + 96 + 5, laneY('volume', 6, 52) - 5)).toBe(2);
expect(pointAt(view, 'volume', points, 60, y)).toBeNull();
});
it('holds before the first point and after the last, and jumps upright on a step', () => {
const way = laneWay(view, 'volume', VOLUME, 300, 2);
const at = (x: number) => way.filter((p) => p.x === x).map((p) => p.y);
// Before the first point (x 10) the lane holds its value.
expect(at(0)).toEqual([laneY('volume', -12, 52)]);
// Halfway to the point at tick 960 (x 106) the linear way is at -6 dB, and reaches 0 dB there.
expect(at(58)).toEqual([laneY('volume', -6, 52)]);
expect(at(106)).toEqual([laneY('volume', 0, 52)]);
// The step holds until tick 1920 (x 202), where it jumps to the last point.
expect(at(200)).toEqual([laneY('volume', 0, 52)]);
expect(at(202)).toEqual([laneY('volume', 0, 52), laneY('volume', -24, 52)]);
expect(at(300)).toEqual([laneY('volume', -24, 52)]);
expect(way.map((p) => p.x)).toEqual([...way.map((p) => p.x)].sort((a, b) => a - b));
});
it('says a value as a head and a sign do', () => {
expect(laneValueText('volume', -6)).toBe('-6.0 dB');
expect(laneValueText('volume', 3.25)).toBe('+3.3 dB');
expect(laneValueText('volume', -60)).toBe('-inf dB');
expect(laneValueText('pan', 0)).toBe('Centre');
expect(laneValueText('pan', -0.25)).toBe('25% left');
expect(laneValueText('pan', 1)).toBe('100% right');
});
});
describe('editing the points of a lane (#337)', () => {
it('puts the points on their lane, the lanes in their order, and drops a lane without points', () => {
const a = withLanePoints(
track('a', [{ target: 'pan', points: [{ tick: 0, value: 1 }] }]),
'volume',
VOLUME,
);
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'pan']);
expect(lanePoints(a, 'volume')).toEqual(VOLUME);
const b = withLanePoints(a, 'pan', []);
expect(b.automation?.map((l) => l.target)).toEqual(['volume']);
expect('automation' in withLanePoints(b, 'volume', [])).toBe(false);
});
it('adds a point after the ones already on its tick, held to whole ticks and the range', () => {
expect(addedPoint(VOLUME, 'volume', 960.4, 3)).toEqual({
points: [VOLUME[0], VOLUME[1], { tick: 960, value: 3 }, VOLUME[2]],
index: 2,
});
expect(addedPoint([], 'pan', -5, 4)).toEqual({ points: [{ tick: 0, value: 1 }], index: 0 });
});
it('drags a point no further than its neighbours, keeping how it eases', () => {
expect(draggedPoint(VOLUME, 'volume', 1, 5000, -3)[1]).toEqual({ tick: 1920, value: -3, ease: 'step' });
expect(draggedPoint(VOLUME, 'volume', 1, -10, 99)[1]).toEqual({ tick: 0, value: 12, ease: 'step' });
expect(draggedPoint(VOLUME, 'volume', 2, 9999.6, -60)[2]).toEqual({ tick: 10000, value: -60 });
expect(draggedPoint(VOLUME, 'volume', 7, 0, 0)).toEqual(VOLUME);
});
it('takes a point away and sets how one eases, linear stored as none', () => {
expect(withoutPoint(VOLUME, 1)).toEqual([VOLUME[0], VOLUME[2]]);
expect(easedPoint(VOLUME, 0, 'sine-in')[0]).toEqual({ tick: 0, value: -12, ease: 'sine-in' });
expect(easedPoint(VOLUME, 1, 'linear')[1]).toEqual({ tick: 960, value: 0 });
});
});
describe('the lane of a parameter (#337)', () => {
const cut = { target: 'param:cut' as const, min: 0, max: 2000 };
const withCut = track('a', [
{ target: 'volume', points: [{ tick: 0, value: -6 }] },
{ ...cut, points: [] },
]);
it('is shown by its name after the volume and the pan, with no point too: it was added', () => {
expect(laneLabel('param:cut')).toBe('cut');
expect(trackLanes(withCut)).toEqual(['volume', 'pan', 'param:cut']);
expect(shownLanes(withCut, new Map())).toEqual(['volume', 'param:cut']);
expect(laneKindOf(withCut, 'param:cut')).toEqual(cut);
expect(laneKindOf(withCut, 'pan')).toEqual({ target: 'pan' });
});
it('draws and reads its values in its own range, to a step that cuts it into a hundred or more', () => {
expect(laneY(cut, 0, 52)).toBe(52 - LANE_INSET);
expect(laneY(cut, 2000, 52)).toBe(LANE_INSET);
expect(laneStep(cut)).toBe(10);
expect(laneStep({ target: 'param:mix' })).toBe(0.01);
expect(laneValue(cut, laneY(cut, 1234, 52), 52)).toBe(1230);
expect(laneValue({ target: 'param:mix' }, 26, 52)).toBe(0.5);
expect(laneValueText(cut, 1234)).toBe('1230');
expect(laneValueText({ target: 'param:mix' }, 0.123)).toBe('0.12');
expect(addedPoint([], cut, 0, 5000).points).toEqual([{ tick: 0, value: 2000 }]);
});
it('keeps its lane and range without a point, takes its points into a new range, and goes only when removed', () => {
const a = withLanePoints(withCut, 'param:cut', [{ tick: 0, value: 1500 }]);
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'param:cut']);
const emptied = withLanePoints(a, 'param:cut', []);
expect(emptied.automation?.[1]).toEqual({ ...cut, points: [] });
const narrow = withParamLane(a, 'cut', { min: 0, max: 1000 });
expect(narrow.automation?.[1]).toEqual({ ...cut, max: 1000, points: [{ tick: 0, value: 1000 }] });
// A new one comes last; a later volume lane still goes first.
const more = withLanePoints(withParamLane(narrow, 'tone', { min: 0, max: 1 }), 'pan', [
{ tick: 0, value: 0 },
]);
expect(more.automation?.map((l) => l.target)).toEqual(['volume', 'pan', 'param:cut', 'param:tone']);
expect(withoutLane(more, 'param:cut').automation?.map((l) => l.target)).toEqual([
'volume',
'pan',
'param:tone',
]);
});
});
describe('the easings as a menu (#337)', () => {
const project = {
assets: [
{ id: 'c2', type: 'curve', name: 'Swell' },
{ id: 'c1', type: 'curve', name: 'Dip' },
{ id: 'c3', type: 'curve', name: 'Inside', owner: 'song' },
],
} as unknown as Project;
const byLabel = (entries: MenuEntry[], label: string) =>
entries.find((e) => 'label' in e && e.label === label) as MenuItem & MenuSubmenu;
it('offers every easing, marks the one in use and hands back what is picked', () => {
const set = vi.fn();
const entries = easingEntries(project, 'cubic-out', set);
expect(entries.map((e) => ('label' in e ? e.label : '-'))).toEqual([
'Linear',
'Step',
'Sine',
'Quad',
'Cubic',
'Quart',
'Quint',
'Expo',
'Circ',
'Back',
'Elastic',
'Bounce',
'Curve',
]);
expect(byLabel(entries, 'Linear').current).toBe(false);
const cubic = byLabel(entries, 'Cubic').submenu as MenuItem[];
expect(cubic.map((e) => [e.label, e.current])).toEqual([
['In', false],
['Out', true],
['In-out', false],
]);
(byLabel(entries, 'Quad').submenu as MenuItem[])[0]!.onSelect();
byLabel(entries, 'Step').onSelect();
expect(set.mock.calls).toEqual([['ease-in'], ['step']]);
});
it('lists the Curve assets of the project by name, not those a song keeps to itself', () => {
const set = vi.fn();
const curve = byLabel(easingEntries(project, 'curve:c2', set), 'Curve');
const items = curve.submenu as MenuItem[];
expect(items.map((e) => [e.label, e.current])).toEqual([
['Dip', false],
['Swell', true],
]);
items[0]!.onSelect();
expect(set).toHaveBeenCalledWith('curve:c1');
expect(
byLabel(easingEntries({ assets: [] } as unknown as Project, 'linear', set), 'Curve').disabled,
).toBe(true);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { heldSongMarkers } from '../../src/core/music/markers';
import {
MARKER_FLAG,
MARKER_REACH,
MARKER_STRIP,
markerAt,
markerNameX,
markerSpans,
SONG_TOP,
} from '../../src/editor/song/markerView';
import { RULER_HEIGHT } from '../../src/editor/music/timeView';
/** A tick a pixel, from x 100; every name 6 pixels a letter. */
const VIEW = { panX: 100, pxPerTick: 1 };
const measure = (name: string) => name.length * 6;
describe('the marker strip of the song tab (#338)', () => {
it('lies between the ruler and the tracks', () => {
expect(SONG_TOP).toBe(RULER_HEIGHT + MARKER_STRIP);
});
it('draws a marker at its tick, as wide as its flag and name, but not into the next one', () => {
const markers = heldSongMarkers([
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'b', name: 'A long name', tick: 40 },
{ id: 'c', name: '', tick: 300 },
]);
const spans = markerSpans(VIEW, markers, measure);
expect(spans.map((s) => s.x)).toEqual([100, 140, 400]);
// Room to the next one is 40 pixels, less 2: the name of "Intro" (30) and the flag fit just so.
expect(spans[0]!.w).toBe(38);
expect(spans[1]!.w).toBe(MARKER_FLAG + 3 + measure('A long name'));
expect(spans[2]!.w).toBe(MARKER_FLAG + 3 + measure('Marker 3'));
expect(markerNameX(spans[0]!)).toBe(100 + MARKER_FLAG + 3);
});
it('keeps the flag of a marker with another one close behind it', () => {
const markers = heldSongMarkers([
{ id: 'a', name: 'Close', tick: 0 },
{ id: 'b', name: 'By', tick: 2 },
]);
expect(markerSpans(VIEW, markers, measure)[0]!.w).toBe(MARKER_FLAG);
});
it('takes the marker near its line or on its flag and name, the later one where they overlap', () => {
const markers = heldSongMarkers([
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'b', name: 'B', tick: 6 },
]);
const spans = markerSpans(VIEW, markers, measure);
expect(markerAt(spans, 100 - MARKER_REACH)).toBe('a');
expect(markerAt(spans, 100 - MARKER_REACH - 1)).toBeNull();
expect(markerAt(spans, 103)).toBe('b');
expect(markerAt(spans, 106 + MARKER_FLAG + 3 + 6)).toBe('b');
expect(markerAt(spans, 106 + MARKER_FLAG + 3 + 7)).toBeNull();
expect(markerAt([], 100)).toBeNull();
});
});
+65 -6
View File
@@ -13,6 +13,7 @@ import {
type GraphLookup,
type GraphNode,
NO_LOOKUP,
type MusicSync,
type NodeContext,
type PointerState,
type Value,
@@ -33,6 +34,7 @@ import { createEmptyProject, findScene, type Project, ROUTING_ID } from '../src/
import { settleRouting } from '../src/core/music/projectRouting';
import { MASTER_BUS } from '../src/core/properties/buses';
import type { RecordingSettings } from '../src/core/recording/recording';
import type { VideoSteer } from '../src/core/model/videoClock';
import type { SceneObject } from '../src/core/model/SceneObject';
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
import type { AudioSink } from '../src/runtime/systems/Audio';
@@ -241,6 +243,8 @@ export type FakeContext = NodeContext & {
tempo: number;
/** The bus picked (#325); left out for none. */
bus?: string;
/** The playing it starts in time with (#338); left out for none. */
sync?: MusicSync;
}[];
/** The notes the context was asked to play (#331); a voice's number is how many have been played. */
notes: {
@@ -255,6 +259,7 @@ export type FakeContext = NodeContext & {
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
sync?: MusicSync;
}[];
/** The patterns the context was asked to play; a playing's number is how many have been played. */
patterns: {
@@ -265,6 +270,7 @@ export type FakeContext = NodeContext & {
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
sync?: MusicSync;
}[];
/** The songs the context was asked to play, numbered the same way. */
songs: {
@@ -276,7 +282,10 @@ export type FakeContext = NodeContext & {
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
live?: boolean;
}[];
/** What Set song track and Switch song asked of a playing (#338), one line each. */
songChanges: string[];
silenced: string[];
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
/** What buses the context was told to change (#325): a volume or FX parameters, in order. */
@@ -296,6 +305,8 @@ export type FakeContext = NodeContext & {
recordings: (RecordingSettings | 'stop')[];
/** How often a `Break` node asked to pause. */
breaks: number;
/** What the video nodes asked of which sprite (#410), in order. */
steered: { target: string; steer: VideoSteer }[];
};
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
@@ -339,12 +350,25 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
return { blockedX: false, blockedY: false, hit: false, normal: { x: 0, y: 0 } };
},
log: (message) => logs.push(message),
steered: [],
steerVideo: (target, steer) => ctx.steered.push({ target: target.id, steer }),
videoReading: () => null,
played: [],
silenced: [],
loaded: [],
// The number of a voice is how many have been played: unique, and never 0.
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo, ...(bus && { bus }) }),
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) =>
ctx.played.push({
assetId,
volume,
loop,
detached,
fx,
fxParams,
tempo,
...(bus && { bus }),
...(sync.playback > 0 && { sync }),
}),
notes: [],
playNote: (
instrumentId,
@@ -358,6 +382,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
fx,
fxParams,
bus,
sync,
) =>
ctx.notes.push({
instrumentId,
@@ -371,15 +396,46 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
fx,
fxParams,
...(bus && { bus }),
...(sync.playback > 0 && { sync }),
}),
patterns: [],
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams, ...(bus && { bus }) }),
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) =>
ctx.patterns.push({
patternId,
volume,
loop,
detached,
fx,
fxParams,
...(bus && { bus }),
...(sync.playback > 0 && { sync }),
}),
stopPattern: (patternId, letRing) =>
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
songs: [],
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams, ...(bus && { bus }) }),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) =>
ctx.songs.push({
songId,
volume,
fadeIn,
loop,
detached,
fx,
fxParams,
...(bus && { bus }),
...(live && { live }),
}),
songChanges: [],
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
ctx.songChanges.push(`track ${playback} ${track} ${volumeDb} dB fade ${fade} ${quantize}`),
setSongLane: (playback, track, lane, value, fade, quantize) =>
ctx.songChanges.push(`lane ${playback} ${track} ${lane} ${value} fade ${fade} ${quantize}`),
releaseSongLane: (playback, track, lane, fade) =>
ctx.songChanges.push(`release ${playback} ${track} ${lane} fade ${fade}`),
switchSong: (playback, songId, marker, quantize, fade) =>
ctx.songChanges.push(
`switch ${playback} to ${songId || '(same)'} at ${marker || '(start)'} ${quantize} fade ${fade}`,
),
stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`),
stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`),
// Every sound lasts a quarter of a second, as if it had been loaded.
@@ -398,6 +454,9 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
busVolume: (bus) =>
[...ctx.buses].reverse().find((change) => change.bus === bus && change.volume !== undefined)?.volume ??
1,
songPosition: () => null,
// An even number plays, an odd one has ended, so a test can ask for either.
playing: (id) => id > 0 && id % 2 === 0,
loadScene: (sceneId) => ctx.loaded.push(sceneId),
spawn: () => null,
addJoint: () => null,
+426 -2
View File
@@ -15,9 +15,11 @@ import {
type FxStagePlan,
type FxVoice,
} from '../../src/core/soundfx/nodes';
import type { AutomationTarget } from '../../src/core/music/automation';
import { dbToGain } from '../../src/core/music/level';
import type { MixBus } from '../../src/core/music/mixer';
import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledNote } from '../../src/core/music/Scheduler';
import { type BakePlay, CUT, type LandingAt, type SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledAutomation, ScheduledNote } from '../../src/core/music/Scheduler';
import type { LfoOptions, PitchOptions, StreamOptions } from '../../src/viewport/fxWorklet';
import { saturationCurve } from '../../src/core/soundfx/saturation';
import { stageNodes } from '../../src/viewport/fxNodes';
@@ -1400,6 +1402,16 @@ describe('notes of an instrument (#331)', () => {
expect(sources[1]!.startedAt).toBe(0);
});
it('a sound asked for a second of the audio clock starts there: a stinger (#338)', async () => {
const library = new SoundLibrary(false);
library.play(FILE, 1, false, '', DRY_VOICE, '', 3);
await settle();
await arrive();
expect(sources[0]!.startedAt).toBe(3);
library.play(FILE, 1, false);
expect(sources[1]!.startedAt).toBe(0);
});
it('starts a note with a Limiter as much earlier as it is late, its other layer held back to it (#326)', async () => {
const library = new SoundLibrary(false);
const limit: FxStagePlan = {
@@ -1951,6 +1963,9 @@ describe('the patterns and songs a game plays (#336)', () => {
'',
);
await settle();
// Planned to its end, but not yet played to it (#373): 10 ticks from 50 ms on.
expect(library.playbacks()).toHaveLength(1);
(library as unknown as { context: { currentTime: number } }).context.currentTime = 0.1;
expect(() => library.playbacks()).not.toThrow();
expect(library.playbacks()).toEqual([]);
clockTo();
@@ -1980,6 +1995,339 @@ describe('the patterns and songs a game plays (#336)', () => {
library.time();
expect(resumes).toBe(2);
});
/** A bus of the library by its name, with the params a test reads. */
const busOf = (library: SoundLibrary, name: string) =>
(
library as unknown as { buses: Map<string, { input: { gain: FakeParam }; gain: { gain: FakeParam } }> }
).buses.get(name);
it('turns a track of a playing on its input from where it lands, on top of its level (#338)', async () => {
const library = new SoundLibrary(false);
const id = library.playSequence(
'song:s',
sequence(() => 0),
'',
);
await settle();
const landed: (number | null)[] = [];
const at: LandingAt = (clock) => (landed.push(clock.startedAt), clock.startedAt! + 2);
expect(library.setSongTrack(id, 'song:s:t', 0.25, 1, at)).toBe('set');
const track = busOf(library, `play:${id}:song:s:t`)!;
expect([landed, track.input.gain.ramps]).toEqual([
[0.05],
[
['set', 1, 2.05],
['linear', 0.25, 3.05],
],
]);
// Its own level is the song's, left alone.
expect(track.gain.gain.value).toBe(0.25);
// A turn after that one is through starts where it left the track, not where it is now.
library.setSongTrack(id, 'song:s:t', 0.5, 0, (clock) => clock.startedAt! + 4);
expect(track.input.gain.ramps.slice(2)).toEqual([
['set', 0.25, 4.05],
['linear', 0.5, expect.closeTo(4.055, 9)],
]);
expect(library.setSongTrack(id, 'song:s:gone', 0.5, 0, at)).toBe('none');
expect(library.setSongTrack(id + 99, 'song:s:t', 0.5, 0, at)).toBe('none');
});
it('a playing of its bake has no tracks to turn (#338)', () => {
const library = new SoundLibrary(false);
const bake = { file: 'baked.ogg', gain: 0.5, playback: { end: 2, until: 2, round: null } };
const id = library.playSequence(
'song:s',
sequence(() => 0, { bake }),
'',
);
expect(library.setSongTrack(id, 'song:s:t', 0.5, 0, () => 0)).toBe('baked');
});
it('switches a playing on buses of its own, its tracks turned as they were, the old one gone once silent (#338)', async () => {
const library = new SoundLibrary(false);
const id = library.playSequence(
'song:s',
sequence(() => 0),
'logic',
);
await settle();
library.setSongTrack(id, 'song:s:t', 0.25, 0, () => 0.5);
const at: LandingAt = (clock) => clock.startedAt! + 2;
expect(
library.switchSequence(
id,
'song:s',
sequence(() => 0, { from: 480 }),
at,
0,
),
).toBe(true);
const fresh = library
.busLevels()
.find((bus) => bus.name.endsWith(':song:s:t') && bus.name !== `play:${id}:song:s:t`);
expect(busOf(library, fresh!.name)!.input.gain.value).toBe(0.25);
await settle();
expect(library.playbacks().map((p) => [p.id, p.owner])).toEqual([
[id, 'logic'],
[id, 'logic'],
]);
expect(busOf(library, `play:${id}`)!.gain.gain.ramps.slice(-2)).toEqual([
['set', 0, 2.05],
['linear', 0, 2.05 + CUT],
]);
clockTo();
expect(library.playbacks().map((p) => p.id)).toEqual([id]);
// The old one's buses are let go, though a playing of its number plays on.
expect(library.busLevels().filter((bus) => bus.name.startsWith(`play:${id}`))).toEqual([]);
expect(
library.switchSequence(
id + 99,
'song:s',
sequence(() => 0),
at,
0,
),
).toBe(false);
});
});
describe('the automation of the tracks of a song (#337)', () => {
const automation = (fields: Partial<ScheduledAutomation>): ScheduledAutomation => ({
bus: 'song:s:t',
target: 'volume',
fresh: false,
marks: [],
...fields,
});
it('leaves what a lane sets alone, and hands it back to the fader from where the lane left it', async () => {
const library = new SoundLibrary(false);
// The first gain is where the bus is fed, the second its level.
await library.setBuses([{ name: 'song:s:t', gain: 0.5, pan: 0.25, automated: ['pan'] }]);
await library.setBuses([{ name: 'song:s:t', gain: 0.4, pan: -1, automated: ['pan'] }]);
expect([gains[1]!.glides, panners[0]!.glides, panners[0]!.holds]).toEqual([[0.5, 0.4], [0.25], 0]);
await library.setBuses([{ name: 'song:s:t', gain: 0.4, pan: -1 }]);
expect([panners[0]!.glides, panners[0]!.holds, gains[1]!.holds]).toEqual([[0.25, -1], 1, 0]);
});
it('ramps a bus to each mark or jumps there, a fresh start first dropping what was planned', async () => {
const library = new SoundLibrary(false);
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, automated: ['volume'] }]);
library.automateBus(
'song:s:t',
automation({
fresh: true,
marks: [
{ at: 1, value: 0, jump: true },
{ at: 2, value: 0.5, jump: false },
],
}),
);
library.automateBus('song:s:t', automation({ marks: [{ at: 3, value: 0.25, jump: false }] }));
// The hold by hand of a context without cancelAndHoldAtTime, at the first mark.
expect(gains[1]!.ramps).toEqual([
['set', 1, 1],
['set', 0, 1],
['linear', 0.5, 2],
['linear', 0.25, 3],
]);
library.automateBus(
'song:s:gone',
automation({ bus: 'song:s:gone', marks: [{ at: 1, value: 0, jump: true }] }),
);
expect(library.busLevels().map((bus) => bus.name)).toEqual(['mix', 'song:s:t']);
});
it('takes the tracks of a playing in a game along their lanes, and no bus the song does not have', async () => {
const library = new SoundLibrary(false);
const lane = (bus: string) => ({
bus,
target: 'pan' as const,
points: [
{ tick: 0, value: -1 },
{ tick: 1920, value: 1 },
],
});
const id = library.playSequence(
'song:s',
{
sequence: {
notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' }],
lanes: [lane('song:s:t'), lane('song:s:gone')],
},
tempo: 120,
length: 1920,
loop: null,
needs: { layers: [], files: [] },
buses: [{ name: 'song:s:t', gain: 1, pan: -1, automated: ['pan'] }],
strike: () => 0,
},
'',
);
await settle();
expect(library.busLevels().map((bus) => bus.name)).toContain(`play:${id}:song:s:t`);
// One bus only is taken along: the track's, over its first window – on from the first note, a
// lookahead ahead, 48 ticks at 120 BPM.
const taken = panners.filter((panner) => panner.ramps.length > 0);
expect(taken).toHaveLength(1);
const ramps = taken[0]!.ramps.map(([kind, value, at]) => [
kind,
value,
Math.round(Number(at) * 1e6) / 1e6,
]);
expect(ramps).toEqual([
['set', -1, 0.05],
['set', -1, 0.05],
['linear', -0.95, 0.1],
]);
expect(library.busLevels().some((bus) => bus.name.includes('gone'))).toBe(false);
});
it('a game holds a lane of a track from where it lands, and lets it go back to the curve (#338)', async () => {
const library = new SoundLibrary(false);
const play: SequencePlay = {
sequence: {
notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' }],
lanes: [
{
bus: 'song:s:t',
target: 'pan',
points: [
{ tick: 0, value: -1 },
{ tick: 1920, value: 1 },
],
},
],
},
tempo: 120,
length: 1920,
loop: null,
needs: { layers: [], files: [] },
buses: [{ name: 'song:s:t', gain: 1, pan: -1, automated: ['pan'] }],
strike: () => 0,
};
const id = library.playSequence('song:s', play, '');
await settle();
const [panner] = panners.filter((p) => p.ramps.length > 0);
const planned = panner!.ramps.length;
const at: LandingAt = (clock) => clock.startedAt! + 0.03;
expect(library.holdSongLane(id, 'song:s:t', 'pan', 0.5, 0, at)).toBe('set');
const ramps = () =>
panner!.ramps
.slice(planned)
.map(([kind, value, when]) => [kind, value, Math.round(Number(when) * 1e6) / 1e6]);
// Held by hand where it lands (the fake holds its value), set there, and held to where planning got.
expect(ramps()).toEqual([
['set', -1, 0.08],
['set', 0.5, 0.08],
['linear', 0.5, 0.1],
]);
expect(library.holdSongLane(id, 'song:s:t', 'volume', 0, 0, at)).toBe('none');
expect(library.holdSongLane(id + 99, 'song:s:t', 'pan', 0, 0, at)).toBe('none');
expect(library.releaseSongLane(id, 'song:s:t', 'pan', 0)).toBe('set');
expect(library.releaseSongLane(id, 'song:s:t', 'volume', 0)).toBe('none');
});
it('a playing of its bake has no lanes to hold (#338)', () => {
const library = new SoundLibrary(false);
const bake = { file: 'baked.ogg', gain: 0.5, playback: { end: 2, until: 2, round: null } };
const id = library.playSequence(
'song:s',
{
sequence: { notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i' }] },
tempo: 120,
length: 1920,
loop: null,
needs: { layers: [], files: [] },
strike: () => 0,
bake,
},
'',
);
expect([
library.holdSongLane(id, 'song:s:t', 'pan', 0.5, 0, () => 0),
library.releaseSongLane(id, 'song:s:t', 'pan', 0),
]).toEqual(['baked', 'baked']);
});
/** A gain whose decibels are the parameter `loud`; with `source` it plays FILE, as a clip's FX does. */
const loud = (fields: Partial<FxPlan> = {}): FxVoice => ({
plan: {
pitch: [],
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
...fields,
},
params: {},
tempo: 120,
});
const clip = (library: SoundLibrary, bus: string, file = FILE) =>
library.playClip(file, { at: 0, offset: 0, duration: 10, volume: 1, bus }, loud());
const tracks = (fx: FxVoice, automated: readonly AutomationTarget[] = ['param:loud']) => [
{ name: 'song:s:t', gain: 1, pan: 0, fx, automated },
{ name: 'song:s:t:#items', gain: 1, pan: 0, into: 'song:s:t' },
{ name: 'song:s:u:#items', gain: 1, pan: 0 },
];
const marks = (...values: [number, number][]) =>
automation({
target: 'param:loud',
marks: values.map(([at, value]) => ({ at, value, jump: false })),
});
/** What was planned for each gain before the clips fade out at their end, 10 s on. */
const moved = () =>
gains.map((gain) => gain.ramps.filter(([, , at]) => Number(at) < 5)).filter((ramps) => ramps.length > 0);
it('takes the sound FX of a track and every voice inside it along a parameter, in what their knobs hold', async () => {
const library = new SoundLibrary(false);
await library.setBuses(tracks(loud()));
await settle();
clip(library, 'song:s:t:#items');
clip(library, 'song:s:u:#items');
await settle();
await arrive();
library.automateBus('song:s:t', marks([1, -6], [2, -12]));
// The chain of the track and the voice on it, each first set where nothing was planned yet.
const way = [
['set', expect.closeTo(dbToGain(-6), 9), 1],
['linear', expect.closeTo(dbToGain(-12), 9), 2],
];
expect(moved()).toEqual([way, way]);
// The next window ramps on from there; a mark where a knob stays is left out.
library.automateBus('song:s:t', marks([2, -12], [3, 0]));
expect(moved()[0]).toEqual([...way, ['linear', expect.closeTo(dbToGain(-12), 9), 2], ['linear', 1, 3]]);
library.automateBus('song:s:t', marks([3, 0], [4, 0]));
expect(moved()[0]).toHaveLength(4);
});
it('takes up the marks planned for a voice still waiting on its bytes once it starts', async () => {
const library = new SoundLibrary(false);
await library.setBuses(tracks({ ...loud(), plan: { pitch: [], stages: [] } }, []));
clip(library, 'song:s:t:#items', OTHER);
library.automateBus('song:s:t', marks([1, -6], [2, -12]));
expect(moved()).toEqual([]);
await settle();
await arrive();
expect(moved()).toEqual([
[
['set', expect.closeTo(dbToGain(-6), 9), 1],
['linear', expect.closeTo(dbToGain(-12), 9), 2],
],
]);
});
it('leaves a parameter a lane rules alone when the tracks are set again, and hands one let go back', async () => {
const library = new SoundLibrary(false);
// One plan throughout, as a sound FX compiles to: another would be another chain.
const fx = loud();
await library.setBuses(tracks(fx));
library.automateBus('song:s:t', marks([1, -12]));
const knob = gains.find((gain) => gain.ramps.length > 0)!;
await library.setBuses(tracks({ ...fx, params: { loud: -3 } }));
expect(knob.glides).toEqual([]);
// The lane is gone: back to what the track's FX is set to, its default.
await library.setBuses(tracks(fx, []));
expect(knob.glides).toEqual([1]);
});
});
describe('a bake (#340)', () => {
@@ -3127,3 +3475,79 @@ describe('what has ended is let go', () => {
expect(worklets).toHaveLength(14);
});
});
describe('what ended, for a game to hear (#373)', () => {
it('keeps the end of a voice of play once its sound is over, once, not stopped', async () => {
const library = new SoundLibrary(false);
const voice = await play(library);
expect(library.endings()).toEqual([]);
sources[0]!.onended!();
sources[0]!.onended!();
expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: false }]);
});
it('with an echo only once the echo has faded', async () => {
const library = new SoundLibrary(false);
const echo: FxStage = {
kind: 'delay',
time: 0.25,
feedback: 0,
damping: 20000,
mix: 0.5,
pingPong: false,
};
const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo] });
sources[0]!.onended!();
clockTo(0.24);
expect(library.endings()).toEqual([]);
clockTo(0.25);
expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: false }]);
});
it('a stopped one at once, stopped – also one still waiting on its bytes, told only once', async () => {
const library = new SoundLibrary(false);
const first = await play(library);
const waiting = await play(library, OTHER, { hold: true });
library.stopVoice(first);
library.stopVoice(waiting);
const told = [
{ id: first, name: FILE, stopped: true },
{ id: waiting, name: OTHER, stopped: true },
];
expect(library.endings()).toEqual(told);
await arrive();
clockTo();
expect(library.endings()).toEqual(told);
});
it('one whose owner went while its bytes were on the way once they come, stopped', async () => {
const library = new SoundLibrary(false);
const voice = await play(library, FILE, { hold: true, owner: 'logic' });
library.stopOwner('logic');
expect(library.endings()).toEqual([]);
await arrive();
expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: true }]);
expect(library.stats().playing).toBe(0);
});
it('keeps no end of a note, and the end of a playing by the name it plays as', async () => {
const library = new SoundLibrary(false);
library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0);
const id = library.playSequence(
'song:s',
{
sequence: { notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i' }] },
tempo: 120,
length: 1920,
loop: { start: 0, end: 1920 },
needs: { layers: [], files: [] },
strike: () => 0,
},
'',
);
await settle();
library.stop('');
clockTo();
expect(library.endings()).toEqual([{ id, name: 'song:s', stopped: true }]);
});
});
+20 -3
View File
@@ -3964,9 +3964,23 @@ export async function sound(t) {
// counted once it is there, so the pause is judged by what it adds. The wait after it lets the
// worklet report how far it has read: until then a stretch sent counts as ahead.
const headsOnly = statsBefore.streaming.held;
await waitFor(stats, (s) => s.streaming.held - headsOnly >= STREAM_SECOND_BYTES, 40);
const aheadStreamed = await waitFor(stats, (s) => s.streaming.held - headsOnly >= STREAM_SECOND_BYTES, 40);
await sleep(500);
const heldStreamed = await stats();
// A runner too slow to decode never gets the second ahead and skips from the start (CI task 750,
// 0.04 s ahead, 1.1 s skipped before the pause): that is told here, not blamed on the pause.
const streamAhead =
aheadStreamed.streaming.held - headsOnly >= STREAM_SECOND_BYTES &&
heldStreamed.streaming.skipped === statsBefore.streaming.skipped;
check(
'before the pause the stream runs a second ahead, not a sample skipped',
streamAhead,
JSON.stringify({
before: statsBefore.streaming,
ahead: aheadStreamed.streaming,
held: heldStreamed.streaming,
}),
);
await t.clickToolbar('pause');
await sleep(500);
const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') };
@@ -3988,12 +4002,15 @@ export async function sound(t) {
Math.max(...(heardLong?.mix ?? [0])) > 0.01,
JSON.stringify({ streaming, heardLong }),
);
// Behind before it, the pause cannot be judged by what it skips: the clock and stopping still are.
check(
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
streamAhead
? 'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it'
: 'the pause holds the clock and stopping ends it (samples not judged: the stream was behind before the pause)',
paused.state === 'suspended' &&
pausedLater === paused.clock &&
resumedLong.streaming.playing === 1 &&
resumedLong.streaming.skipped === heldStreamed.streaming.skipped &&
(!streamAhead || resumedLong.streaming.skipped === heldStreamed.streaming.skipped) &&
stoppedLong.streaming.playing === 0 &&
stoppedLong.playing === 0,
// Held before the pause too: it tells a stream that fell behind from the start from one the
+32
View File
@@ -496,6 +496,38 @@ export async function pattern(t) {
JSON.stringify({ held, heldKey }),
);
// 64k'. Swing (#372): with 100 % on 1/8 a bar of eighths at 120 BPM plays long – short, every second
// eighth on the triplet: 1/3 s, then 1/6 s. The notes stay on the grid.
await mutate(`
const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
p.props.swing = 100;
p.props.swingGrid = '1/8';
p.notes = [60, 61, 62, 63, 64, 65, 66, 67].map((note, i) => ({ id: 'pt64-sw' + i, tick: i * 240, length: 120, note, velocity: 1 }));`);
await press('Space');
const swung = await waitFor(
() => json('__gm.pattern.planned()'),
(list) => list.length >= 8,
30,
);
await press('Space');
const eighths = swung.slice(0, 8);
const swingGaps = eighths.slice(1).map((n, i) => +(n.at - eighths[i].at).toFixed(6));
const swingTicks = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).notes.map((n) => n.tick)`,
);
check(
'with Swing at 100 % on 1/8 its eighths are heard long – short, 1/3 s and 1/6 s apart, written on the grid',
eighths.map((n) => n.note).join() === '60,61,62,63,64,65,66,67' &&
swingGaps.every((gap, i) => Math.abs(gap - (i % 2 === 0 ? 1 / 3 : 1 / 6)) < 1e-4) &&
swingTicks.join() === '0,240,480,720,960,1200,1440,1680',
JSON.stringify({ swingGaps, swingTicks }),
);
await waitFor(stats, (s) => s.playing === 0);
await mutate(`
const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
delete p.props.swing;
delete p.props.swingGrid;`);
// 64l. A game plays the pattern through Play pattern: a bar of quarters at 150 BPM, its notes at
// their ticks on the audio clock and round and round, owned by the behavior. Ending the run
// ends it; played once, it goes by itself after its bar.
+673 -3
View File
@@ -1,4 +1,4 @@
import { edge, node } from '../lib/builders.mjs';
import { edge, node, port as readPort, variable, written } from '../lib/builders.mjs';
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
import { sampleZoom } from './lib/zoomTransition.mjs';
@@ -490,6 +490,215 @@ export async function song(t) {
song.tracks[2].solo = false;
song.tracks[0].mute = false;`);
// 65g2. Automation lanes (#337). The triangle in a head opens a lane for the volume and one for the
// pan right under its track, and what lies below moves down. A click on a lane sets a point on
// the grid, a press dragged on sets one and takes it along, as one step. A dragged point stops
// at its neighbour. A point's menu sets how it eases to the next, it says its value when the
// pointer rests on it, and a double-click takes it away. The fader a lane rules does not move.
// Clear lane leaves the lane open and empty, the triangle closes it.
const lanesShown = () => json('__gm.song.automation()');
const volumeAt = (tick, value) => json(`__gm.song.automationAt(1, 'volume', ${tick}, ${value})`);
const volumePoints = async () =>
(await model()).tracks[1].automation?.find((lane) => lane.target === 'volume')?.points ?? [];
const lowItem = (await model()).items.find((item) => item.track !== tracks[0] && item.track !== tracks[1]);
const lowY = async () => (await json('__gm.song.items()')).find((item) => item.id === lowItem?.id)?.box.y;
const lowBefore = await lowY();
const rowHeight = (await json('__gm.song.view()')).trackHeight;
p = await json("__gm.song.headAt(1, 'lanes')");
await click(p.x, p.y);
await sleep(100);
const lanesOpened = await lanesShown();
const lowAfter = await lowY();
check(
'the triangle of a head opens a volume and a pan lane under its track, and what lies below moves down',
JSON.stringify(lanesOpened.rows.map((row) => row.lane)) ===
JSON.stringify([null, null, 'volume', 'pan', null, null, null]) &&
lanesOpened.rows[2].track === tracks[1] &&
lanesOpened.rows[3].track === tracks[1] &&
lowItem !== undefined &&
Math.abs(lowAfter - lowBefore - 2 * rowHeight) < 0.5,
JSON.stringify({ lanesOpened, lowBefore, lowAfter, rowHeight }),
);
p = await volumeAt(BAR + 100, -12);
await click(p.x, p.y);
await sleep(100);
const onePoint = await volumePoints();
p = await volumeAt(3 * BAR, -30);
to = await volumeAt(4 * BAR + 50, 0);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const twoPoints = await volumePoints();
const addedLabel = await json('__gm.state.commands.undoLabel');
await press('z', { ctrl: true });
await sleep(100);
const laneUndone = await volumePoints();
await clickToolbar('redo');
await sleep(100);
const laneRedone = await volumePoints();
check(
'a click on a lane sets a point on the bar, a drag sets one and takes it along as one step',
onePoint.length === 1 &&
onePoint[0].tick === BAR &&
Math.abs(onePoint[0].value + 12) < 1.5 &&
twoPoints.length === 2 &&
twoPoints[1].tick === 4 * BAR &&
Math.abs(twoPoints[1].value) < 1.5 &&
addedLabel === 'Add automation point' &&
laneUndone.length === 1 &&
JSON.stringify(laneRedone) === JSON.stringify(twoPoints),
JSON.stringify({ onePoint, twoPoints, addedLabel, laneUndone, laneRedone }),
);
p = await volumeAt(twoPoints[0].tick, twoPoints[0].value);
to = await volumeAt(6 * BAR, twoPoints[0].value);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const heldPoints = await volumePoints();
await press('z', { ctrl: true });
await sleep(100);
check(
'a point dragged past its neighbour stops on its tick: two points there, a jump',
heldPoints.length === 2 &&
heldPoints[0].tick === 4 * BAR &&
heldPoints[1].tick === 4 * BAR &&
Math.abs(heldPoints[0].value - twoPoints[0].value) < 0.2 &&
JSON.stringify(await volumePoints()) === JSON.stringify(twoPoints),
JSON.stringify(heldPoints),
);
p = await volumeAt(twoPoints[0].tick, twoPoints[0].value);
await click(p.x, p.y, 'right');
await sleep(150);
const pointRows = await json('__gm.menuRows()');
await clickText('Ease to next point');
await sleep(150);
await clickText('Sine');
await sleep(150);
await clickText('In');
await sleep(150);
const eased = await volumePoints();
const shownValue = (v) =>
`${Math.round(v * 10) / 10 > 0 ? '+' : ''}${(Math.round(v * 10) / 10).toFixed(1)} dB`;
p = await volumeAt(twoPoints[1].tick, twoPoints[1].value);
await mouse('mouseMoved', p.x - 1, p.y);
await mouse('mouseMoved', p.x, p.y);
await t.waitUntil('__gm.tooltip() !== null', { label: 'the sign over a point of a lane' });
const pointSign = await json('__gm.tooltip()');
check(
"a point's menu sets how it eases to the next, and resting on a point says its value",
pointRows?.includes('Ease to next point') &&
pointRows.includes('Delete point') &&
eased[0].ease === 'sine-in' &&
eased[1].ease === undefined &&
pointSign?.text === shownValue(twoPoints[1].value),
JSON.stringify({ pointRows, eased, pointSign }),
);
p = await json("__gm.song.headAt(1, 'volume', 0.5)");
to = await json("__gm.song.headAt(1, 'volume', 0)");
await drag(p.x, p.y, to.x + 1, to.y);
await sleep(100);
const ruledFader = { volume: (await model()).tracks[1].volume, notice: await json('__gm.state.notice') };
p = await volumeAt(twoPoints[1].tick, twoPoints[1].value);
await doubleClick(p.x, p.y);
await sleep(100);
const afterDouble = await volumePoints();
check(
'the fader a lane rules does not move, and a double-click takes a point away',
ruledFader.volume === 0 &&
ruledFader.notice.startsWith('Volume follows its automation lane') &&
afterDouble.length === 1 &&
afterDouble[0].tick === BAR,
JSON.stringify({ ruledFader, afterDouble }),
);
p = await json("__gm.song.automationHeadAt(1, 'volume')");
await click(p.x, p.y, 'right');
await sleep(150);
await clickText('Clear lane');
await sleep(100);
const laneCleared = { track: (await model()).tracks[1], rows: (await lanesShown()).rows.length };
p = await json("__gm.song.headAt(1, 'lanes')");
await click(p.x, p.y);
await sleep(100);
const closedRows = (await lanesShown()).rows.length;
check(
'Clear lane takes the lane out of the song and leaves it open, the triangle closes the lanes',
!('automation' in laneCleared.track) &&
laneCleared.rows === 7 &&
closedRows === 5 &&
(await lowY()) === lowBefore,
JSON.stringify({ laneCleared, closedRows }),
);
await evaluate("__gm.state.notice = ''");
// 65g3. Markers (#338) on their strip under the ruler. A double-click there adds one on the grid,
// a drag moves it as one step, a click starts playing at it, a double-click names it, resting
// on it says its name, and its menu takes it away.
const songMarkers = async () => (await model()).markers;
const markerAt = (tick) => json(`__gm.song.markerAt(${tick})`);
p = await markerAt(BAR + 100);
await doubleClick(p.x, p.y);
await sleep(100);
const added = await songMarkers();
p = await markerAt(BAR);
to = await markerAt(3 * BAR + 50);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const movedMarkers = await songMarkers();
const movedLabel = await json('__gm.state.commands.undoLabel');
await press('z', { ctrl: true });
await sleep(100);
const markerUndone = await songMarkers();
await clickToolbar('redo');
await sleep(100);
const markerDrawn = (await json('__gm.song.markers()'))[0];
const rulerX = (await json(`__gm.song.rulerAt(${3 * BAR})`)).x;
check(
'a double-click on the strip adds a marker on the bar, a drag moves it as one step, drawn at its tick',
added.length === 1 &&
added[0].name === 'Marker 1' &&
added[0].tick === BAR &&
movedMarkers[0].tick === 3 * BAR &&
movedLabel === 'Move marker' &&
markerUndone[0].tick === BAR &&
(await songMarkers())[0].tick === 3 * BAR &&
Math.abs(markerDrawn?.x - rulerX) < 1,
JSON.stringify({ added, movedMarkers, movedLabel, markerUndone, markerDrawn, rulerX }),
);
p = await markerAt(3 * BAR);
await click(p.x, p.y);
await sleep(100);
const startAtMarker = (await json('__gm.song.view()')).start;
await sleep(500);
await doubleClick(p.x, p.y);
await sleep(150);
const nameDialog = await json('__gm.groupDialogOpen()');
await t.typeText('Boss');
await press('Enter');
await sleep(100);
const named = (await songMarkers())[0];
// The presses left the marker's sign quiet: the pointer goes off the strip and back.
to = await json(`__gm.song.rulerAt(${3 * BAR})`);
await mouse('mouseMoved', to.x, to.y);
await mouse('mouseMoved', p.x + 5, p.y);
await mouse('mouseMoved', p.x + 6, p.y);
await t.waitUntil('__gm.tooltip() !== null', { label: 'the sign over a marker' });
const markerSign = await json('__gm.tooltip()');
check(
'a click on a marker starts playing there, a double-click names it, and resting on it says the name',
startAtMarker === 3 * BAR && nameDialog === true && named?.name === 'Boss' && markerSign?.text === 'Boss',
JSON.stringify({ startAtMarker, nameDialog, named, markerSign }),
);
await click(p.x, p.y, 'right');
await sleep(150);
const markerRows = await json('__gm.menuRows()');
await clickText('Delete marker');
await sleep(100);
const markersLeft = await model();
check(
"a marker's menu plays from it, renames it and takes it away",
['Play from here', 'Rename…', 'Delete marker'].every((row) => markerRows?.includes(row)) &&
markersLeft.markers.length === 0,
JSON.stringify({ markerRows, markers: markersLeft.markers }),
);
// 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a
// clip round and round, each on the bus of its track.
p = await json('__gm.song.rulerAt(0)');
@@ -531,6 +740,100 @@ export async function song(t) {
bus(tracks[1])?.gain === 1,
JSON.stringify(buses),
);
// 65h2. A lane of the second track's volume (#337), -40 dB to 0 dB over eight bars, takes its bus
// along while the song plays, from the window after it is set on: the level follows the
// playhead, and the fader turned all the way down meanwhile does not move it. The loop is
// made long, so the playhead does not jump back while it is read.
const loopBefore = (await model()).loop;
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.loop = { start: 0, end: ${64 * BAR} };
song.tracks[1].automation = [{ target: 'volume', points: [{ tick: 0, value: -40 }, { tick: ${8 * BAR}, value: 0 }] }];`);
const laneDb = (ticks) => -40 + (40 * ticks) / (8 * BAR);
const readLane = () =>
json(`({
level: __gm.song.buses().find((b) => b.name === ${JSON.stringify(busOf(tracks[1]))})?.level,
at: __gm.song.position()?.ticks,
})`);
await sleep(300);
const laneFirst = await readLane();
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.tracks[1].volume = -60;`);
await sleep(1000);
const laneLater = await readLane();
const offLane = (r) => Math.abs(20 * Math.log10(r.level) - laneDb(r.at));
check(
'a lane of the volume takes the bus of its track along the playhead, and the fader no longer moves it',
[laneFirst, laneLater].every((r) => r.level > 0 && r.at < 8 * BAR && offLane(r) < 1) &&
laneLater.at - laneFirst.at > 800 &&
20 * Math.log10(laneLater.level / laneFirst.level) > 2,
JSON.stringify({ laneFirst, laneLater, off: [laneFirst, laneLater].map(offLane) }),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
delete song.tracks[1].automation;
song.tracks[1].volume = 0;`);
// 65h3. A lane of a parameter (#337, stage 3): the sound item of the first track goes through a
// sound FX whose parameter `cut` is its gain in dB, and starts again a bar on, eight bars
// long. Its clip starts at the value the lane has there, and its knob follows the lane while
// it plays: one voice, read twice.
const cutItem = (await model()).items.find((i) => i.kind === 'sound');
const itemBefore = JSON.stringify(cutItem);
const startAt = Math.ceil(((await json('__gm.song.position()')).ticks + BAR) / BAR) * BAR;
await mutate(`
__gm.state.project.assets.push({
id: 'sg65-fx',
type: 'soundfx',
name: 'Cut 65',
props: {},
graph: {
nodes: ${JSON.stringify([
node('sg65-fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'cut', kind: 'number', default: 0 }]),
node('sg65-fx-gain', 'SoundFxGain', 200, 0),
node('sg65-fx-out', 'SoundFxOutput', 400, 0),
])},
edges: ${JSON.stringify([
edge('sg65-fx-e1', 'sg65-fx-in', 'out', 'sg65-fx-gain', 'in'),
edge('sg65-fx-e2', 'sg65-fx-in', 'cut', 'sg65-fx-gain', 'gain'),
edge('sg65-fx-e3', 'sg65-fx-gain', 'out', 'sg65-fx-out', 'in'),
])},
},
});
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const item = song.items.find((i) => i.id === ${JSON.stringify(cutItem.id)});
Object.assign(item, { fx: 'sg65-fx', tick: ${startAt}, length: ${8 * BAR} });
song.tracks[0].automation = [
{ target: 'param:cut', min: -40, max: 0, points: [{ tick: 0, value: -40 }, { tick: ${16 * BAR}, value: 0 }] },
];`);
const cutDb = (ticks) => -40 + (40 * ticks) / (16 * BAR);
const readCut = () =>
json(`({
voices: __gm.sound.levels(${JSON.stringify(blip?.file ?? '')}).filter((knobs) => knobs.length > 0),
at: __gm.song.position()?.ticks,
clip: __gm.song.clips().filter((c) => c.fx === 'sg65-fx').at(-1) ?? null,
})`);
const cutFirst = await waitFor(readCut, (r) => r.voices.length > 0 && r.at > startAt + 0.25 * BAR, 40);
await sleep(1500);
const cutLater = await readCut();
const offCut = (r) => Math.abs(20 * Math.log10(r.voices[0]?.at(-1) ?? 0) - cutDb(r.at));
check(
'a lane of a parameter starts a clip at its value there and takes its knob along while it plays',
[cutFirst, cutLater].every((r) => r.voices.length === 1 && offCut(r) < 1) &&
Math.abs(cutFirst.clip?.params?.cut - cutDb(startAt)) < 0.01 &&
cutLater.at - cutFirst.at > 600 &&
20 * Math.log10(cutLater.voices[0].at(-1) / cutFirst.voices[0].at(-1)) > 1,
JSON.stringify({ startAt, cutFirst, cutLater, off: [cutFirst, cutLater].map(offCut) }),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const at = song.items.findIndex((i) => i.id === ${JSON.stringify(cutItem.id)});
song.items[at] = ${itemBefore};
delete song.tracks[0].automation;
__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'sg65-fx');
${loopBefore ? `song.loop = ${JSON.stringify(loopBefore)};` : 'delete song.loop;'}`);
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
// Mute closes every send of the track (#350); its own level stays as it is set.
const closed = (list) => {
@@ -633,8 +936,22 @@ export async function song(t) {
{ ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 },
{ ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 },
];
song.loop = { start: ${BAR}, end: ${2 * BAR} };`);
song.loop = { start: ${BAR}, end: ${2 * BAR} };
song.markers = [
{ id: 'sg65-mark', name: 'Round', tick: ${BAR} },
{ id: 'sg65-half', name: 'Half', tick: ${BAR + BAR / 2} },
];
// 65k5: the volume of the sound's track rises from -24 to 0 dB over each round of the loop. It
// starts at 0 dB, as the track is set.
song.tracks[0].automation = [{ target: 'volume', points: [{ tick: 0, value: 0 }, { tick: ${BAR}, value: -24 }, { tick: ${2 * BAR}, value: 0 }] }];
// 65k4: a tempo of its own, for the song's to show when it plays in time with it.
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo = 100;`);
await showTab('scene');
// 65k2 hears it through these (#338): each beat logs bar, beat, how late it is told and where
// Song position stands then, each marker its name.
const joined = (key, a, b, x) => [
node(key, 'Join', x, 400, { ...(a !== null && { a }), ...(b !== null && { b }) }),
];
const rig = await scaffoldBehavior(t, {
assetId: 'sg65-logic',
name: 'Music 65',
@@ -647,14 +964,122 @@ export async function song(t) {
node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }),
node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }),
node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }),
node('sg65-beat', 'OnBeat', 0, 400, { song: id }),
node('sg65-pos', 'SongPosition', 0, 600, { song: id }),
...joined('sg65-j1', 'beat ', null, 200),
...joined('sg65-j2', null, '.', 400),
...joined('sg65-j3', null, null, 600),
...joined('sg65-j4', null, ' ', 800),
...joined('sg65-j5', null, null, 1000),
...joined('sg65-j6', null, ' ', 1200),
...joined('sg65-j7', null, null, 1400),
node('sg65-log', 'Log', 1600, 400),
node('sg65-marker', 'OnSongMarker', 0, 800, { song: id }),
node('sg65-jm', 'Join', 200, 800, { a: 'marker ' }),
node('sg65-logm', 'Log', 400, 800),
// 65k3 (#338): on a beat with W held, Switch song to "Half" on the next bar; with V held, the
// track of the pattern turned down to silence there.
node('sg65-cue', 'OnBeat', 0, 1000, { song: id }),
node('sg65-w', 'KeyIsDown', 0, 1200, { key: 'w' }),
node('sg65-wif', 'Branch', 260, 1000),
node('sg65-switch', 'SwitchSong', 520, 1000, { marker: 'sg65-half', quantize: 'bar', fade: 0 }),
node('sg65-v', 'KeyIsDown', 0, 1400, { key: 'v' }),
node('sg65-vif', 'Branch', 520, 1200),
node('sg65-turn', 'SetSongTrack', 780, 1200, {
track: tracks[1],
volume: -60,
fade: 0,
quantize: 'bar',
}),
// 65k4: with P held, the pattern once as a stinger on the next beat.
node('sg65-p', 'KeyIsDown', 0, 1600, { key: 'p' }),
node('sg65-pif', 'Branch', 780, 1400),
node('sg65-sting', 'PlayPattern', 1040, 1400, { pattern: PATTERN, loop: false, quantize: 'beat' }),
// 65k5: with L held, the volume lane of the sound's track held at -40 dB on the next bar; with
// R held, let go back to its curve over half a second.
node('sg65-l', 'KeyIsDown', 0, 1800, { key: 'l' }),
node('sg65-lif', 'Branch', 1040, 1600),
node('sg65-hold', 'SetSongLane', 1300, 1600, {
track: tracks[0],
lane: 'volume',
value: -40,
fade: 0,
quantize: 'bar',
}),
node('sg65-r', 'KeyIsDown', 0, 2000, { key: 'r' }),
node('sg65-rif', 'Branch', 1300, 1800),
node('sg65-let', 'ReleaseSongLane', 1560, 1800, { track: tracks[0], lane: 'volume', fade: 0.5 }),
// 65k6 (#373): once the stinger is over, the pattern once more, at its own tempo. The
// stinger's number is kept in a variable, as no event reads an output another one left; heard
// by it, the one started after is not heard. And the end of the song, logged.
node('sg65-keep', 'Variables', 1300, 1600, {}, [written('sting', 'number')]),
node('sg65-kept', 'Variables', 1040, 1800, {}, [readPort('sting', 'number')]),
node('sg65-fin', 'OnMusicFinished', 1300, 1400),
node('sg65-again', 'PlayPattern', 1560, 1400, { pattern: PATTERN, loop: false }),
node('sg65-jf', 'Join', 1560, 1600, { a: 'finished stinger ' }),
node('sg65-logf', 'Log', 1820, 1400),
node('sg65-fins', 'OnMusicFinished', 0, 2200, { song: id }),
node('sg65-js', 'Join', 260, 2200, { a: 'finished song ' }),
node('sg65-logs', 'Log', 520, 2200),
],
edges: [
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'),
edge('sg65-b1', 'sg65-beat', 'bar', 'sg65-j1', 'b'),
edge('sg65-b2', 'sg65-j1', 'text', 'sg65-j2', 'a'),
edge('sg65-b3', 'sg65-j2', 'text', 'sg65-j3', 'a'),
edge('sg65-b4', 'sg65-beat', 'beat', 'sg65-j3', 'b'),
edge('sg65-b5', 'sg65-j3', 'text', 'sg65-j4', 'a'),
edge('sg65-b6', 'sg65-j4', 'text', 'sg65-j5', 'a'),
edge('sg65-b7', 'sg65-beat', 'late', 'sg65-j5', 'b'),
edge('sg65-b8', 'sg65-j5', 'text', 'sg65-j6', 'a'),
edge('sg65-b9', 'sg65-j6', 'text', 'sg65-j7', 'a'),
edge('sg65-b10', 'sg65-pos', 'fraction', 'sg65-j7', 'b'),
edge('sg65-b11', 'sg65-beat', 'exec', 'sg65-log', 'exec'),
edge('sg65-b12', 'sg65-j7', 'text', 'sg65-log', 'message'),
edge('sg65-m1', 'sg65-marker', 'name', 'sg65-jm', 'b'),
edge('sg65-m2', 'sg65-marker', 'exec', 'sg65-logm', 'exec'),
edge('sg65-m3', 'sg65-jm', 'text', 'sg65-logm', 'message'),
edge('sg65-c1', 'sg65-cue', 'exec', 'sg65-wif', 'exec'),
edge('sg65-c2', 'sg65-w', 'down', 'sg65-wif', 'condition'),
edge('sg65-c3', 'sg65-wif', 'then', 'sg65-switch', 'exec'),
edge('sg65-c4', 'sg65-cue', 'playback', 'sg65-switch', 'playback'),
edge('sg65-c5', 'sg65-wif', 'else', 'sg65-vif', 'exec'),
edge('sg65-c6', 'sg65-v', 'down', 'sg65-vif', 'condition'),
edge('sg65-c7', 'sg65-vif', 'then', 'sg65-turn', 'exec'),
edge('sg65-c8', 'sg65-cue', 'playback', 'sg65-turn', 'playback'),
edge('sg65-c9', 'sg65-vif', 'else', 'sg65-pif', 'exec'),
edge('sg65-c10', 'sg65-p', 'down', 'sg65-pif', 'condition'),
edge('sg65-c11', 'sg65-pif', 'then', 'sg65-sting', 'exec'),
edge('sg65-c12', 'sg65-cue', 'playback', 'sg65-sting', 'sync'),
edge('sg65-c13', 'sg65-pif', 'else', 'sg65-lif', 'exec'),
edge('sg65-c14', 'sg65-l', 'down', 'sg65-lif', 'condition'),
edge('sg65-c15', 'sg65-lif', 'then', 'sg65-hold', 'exec'),
edge('sg65-c16', 'sg65-cue', 'playback', 'sg65-hold', 'playback'),
edge('sg65-c17', 'sg65-lif', 'else', 'sg65-rif', 'exec'),
edge('sg65-c18', 'sg65-r', 'down', 'sg65-rif', 'condition'),
edge('sg65-c19', 'sg65-rif', 'then', 'sg65-let', 'exec'),
edge('sg65-c20', 'sg65-cue', 'playback', 'sg65-let', 'playback'),
edge('sg65-f0', 'sg65-sting', 'exec', 'sg65-keep', 'exec'),
edge('sg65-f00', 'sg65-sting', 'playback', 'sg65-keep', 'sting'),
edge('sg65-f1', 'sg65-kept', 'sting', 'sg65-fin', 'playback'),
edge('sg65-f2', 'sg65-fin', 'exec', 'sg65-again', 'exec'),
edge('sg65-f3', 'sg65-again', 'exec', 'sg65-logf', 'exec'),
edge('sg65-f4', 'sg65-fin', 'stopped', 'sg65-jf', 'b'),
edge('sg65-f5', 'sg65-jf', 'text', 'sg65-logf', 'message'),
edge('sg65-f6', 'sg65-fins', 'exec', 'sg65-logs', 'exec'),
edge('sg65-f7', 'sg65-fins', 'stopped', 'sg65-js', 'b'),
edge('sg65-f8', 'sg65-js', 'text', 'sg65-logs', 'message'),
],
},
});
await mutate(
`__gm.state.project.assets.find((a) => a.id === 'sg65-logic').variables = ${JSON.stringify([
variable('sting', 'number', 0),
])};`,
);
const playbacks = () => json('__gm.sound.playbacks()');
const heardFrom = t.consoleLog.length;
await play();
const game = await waitFor(playbacks, (list) => list[0]?.planned.length >= 4, 60);
const [run] = game;
@@ -701,11 +1126,248 @@ export async function song(t) {
resumed === 'running',
JSON.stringify({ heldClock, heldLater, resumed }),
);
// 65k2. The game follows the song (#338): On beat runs on every beat as it is heard, 0.5 s apart
// at 120 BPM, bar 2 again after bar 2's last beat as the loop goes round; On song marker
// runs each time round; the pause above held them with the clock. "late" is how long ago
// the beat was heard, and Song position, read then, stands that far into the beat.
// Read from the page's console as the harness keeps it since Play, not from the panel: the
// panel keeps the last 500 lines, and in the full suite other lines pushed beats out of it.
const heard = (text) => /(beat|marker) /.test(text);
const heardLines = async () => t.consoleLog.slice(heardFrom).filter(heard);
// What else the panel was filled with, the three lines most often there: says in the output of
// the check which leftover of an earlier step logs that much.
const panelBusiest = async () => {
const counts = new Map();
for (const { text } of await json('__gm.console.lines()'))
if (!heard(text)) counts.set(text.slice(0, 120), (counts.get(text.slice(0, 120)) ?? 0) + 1);
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3);
};
const heardSome = await waitFor(
heardLines,
(lines) => lines.filter((l) => l.includes('marker Round')).length >= 2,
80,
);
const beats = heardSome.flatMap((text) => {
const m = /beat (\d+)\.(\d+) ([-\d.e]+) ([-\d.e]+)/.exec(text);
return m ? [{ bar: +m[1], beat: +m[2], late: +m[3], fraction: +m[4] }] : [];
});
const counted = beats.map((b) => `${b.bar}.${b.beat}`);
const expected = ['1.1', '1.2', '1.3', '1.4', '2.1', '2.2', '2.3', '2.4', '2.1', '2.2', '2.3', '2.4'];
const markersHeard = heardSome.filter((text) => text.includes('marker Round'));
check(
'On beat runs on every beat of the song as heard, round the loop again from bar 2, none lost to the pause; On song marker each round',
counted.length >= 9 &&
counted.every((c, i) => c === expected[i]) &&
markersHeard.length >= 2 &&
beats.every((b) => b.late >= 0 && b.late <= 0.15 && Math.abs(b.fraction * 0.5 - b.late) < 0.02),
JSON.stringify({ beats, markersHeard: markersHeard.length, panelBusiest: await panelBusiest() }),
);
// 65k3. Music follows the game (#338): W held over a beat asks Switch song for "Half", bar 2 beat 3,
// on the next bar. The playing keeps its number on buses of its own, heard from that bar line
// to the sample; On beat starts over from the marker. V held turns the pattern's track down to
// silence on the bar after, on its input, its level as the song has it.
const clockNow = () => json('__gm.sound.clock()');
const firstPlaying = (await playbacks())[0];
const linesBefore = (await heardLines()).length;
await t.holdKey('w', 600);
const pair = await waitFor(playbacks, (list) => list.length === 2, 20);
const landed = await waitFor(
playbacks,
(list) => list.length === 1 && list[0].startedAt !== firstPlaying?.startedAt,
40,
);
const switchedTo = landed[0];
const beatsSince = (lines) =>
lines.slice(linesBefore).flatMap((text) => {
const m = /\bbeat (\d+)\.(\d+) /.exec(text);
return m ? [`${m[1]}.${m[2]}`] : [];
});
// Only the jump to the marker hears 2.3 right after 2.4: the loop goes from 2.4 to 2.1.
const jumpIn = (beats) => beats.findIndex((b, i) => b === '2.4' && beats[i + 1] === '2.3') + 1;
const sinceSwitch = beatsSince(
await waitFor(
heardLines,
(lines) => {
const beats = beatsSince(lines);
const at = jumpIn(beats);
return at > 0 && beats.length > at + 1;
},
40,
),
);
const fromHalf = jumpIn(sinceSwitch) || -1;
const bars = switchedTo && firstPlaying ? (switchedTo.startedAt - firstPlaying.startedAt) / 2 : NaN;
check(
'Switch song plays the song from its marker on the next bar line, to the sample, under the same number; On beat goes on from the marker',
pair.length === 2 &&
pair.every((p) => p.id === firstPlaying?.id) &&
switchedTo?.id === firstPlaying?.id &&
Math.abs(bars - Math.round(bars)) < 1e-6 &&
Math.round(bars) >= 1 &&
fromHalf >= 0 &&
sinceSwitch[fromHalf + 1] === '2.4',
JSON.stringify({
firstPlaying,
pair: pair.map((p) => [p.id, p.startedAt]),
switchedTo,
bars,
sinceSwitch,
}),
);
const newMaster = (await json('__gm.song.buses()')).find((b) => /^play:\d+$/.test(b.name))?.name;
const pattern = `${newMaster}:${busOf(tracks[1])}`;
const turnedOf = async () => {
const at = await clockNow();
const bus = (await json('__gm.song.buses()')).find((b) => b.name === pattern);
return { at, turned: bus?.turned ?? 1, gain: bus?.gain };
};
// Read from before V on until it is silent: a cut, so 1 up to a bar line and 0 from it, nothing
// between. The switched song started on beat 3 of bar 2, so its bar lines lie 1 s after it, then
// every 2 s.
const turns = [await turnedOf()];
await t.keyDown('v');
const silent = await waitFor(
async () => (turns.push(await turnedOf()), turns.at(-1)),
(r) => r.turned === 0,
40,
);
await t.keyUp('v');
const lastOn = turns.findLast((r) => r.turned === 1);
const firstBar = (switchedTo?.startedAt ?? 0) + 1;
const barLine = lastOn && firstBar + Math.max(0, Math.ceil((lastOn.at - 0.05 - firstBar) / 2)) * 2;
check(
'Set song track turns the pattern’s track to silence on the next bar line, its own level left as the song has it',
newMaster !== `play:${firstPlaying?.id}` &&
silent.turned === 0 &&
turns.every((r) => r.turned === 1 || r.turned === 0) &&
barLine !== undefined &&
silent.at >= barLine - 0.05 &&
silent.at < barLine + 0.5 &&
Math.abs(silent.gain - 10 ** (-6 / 20)) < 1e-6,
JSON.stringify({ newMaster, barLine, turns }),
);
// 65k4. A stinger (#338): P held over a beat plays the pattern once, synced to the song on the
// next beat. It starts on a beat of the song to the sample, at the song's 120 BPM rather
// than its own 100, and goes once it has played.
const pressedAt = await clockNow();
await t.keyDown('p');
const stung = await waitFor(
playbacks,
(list) => list.some((p) => p.name === `pattern:${PATTERN}` && p.planned.length > 0),
40,
);
await t.keyUp('p');
const song = stung.find((p) => p.name === `song:${id}`);
const stinger = stung.find((p) => p.name === `pattern:${PATTERN}`);
const beatsIn = stinger && song ? (stinger.startedAt - song.startedAt) / 0.5 : NaN;
// Every pattern playing seen on the way, the one 65k6 starts after the stinger among them.
const patterns = new Map();
const alone = await waitFor(
async () => {
const list = await playbacks();
for (const p of list)
if (p.name === `pattern:${PATTERN}` && p.startedAt !== null) patterns.set(p.id, p);
return list;
},
(list) => list.length === 1 && patterns.size >= 2,
80,
);
check(
'a pattern synced to the song plays once on its next beat, to the sample, at the tempo of the song',
Math.abs(beatsIn - Math.round(beatsIn)) < 1e-6 &&
stinger.startedAt > pressedAt &&
stinger.startedAt < pressedAt + 1.5 &&
stinger.planned.every((n) => n.tempo === 120) &&
alone.length === 1 &&
alone[0].name === `song:${id}`,
JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }),
);
// 65k6. The end of a playing (#373): On music finished, hearing the stinger by its number, runs
// once it is over, its "stopped" off, and Play pattern starts the pattern once more – after
// the stinger's 2 s, within a frame and the lead of the scheduler. That one it does not hear.
const finishedLines = (what) =>
t.consoleLog.slice(heardFrom).filter((text) => text.includes(`finished ${what}`));
// P held over two beats plays two stingers; the listener hears the last, by the number it left.
const lastStinger = [...patterns.values()].filter((p) => p.startedAt < stinger.startedAt + 1.5).at(-1);
const chained = [...patterns.values()].filter((p) => p.startedAt > lastStinger.startedAt + 1);
const chainedAfter = chained[0] ? chained[0].startedAt - lastStinger.startedAt : NaN;
check(
'On music finished runs once the stinger is over, not stopped, and starts the pattern again right after it',
finishedLines('stinger').length === 1 &&
finishedLines('stinger')[0].endsWith('finished stinger false') &&
chained.length === 1 &&
chainedAfter >= 2 &&
chainedAfter < 2.4,
JSON.stringify({
lines: finishedLines('stinger'),
patterns: [...patterns.values()].map((p) => ({ id: p.id, startedAt: p.startedAt })),
chainedAfter,
}),
);
// 65k5. A game takes a lane over (#338): L held over a beat holds the volume lane of the sound's
// track at -40 dB from the next bar line on, its curve (-24 to 0 dB each round) no longer
// heard; R held lets it go, back to the curve over half a second. Read from the level of the
// track's bus, against the bar lines 65k3 worked out.
const swell = `${newMaster}:${busOf(tracks[0])}`;
const levelOf = async () => {
const at = await clockNow();
const bus = (await json('__gm.song.buses()')).find((b) => b.name === swell);
return { at, db: bus ? 20 * Math.log10(Math.max(bus.level, 1e-6)) : NaN };
};
const onCurve = (r) => r.db >= -24.5 && r.db <= 0.5;
const held = (r) => Math.abs(r.db + 40) < 0.1;
const holding = [await levelOf()];
await t.keyDown('l');
const heldFrom = await waitFor(async () => (holding.push(await levelOf()), holding.at(-1)), held, 40);
await t.keyUp('l');
const lastFree = holding.findLast((r) => !held(r));
const holdBar = lastFree && firstBar + Math.max(0, Math.ceil((lastFree.at - 0.05 - firstBar) / 2)) * 2;
const heldRound = [];
for (let i = 0; i < 10; i++) {
heldRound.push(await levelOf());
await sleep(200);
}
check(
'Set song lane holds the volume lane at -40 dB from the next bar line on, in a cut, and stays there as the curve would rise',
held(heldFrom) &&
holding.every((r) => held(r) || onCurve(r)) &&
holdBar !== undefined &&
heldFrom.at >= holdBar - 0.05 &&
heldFrom.at < holdBar + 0.5 &&
heldRound.every(held),
JSON.stringify({ swell, holdBar, holding, heldRound }),
);
// Read as fast as the page answers, so the half second of the way back is seen.
const letting = [];
await t.keyDown('r');
for (const begun = Date.now(); Date.now() - begun < 6000;) {
letting.push(await levelOf());
const laneFreed = letting.find((r) => !held(r));
if (laneFreed && letting.at(-1).at - laneFreed.at > 0.8) break;
}
await t.keyUp('r');
const laneFreed = letting.find((r) => !held(r));
const freeRound = [];
for (let i = 0; i < 10; i++) {
freeRound.push(await levelOf());
await sleep(200);
}
const spread = Math.max(...freeRound.map((r) => r.db)) - Math.min(...freeRound.map((r) => r.db));
check(
'Release song lane glides back to the curve over half a second, then it follows the curve round the loop again',
laneFreed !== undefined &&
laneFreed.db < -30 &&
letting.filter((r) => r.at >= laneFreed.at + 0.55).every(onCurve) &&
freeRound.every(onCurve) &&
spread > 6,
JSON.stringify({ laneFreed, letting: letting.filter((r, i) => i % 3 === 0), freeRound, spread }),
);
await press('s');
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);
await sleep(100);
const leftBuses = (await json('__gm.song.buses()')).filter((b) => b.name.startsWith(master));
// Every playing's, the one 65k3 switched to among them.
const leftBuses = (await json('__gm.song.buses()')).filter((b) => /^play:/.test(b.name));
await stop();
check(
'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone',
@@ -716,6 +1378,14 @@ export async function song(t) {
leftBuses.length === 0,
JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }),
);
check(
'On music finished runs once for the song after its fade out, "stopped" on – not for the switch 65k3 made',
finishedLines('song').length === 1 && finishedLines('song')[0].endsWith('finished song true'),
JSON.stringify(finishedLines('song')),
);
await mutate(
`delete __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo;`,
);
const left = await rig.remove();
// 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a
+124 -2
View File
@@ -25,6 +25,10 @@ const EARLY = 'vd66-early';
const LATE = 'vd66-late';
const CUT = 'vd66-cut';
const BUS = 'vd66-bus';
const STEER = 'vd66-steer';
const STEERED = 'vd66-steered';
const STEER_LOGIC = 'vd66-steer-logic';
const STEER_BRAIN = 'vd66-steer-brain';
const LANE = LANES.video;
const COLORS = { red: '#fe0000', green: '#00ff01', blue: '#0000ff', white: '#ffffff' };
@@ -702,11 +706,129 @@ export async function video(t) {
JSON.stringify({ settledColor, settled }),
);
// 66s. Steered by a behavior (#410): Pause video holds the picture on a player of its own, Seek
// video sends it to 0.5 s (green) and it stays held there, Play video plays on from there:
// blue, then white held at the end of a video without Loop, and On video finished is heard
// once. Read from the Game tab, the frames after Play in the page. No driven sprite is left
// in the scene: the run would wait for its picture, free videos playing meanwhile, keys lost.
await mutate(`
const p = __gm.state.project;
const gone = ['${OBJECT}', '${EARLY}', '${LATE}'];
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== '${LATE}' && o.id !== '${EARLY}');
p.assets.find((a) => a.id === ${JSON.stringify(assetId)}).props.loop = false;
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(logic, {
id: '${STEER_LOGIC}',
name: '${STEER_LOGIC}',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify({
nodes: [
node('vd66s-p', 'OnKeyDown', 0, 0, { key: 'p' }),
node('vd66s-pause', 'PauseVideo', 260, 0),
node('vd66s-s', 'OnKeyDown', 0, 160, { key: 's' }),
node('vd66s-seek', 'SeekVideo', 260, 160, { time: 0.5 }),
node('vd66s-g', 'OnKeyDown', 0, 320, { key: 'g' }),
node('vd66s-play', 'PlayVideo', 260, 320),
node('vd66s-end', 'OnVideoFinished', 0, 480),
node('vd66s-log', 'Log', 260, 480, { message: 'vd66 finished' }),
],
edges: [
edge('vd66s-e1', 'vd66s-p', 'exec', 'vd66s-pause', 'exec'),
edge('vd66s-e2', 'vd66s-s', 'exec', 'vd66s-seek', 'exec'),
edge('vd66s-e3', 'vd66s-g', 'exec', 'vd66s-play', 'exec'),
edge('vd66s-e4', 'vd66s-end', 'exec', 'vd66s-log', 'exec'),
],
})},
});
p.assets.unshift(logic);
p.assets.unshift({ id: '${STEER}', type: 'sprite', name: '${STEER}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(assetId)}, shader: '' } });
${MAKE_OBJECT}
const cam = scene.objects.find((o) => o.id === 'o3').position;
const screen = make('${STEERED}', '${STEER}', '', 0, '${STEERED}');
Object.assign(screen.position, { x: cam.x + 120, y: cam.y + 80, z: 1000 });
scene.objects.push(screen);
scene.objects.push(make('${STEER_BRAIN}', '${STEER_LOGIC}', '${STEERED}', 0, '${STEER_BRAIN}'));`);
const steeredColor = () =>
json(`(() => {
const r = __gm.gameRect('${STEERED}');
return r ? __gm.pixelAt(Math.round(r.x), Math.round(r.y)) : null;
})()`);
const steeredPlayers = async () =>
(await json('__gm.videos()')).filter((v) => v.key.includes('@') && v.key.includes(STEERED));
const finishedFrom = t.consoleLog.length;
await play();
const steerRun = await waitFor(
() => json('__gm.playInfo()'),
(info) => info.playing && !info.waiting && info.time > 0,
);
const startColor = await steeredColor();
await press('p');
await sleep(900);
const heldColors = [];
for (let i = 0; i < 6; i++) {
heldColors.push(await steeredColor());
await sleep(120);
}
const heldPlayers = await steeredPlayers();
check(
'Pause video holds the picture of a free video, on a player of its own',
!steerRun.waiting &&
startColor === COLORS.red &&
heldColors.every((color) => color === COLORS.red) &&
heldPlayers.length === 1 &&
heldPlayers[0].paused &&
heldPlayers[0].muted,
JSON.stringify({ steerRun, startColor, heldColors, heldPlayers }),
);
await press('s');
const sought = await waitFor(steeredColor, (color) => color === COLORS.green);
await sleep(700);
const soughtHeld = await steeredColor();
const soughtPlayer = (await steeredPlayers())[0];
check(
'Seek video sends it to 0.5 s, green, and it stays held there',
sought === COLORS.green &&
soughtHeld === COLORS.green &&
soughtPlayer?.paused === true &&
Math.abs(soughtPlayer.time - 0.5) < 0.05,
JSON.stringify({ sought, soughtHeld, soughtPlayer }),
);
// The reads end while the run still draws each frame: a read leaves the stage shifted to its pixel
// until the next drawing, and the editor, idle after Stop, would keep it so under every later
// click (e2e 67's Play missed).
await t.evaluate(`window.__vd66s = [];
window.__vd66sOn = true;
(function read() {
if (!window.__vd66sOn) return;
const r = __gm.gameRect('${STEERED}');
if (r) window.__vd66s.push(__gm.pixelAt(Math.round(r.x), Math.round(r.y)));
requestAnimationFrame(read);
})();`);
await press('g');
await sleep(2200);
await t.evaluate('window.__vd66sOn = false');
await sleep(100);
const playedFrames = await json('window.__vd66s.splice(0)');
const finished = t.consoleLog.slice(finishedFrom).filter((line) => line.includes('vd66 finished'));
await stop();
const firstBlue = playedFrames.indexOf(COLORS.blue);
const firstWhite = playedFrames.indexOf(COLORS.white);
check(
'Play video plays on from there to the last picture, and On video finished is heard once',
!playedFrames.includes(COLORS.red) &&
firstBlue > 0 &&
firstWhite > firstBlue &&
playedFrames.slice(firstWhite).every((color) => color === COLORS.white) &&
finished.length === 1,
JSON.stringify({ finished, frames: playedFrames.join(' ') }),
);
await mutate(`
const p = __gm.state.project;
const gone = ['${OBJECT}', '${EARLY}', '${LATE}', '${STEERED}', '${STEER_BRAIN}'];
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => !gone.includes(o.id));
p.assets = p.assets.filter((a) => ![ '${SPRITE}', '${DRIVEN}', '${CUT}', '${BUS}', ${JSON.stringify(assetId)} ].includes(a.id));
p.assets = p.assets.filter((a) => ![ '${SPRITE}', '${DRIVEN}', '${CUT}', '${BUS}', '${STEER}', '${STEER_LOGIC}', ${JSON.stringify(assetId)} ].includes(a.id));
__gm.state.select(null);
__gm.lookAt(0, 0);`);
}
+2
View File
@@ -51,7 +51,9 @@ const KEYS = {
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },
e: { key: 'e', code: 'KeyE', windowsVirtualKeyCode: 69 },
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
l: { key: 'l', code: 'KeyL', windowsVirtualKeyCode: 76 },
p: { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 },
r: { key: 'r', code: 'KeyR', windowsVirtualKeyCode: 82 },
s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },
v: { key: 'v', code: 'KeyV', windowsVirtualKeyCode: 86 },
w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 },