When someone says an osu! skin "sounds wrong", it is almost never the skin's taste in sounds. It is nearly always one of three file problems, and all three can be found with a few lines of Python and fixed with one ffmpeg command. I check for these whenever a downloaded skin is added to osuskins.org, and they cover most of the "my custom hitsounds only work on some maps" reports I have seen.
1. A partial sample set
osu! hitsounds come in three sample sets — normal, soft and drum — and each set has four additions:
normal-hitnormal normal-hitwhistle normal-hitfinish normal-hitclap soft-hitnormal soft-hitwhistle soft-hitfinish soft-hitclap drum-hitnormal drum-hitwhistle drum-hitfinish drum-hitclap A beatmap chooses which set each object uses. If the skin only ships normal-*, every map that uses the soft set plays the default soft sounds. The player hears "my hitsounds" on one map and "someone else's hitsounds" on the next, and blames the skin or the map.
The check is a set comparison. File names are case-insensitive in practice (skins come from Windows), so lower-case everything first:
import os SETS = ("normal", "soft", "drum") ADDITIONS = ("hitnormal", "hitwhistle", "hitfinish", "hitclap") AUDIO = (".wav", ".ogg", ".mp3") def sample_sets(folder): stems = {os.path.splitext(n.lower())[0] for n in os.listdir(folder) if n.lower().endswith(AUDIO)} report = {} for s in SETS: missing = [f"{s}-{a}" for a in ADDITIONS if f"{s}-{a}" not in stems] report[s] = "complete" if not missing else ( "not skinned" if len(missing) == 4 else "partial: " + ", ".join(missing)) return report "Not skinned" is fine — the skin simply uses default sounds for that set, consistently. "Partial" is the bug. The fix is to either supply the missing files or copy the existing ones across, e.g. use your normal-hitclap.wav as soft-hitclap.wav too, so every map sounds the same.
2. MP3 samples feel late
MP3 cannot start a stream at sample zero. Encoders add padding at the start (with LAME the encoder delay plus the decoder delay is commonly around 1,100 samples — roughly 25 ms at 44.1 kHz), and unless the player reads and honours gapless metadata, that silence is played. For music that does not matter. For a hit sound that is supposed to land exactly when you click, it is the difference between "crisp" and "mushy".
Detect it:
def mp3_samples(folder): return [n for n in os.listdir(folder) if n.lower().endswith(".mp3")] Convert to WAV (16-bit PCM is plenty for a hit sound) and remove leading silence while you are at it:
for f in *.mp3; do ffmpeg -loglevel error -i "$f" \ -af "silenceremove=start_periods=1:start_threshold=-60dB" \ -c:a pcm_s16le "${f%.mp3}.wav" && rm "$f" done On Windows PowerShell:
Get-ChildItem *.mp3 | ForEach-Object { ffmpeg -loglevel error -i $_.FullName ` -af "silenceremove=start_periods=1:start_threshold=-60dB" ` -c:a pcm_s16le ($_.BaseName + ".wav") } Keep the same base name — osu! looks up the sample by name, and the extension change from .mp3 to .wav is picked up automatically. Delete the old MP3 so you do not have two candidates for the same sample. OGG works too if you care about size; avoid re-encoding a lossy MP3 into OGG at a low bitrate, though, or you stack two generations of artefacts.
3. Zero-byte files
Skin makers often want to mute something — the combo-break sound, spinner sounds, a section-pass voice — and the quick way is to create an empty file. The game cannot decode a 0-byte file, so it falls back to the default element, which is exactly the sound they wanted to get rid of.
def empty_files(folder): return [n for n in os.listdir(folder) if os.path.getsize(os.path.join(folder, n)) == 0] To really mute an element, ship a short silent file instead:
ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 0.05 -c:a pcm_s16le combobreak.wav The same trick applies to images: a 0-byte hitcircleoverlay.png brings back the default overlay, while a 1×1 transparent PNG really hides it.
Putting it together
All three checks, plus skin.ini sanity and @2x/SD image pairing, are in a small MIT-licensed script, osu-skin-lint. It is read-only and standard-library only:
python3 osu_skin_lint.py "C:/Users/you/AppData/Local/osu!/Skins/My Skin" info normal set: complete WARN soft set partial, falls back to default for: soft-hitnormal, soft-hitwhistle, soft-hitfinish WARN combobreak.wav is 0 bytes: use a short silent file instead to mute an element WARN soft-hitclap.mp3 is MP3: leading encoder silence can make it feel late; use WAV/OGG 3 warning(s) It exits non-zero when there are warnings, so you can run it over a whole Skins directory in a loop before sharing a skin.
Two things that are not the skin's fault
- Beatmap hitsounds override skin hitsounds when the map ships its own samples and the player has not turned beatmap skins/hitsounds off in the client settings (the option names differ between stable and lazer). If one specific map sounds different, check that before touching the skin.
- Universal offset and audio latency are a client setting, not a file problem. Fix the MP3 issue first, then calibrate offset — otherwise you calibrate against the padding.
If you are looking for a skin to start from rather than repairing one, osuskins.org lists skins by how often players actually use them, with the creator credited, and this install guide covers importing a downloaded .osk on stable and lazer. The official osu! wiki skinning section is the reference for element names.
