bunnytrack / umx-converter

JavaScript plugin for converting audio to UMX format (UT99)
4 stars 0 forks source link

Add dithering option #1

Open peterekepeter opened 2 years ago

peterekepeter commented 2 years ago

Hi! First off thank you for the UMX converter! It really simplifies the conversion job! I can see it is widely known and used.

There is one really useful trick you can do to squeeze out extra quality with lower size conversion and it's called dithering. Essentially it helps reduce harmonic distortion when you're going down to lower bits from a higher bit signal.

This is especially useful when going down to 8-bit!

Example (with 4-bit): https://www.youtube.com/watch?v=h59LwyJbfzs Explanation (for a bit of theory): https://www.youtube.com/watch?v=ZMbTUD6c4a8

Now there are multiple algorithms to do this, I present to you 2 algorithms:

1) Just add a bit of noise

const noise_amount = 1; // value range 0..1 controls the amount of noise added 
for (var i = 0; i < input.length; i++, offset++) 
{
    let sample = input[i];
    sample = sample * 256 / 2;  // maps to -128..+128 range
    sample += (Math.random() - 0.5) * noise_amount; // add signed noise
    sample = Math.round(sample); // converts to integer value
    sample = Math.max(-128, Math.min(sample, 127)); // clip to -128..+127 range
    output.setInt8(offset, sample)
}

2) Error propagation

const error_propagation = 1; // 0..1 controls how much error is added to adjacent sample 
let error = 0;
for (var i = 0; i < input.length; i++, offset++) 
{
    const original_sample = input[i];
    let sample = original_sample * 256 / 2;  // maps to -128..+128 range
    sample += error * error_propagation; // propagates error into adjacent sample
    sample = Math.round(sample); // converts to integer value
    let quantized_sample = sample / 256 * 2; /// maps back to -1..+1 range
    error += original_sample - quantized_sample; // accumulate error for adjacent samples
    sample = Math.max(-128, Math.min(sample, 127)); // clip to -128..+127 range
    output.setInt8(offset, sample)
}

I suspect the 2nd algorithm can get rid of the static noise where the music is silent, but it may introduce an artifact when the music is extra low volume, or when it has a long fade-out.

A combination of the two might be worth implementing. Something like 80% error propagation + 40% noise. Adding signed noise values in range -0.4..+0.4 would still help with conversion but would avoid adding static noise when the signal is silent as it would essentially be rounded to zero.

Let me know what you think!

sapphire-bt commented 2 years ago

Hi there,

Apologies for such a delayed reply; I don't think I was actually following this organisation and never received a notification…

Thank you for the detailed feedback; I'll definitely have a go at adding dithering in the coming days/weeks.

Was there a specific track/piece of music that you noticed had suffered after conversion?

Thanks again.

PS: are you active on UT?

peterekepeter commented 2 years ago

Yeah no problemo, it's free and opensource I don't expect fast reply

I don't have a specific track, but converting to low bit depth dithering is pretty standard. It makes the 8-bit samples sounds a lot closer to 16-bit samples. So you can get the pros of 8bit samples using half the space with losing less precision.

I'd make a PR myself but I'm a bit confused on how all your projects are wired up together. This looks like a standalone script I'd embed in my own webpage.

Yep I'm active on UT99. And I see a lot of people using this converter for their custom maps.

There is one extra issue I found that sometimes the song loops badly, too early. I think its some compatibility issue when song has high row-count I think in some cases it resets after 128 or 256 rows, can causes the song to prematurely loop. I didn't open issue because I wasn't able to get stable reproduction steps myself. If I get stable steps I'll open a bug here.

Another possible improvement could be to use XM format. In XM I know the samples use delta encoding, which transforms the signal from 0 16 32 48 64, 65, 66, 66, 66, 65, 64 to 0 16 16 16 16 1 1 0 0 0 1 -1 this trick increases redundancy for continuous samples which could yield extra compression when the UMX package goes through UZ compression.

Just some ideas for now. If I get enough time I'll investigate deeper and open issue or PR. I don't see the point of me making a separate UMX converter.

sapphire-bt commented 2 years ago

I'd make a PR myself but I'm a bit confused on how all your projects are wired up together. This looks like a standalone script I'd embed in my own webpage.

Exactly - this is just a standalone script, not connected to any of the other repos. Pull requests welcome :)

When I get around to picking this up again I'll try and replicate the looping issue you've described.

Great idea with XM format - I'll open a separate issue for that.

Thanks again :+1:

sapphire-bt commented 2 years ago

Hi there.

I've created a dev branch and implemented the dither functions in your post - see https://github.com/bunnytrack/umx-converter/commit/7a92f06a31df2c6e89cdc50718e0802a03ea7a3b.

Personally I find it difficult to notice a discernible difference unless the input audio is a sine wave test tone and I'm using headphones. Perhaps I haven't implemented the algorithms correctly or they may be too simple.

I also searched for existing JavaScript dither solutions and there are some pretty sophisticated methods out there, e.g. this repo, with a demo here, but at first glance it looks like it would be tricky to implement into this project (it uses WebAssembly), and it's probably a bit overkill.

Let me know what you think when you get a chance.

peterekepeter commented 2 years ago

I will take a look some time later. I'm way too busy these days. Differences should be more noticeable when audio fades in/out or when the input is low volume and you end up boosting the volume by duplicating the channels.