Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Various fixes and improvements to the APU #716

Closed
wants to merge 18 commits into from
Closed

Conversation

majaha
Copy link
Contributor

@majaha majaha commented May 1, 2024

After the tick-awareness was added recently, I took the opportunity to review the APU code, and found various issues I could fix.

The biggest changes are that now tone() calls will all take affect simultaneously at the end of a tick, and that the audio will pause more smoothly when the game is paused. Other changes are mostly bug fixes and clean-ups.

majaha added 7 commits April 30, 2024 23:26
This bug can be recreated by running
`tone(345, 1, 50, 4)`
as well as a big time-wasting spin-loop every `update()`.
This bug can be recreated by running
`tone(345, 2, 50, 4)`
as well as a big time-wasting spin-loop every `update()`.
The old logic could cause sound glitches if a tick comes in earlier than expected.
This means that tone() calls that are made at different times within a frame all start at the same time.
Also fixes up the code that pauses audio, which was buggy and some of which was dead code.
Copy link
Contributor

@JerwuQu JerwuQu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside from the comment, I will give this a try to see if the approach fixes the problems I've seen previously!

Comment on lines +138 to +143
int channelIdx = flags & 0x3;
bufferedToneCalls[channelIdx].active = true;
bufferedToneCalls[channelIdx].frequency = frequency;
bufferedToneCalls[channelIdx].duration = duration;
bufferedToneCalls[channelIdx].volume = volume;
bufferedToneCalls[channelIdx].flags = flags;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if keeping APU state outside of the APU is desired here.

The APU already has internal global state, so maybe this logic could be placed in apu.c instead to keep it isolated?

@@ -117,7 +114,7 @@ class APUProcessor extends AudioWorkletProcessor {

getCurrentVolume (channel: Channel) {
const time = this.time;
if (time >= channel.sustainTime && (channel.releaseTime - channel.sustainTime) > RELEASE_TIME_TRIANGLE) {
if (time >= channel.sustainTime) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I quickly tested.

This change brings back the continous tone bug.

Here's a cart you can use to test the bug: cart.zip
With this test cart, only the "Short attack" should be popping and all other ones should be smooth.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, so it does.

Can I ask what your specific goal with the whole tick thing was anyway? The more I think about it, the more I think that messing with the ADSR envelope using ticks is weird, and possibly overcomplicating things. If the user asks for the envelope to be only one tick long, why would they be surprised when it ends within one tick?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user asks for the envelope to be only one tick long, why would they be surprised when it ends within one tick?

Put another way: if a user asks for the tone to be one tick long, shouldn't they be surprised if isn't actually one tick long? That doesn't make sense in terms of the abstraction WASM-4 is. There have been bug reports from people being confused about it behaving like that when wanting to make continous tones.


More technically (which I plan to go into in a blog post), custom volume/pitch envelopes and effects like vibrato are simply not feasible if you can't rely on tone to last the length you say it should last. You can do workarounds by extending each tone to be longer (e.g. 2 or 3 ticks), but then you end up with pretty inaccurate envelopes instead that will overextend in whatever direction they are heading towards, especially if framerates are unstable. In these cases it's better to have it stay stable on the last known volume and pitch instead.

Some other features that are very difficult to do if tone length can't be relied upon are live playback (e.g. virtual piano) with working ADSR, and temporarily muting audio and then unmuting with retained note and envelope state.

The issue I am concerned with is specifically when the tone is only one tick long. It should last until the next tick.

Worth noting is all of these issues would disappear if the update loop is bound to the audio callback which I plan on giving a shot later, but it's a big effort.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the previous version, when the user asked for a note that was the length of one tick, that's exactly what they got: a note that was precisely 1/60th of a second long. I think the problem of confused users may be better serviced with documentation saying that only long or overlapping notes are guaranteed to be continuous.

Phrased another way: There's a lot to be said for taking the approach of "a note is a note is a note", i.e. given a particular tone() call, the sound produced is always exactly the same, in length, volume, and pitch. No stretching or other irregularities can occur.

Are the main problems you have with live playback to do with the pitch gliding? I can think of solutions to other problems you might have, but trying to make the pitch-glides work while making extra-duration tone() calls does present a tricky challenge.

I do also see the value in having the note lengths stay in sync with the game's ticks. Especially as the developer can schedule sound events over 10 seconds into the future, and the tone() API presents the lengths as a number of ticks (I wonder whether the same issue would be brought up if tone lengths were in an integer number of milliseconds, or a float of seconds). I see these two approaches as competing ideas, both with their own pros and cons.

However, my gut feeling is that if a tick-based solution is chosen, then the whole note should be tick-based, with each ADSR point waiting for it's tick to come in. The developer could use any of the ADSR points as an important sync point, so we should sync them all. What do you think of that idea? I think I'll try to implement this.
(Another idea is to sync on every tick, but that may be overkill.)

Worth noting is all of these issues would disappear if the update loop is bound to the audio callback which I plan on giving a shot later, but it's a big effort.

About this, I can't see how that's going to be workable. At least, not in the Web runtime, which I know better. You could try though.

majaha added 11 commits July 14, 2024 23:42
Each tone call is split into sections, each of which has a start volume and
frequency, and an end volume and frequency. Each section is played
continuously until either its end *tick* is reached, ending the section and
starting the next, or its end *time* is reached, which holds the tone there
and stretches it out until its end tick is reached.
Add a short fade-out period to the end of all tones to prevent audio clicks and pops.
The web runtime doesn't run on NodeJS, but was using node types. This was causing issues because some node types override the DOM types, like setTimeout().

Adding `"types": []` to tsconfig.json prevents types in node_modules/@types from being used, including node_modules/@types/node, which is installed because it is a dependency of our dependencies.
This commit reworks the frame timing of the runtime ensuring a regular update
every frame on 60 fps monitors, while still supporting other framerates both
higher and lower. It achieves this by measuring the framerate continuously, and
updating in a requestAnimationFrame() callback when the framerate is close to
60, but switching to a setTimer() timing scheme otherwise.

This keeps animation smooth on a 60 fps screen, but keeps updates both
full-speed and at regular 16ms intervals on a 30 fps screen, important for
making tick-based audio timings accurate. This also has the effect of allowing
you to run the runtime in a minimised or background browser window. This is
really improves the audio experience, allows you to run games or demos in the
background, and also makes it much easier to test netplay without having to
keep many windows on screen at once.
Previously favicon.ico was being requested from wasm4.org when developing
locally. This won't work if the user is offline, the website goes down
etc. It also doesn't work well with the new CORS headers I'll introduce
in the next commit.
Setting these security headers grants us access to higher precision time. This
improves our ability to pace frame timings and make accurate performance
measurements. I hope to take advantage of this to improve the devtools in the future.
@majaha majaha closed this Jul 16, 2024
@majaha majaha deleted the apufixes branch July 16, 2024 21:27
@majaha majaha reopened this Jul 16, 2024
@majaha
Copy link
Contributor Author

majaha commented Jul 16, 2024

Alrighty, bit of a behemoth patch here. I made the APU completely tick based, and I also ended up rewriting the frame timing code so that it's much smoother now, whereas before even in perfect conditions it would microstutter. Wasm-4 can also now keep running in a background tab, which is great for the audio experience, and should make netplay much less annoying to work with too. There's also a few other little improvements and clean-ups sprinkled in.

@JerwuQu
Copy link
Contributor

JerwuQu commented Jul 20, 2024

Sounds awesome! Can give it a proper look after I'm back from summer vacation, but if it works properly for the cart linked here and for music in games currently published on wasm4.org it has my approval!

@majaha
Copy link
Contributor Author

majaha commented Jul 23, 2024

Yup, it should be all good!
I've thrown some builds up on my web server:
http://majaha.com/temp/w4/jrwq
http://majaha.com/temp/w4/journey-to-entorus

Hopefully you can see that it all sounds very smooth and in-time. And that you can pause Journey to Entorus or minimise the browser window and it behaves nicely. Also the falling stars should move in a more regular way, hopefully!

@aduros
Copy link
Owner

aduros commented Dec 5, 2024

@JerwuQu Should we merge this? 👀

@majaha
Copy link
Contributor Author

majaha commented Dec 5, 2024

@JerwuQu Should we merge this? 👀

Nah, not right now. I think I'm going to have another pass at some of these things, eventually

@JerwuQu
Copy link
Contributor

JerwuQu commented Dec 5, 2024

Sorry! Postponed this due to the complexity in evalutating it, then completely forgot 😓

Last thing I remember was that performance.now() is not the best for compatibility. High-precision depends on headers being present, and therefore breaks on localhost (at least in the browser I tested) and for users that put their games on sites that simply don't have them (does itch.io have them?).
My suggestion would be to rewrite it to have the timing be based on samples instead, but I'm aware that it's a big ask.

Really appreciate all the effort you put into this!

@majaha
Copy link
Contributor Author

majaha commented Dec 20, 2024

I'm closing this in favour of other pull requests.

@majaha majaha closed this Dec 20, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants