Skip to content

Commit e697473

Browse files
committed
v0.3.1: readme
- CLI with no args invokes GUI. that's better, I think
1 parent 2f8d5ae commit e697473

File tree

6 files changed

+149
-78
lines changed

6 files changed

+149
-78
lines changed

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,5 +150,3 @@ cython_debug/
150150
# and can be added to the global gitignore or merged into this file. For a more nuclear
151151
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
152152
#.idea/
153-
154-
*.png

README.md

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,66 @@
1-
# itl-rhythm
2-
Testing rhythmic complexity algorithms for ITL.
3-
4-
The "+9ms or null" sync analysis also ended up in here. Oops!
1+
# +9ms or Null?
2+
## +9ms or Null? is a StepMania simfile unbiasing utility.
3+
4+
This utility can determine whether the sync bias of a simfile or a pack is +9ms (In The Groove) or null (general StepMania). A future version will also offer to unify it under one of those two options.
5+
6+
***It is not meant to perform a millisecond-perfect sync!*** Please don't depend on the exact value you get from it. Instrument attacks vary significantly, and the algorithm I'm using is not smart enough to know what to focus on.
7+
8+
You can read more about the origins of the +9ms sync bias here:
9+
- [Club Fantastic Wiki's explanation](https://wiki.clubfantastic.dance/Sync#itg-offset-and-the-9ms-bias)
10+
- [Ash's discussion of solutions @ meow.garden](https://meow.garden/killing-the-9ms-bias)
11+
12+
Needless to say, it's about time to put the nine millisecond nail in this coffin.
13+
14+
## How does it work?
15+
I'll get into more detail on this elsewhere, but the seed concept is a visual representation first implemented (as far as I can tell) by beware, of beware's DDR Extreme fame.
16+
1. Identify the time that each beat occurs - I used ashastral's excellent [`simfile`](https://simfile.readthedocs.io/en/latest/) library to do this.
17+
1. Load the audio (`pydub`) and calculate its spectrogram (`numpy` & `scipy`).
18+
1. Snip a small window around each beat time out of the spectrogram and stack them in some way - this becomes the **sync fingerprint**.
19+
- Time will always be on the X axis for this exercise.
20+
- **Beat digest**: Flatten the frequencies (after applying a simple filter to avoid ultra-high/-low bands) and let the Y coordinate be the index of the beat. The most helpful visual, and the original inspiration.
21+
- **Accumulator**: Sum up the window and keep the frequency of the sound energy as the Y coordinate. Not as useful but a good sanity check.
22+
1. Apply a time-domain convolution to identify the audio feature the simfile is sync'd to.
23+
- **Rising edge**: This one seems like the most reliable.
24+
- **Local loudness**: A more naive approach - and a bit easier to fool. But if you want to see what it does, it's here.
25+
1. Take the time at the highest response of the convolution as the bias of the simfile's sync.
26+
1. Decide whether the simfile was sync'd to the +9ms (In The Groove) or the null (StepMania) paradigm, or to neither in particular, by checking whether that bias lies within a small interval around +9ms or 0ms, respectively.
27+
1. Visualize it all for the user (`matplotlib`).
28+
29+
## How to use
30+
31+
### Setting up the project
32+
You have a couple options:
33+
- Clone the repository and use `poetry` to set up the `nine-or-null` package.
34+
1. `poetry install`
35+
1. (CLI) `poetry run python -m nine_or_null "C:\Games\ITGmania\Songs\pack-name"`
36+
1. (GUI) `poetry run python -m nine_or_null.gui` or `poetry run python -m nine_or_null`
37+
- Download the executable (made with PyInstaller).
38+
39+
### Command-line interface (CLI)
40+
It's not as configurable as the GUI yet but that's coming soon. For now you can just call the main routine of the `nine_or_null` package, and pass it the full path to the pack as a command-line argument. If you call it without a path, it'll invoke the GUI.
41+
42+
### Graphical user interface (GUI)
43+
![Screenshot of +9ms or Null v0.2.0](doc/nine-or-null-v0.2.0.png)
44+
45+
The intended workflow:
46+
1. Select the path to the pack or simfile you want to check using the directory button in the upper-right corner (or manually enter in the text box next to it).
47+
1. You can probably leave all the parameters alone for now.
48+
1. Press the big button to **let that sync in**.
49+
1. Let the GUI do some heavy lifting. It'll probably take a few seconds per simfile with the default settings - and it's probably gonna act like it's unresponsive. (There's some threading I could do to fix that, but I'll get to that later.)
50+
1. Watch the status bar, results table, and sync fingerprint plots for updates.
51+
- From top to bottom, the plots represent the frequency domain accumulator, the beat digest, and the convolved fingerprint.
52+
- The red line indicates where the maximum kernel response lies, and thus the sync bias of this simfile. The white line is the kernel response for the whole fingerprint vs. local time.
53+
1. Once the status bar indicates the job is "Done!", the number of paradigm-adherent simfiles will appear above the results table.
54+
1. Feel free to "View logs", "View plots", or double-click on individual simfiles in the results table to reload plots in the neighboring pane.
55+
1. In the future, the two arrows above the paradigm counts will allow you to batch adjust the sync bias on simfiles (either from +9ms to null, or vice versa). ***Again, not a millisecond-accurate sync utility! It's only offering to add or subtract 9ms.***
56+
57+
(If your computer starts really chugging during the bias check, bump the "Spectral step" up or the "Spectral window" down - both of these sacrifice a bit of spectrogram precision but the results are still generally good.)
58+
59+
## Future plans
60+
- Batch adjust the sync bias on simfiles (either from +9ms to null, or vice versa)
61+
- Handle split timing properly when it's present
62+
- If a straight vertical line "fit" can identify bias, then a line fit with both local time and beat index dependence could also identify sync drift...hmm...
63+
64+
65+
----
66+
Also there's a little broom closet in this repo where I was doing some testing on rhythmic complexity algorithms. Don't worry about that for now ;)

doc/nine-or-null-v0.2.0.png

167 KB
Loading

nine-or-null/nine_or_null/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
_VERSION = '0.3.0'
1+
_VERSION = '0.3.1'
22

33
from datetime import datetime as dt
44
from enum import IntEnum

nine-or-null/nine_or_null/__main__.py

Lines changed: 75 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,84 +3,92 @@
33
import logging
44

55
from . import BiasKernel, KernelTarget, batch_process, guess_paradigm, _VERSION
6+
from .gui import start_gui
67

78
if __name__ == '__main__':
89
print(f'+9ms or Null? v{_VERSION}')
9-
root_path = os.getcwd()
10-
if len(sys.argv) > 1:
11-
root_path = sys.argv[1]
12-
13-
# Verify existence of root path
14-
if not os.path.isdir(root_path):
15-
sys.exit(f'Root directory doesn\'t exist: {root_path}')
16-
else:
17-
print(f"Root directory exists: {root_path}")
1810

19-
# Verify existence of root path
20-
report_path = os.path.join(root_path, '__bias-check')
21-
if not os.path.isdir(report_path):
22-
try:
23-
os.makedirs(report_path)
24-
print(f"Report directory created: {report_path}")
25-
except Exception as e:
26-
sys.exit(f'Report directory can\'t be created: {report_path}')
11+
if len(sys.argv) <= 1:
12+
print('No path to simfile or pack provided. Entering GUI mode...')
13+
start_gui()
14+
print('Exiting GUI mode. Thank you for playing!')
2715
else:
28-
print(f"Report directory exists: {report_path}")
16+
print('Entering CLI mode...')
17+
18+
root_path = sys.argv[1]
19+
20+
# Verify existence of root path
21+
if not os.path.isdir(root_path):
22+
sys.exit(f'Root directory doesn\'t exist: {root_path}')
23+
else:
24+
print(f"Root directory exists: {root_path}")
25+
26+
# Verify existence of root path
27+
report_path = os.path.join(root_path, '__bias-check')
28+
if not os.path.isdir(report_path):
29+
try:
30+
os.makedirs(report_path)
31+
print(f"Report directory created: {report_path}")
32+
except Exception as e:
33+
sys.exit(f'Report directory can\'t be created: {report_path}')
34+
else:
35+
print(f"Report directory exists: {report_path}")
2936

30-
# Set up logging
31-
log_path = os.path.join(report_path, 'nine-or-null.log')
32-
log_fmt = logging.Formatter(
33-
'[%(asctime)s.%(msecs)03d] %(levelname)-8s %(message)s',
34-
datefmt='%Y-%m-%d %H:%M:%S'
35-
)
36-
logging.basicConfig(
37-
filename=log_path,
38-
encoding='utf-8',
39-
level=logging.INFO
40-
)
41-
logging.getLogger().addHandler(logging.StreamHandler())
42-
for handler in logging.getLogger().handlers:
43-
handler.setFormatter(log_fmt)
37+
# Set up logging
38+
log_path = os.path.join(report_path, 'nine-or-null.log')
39+
log_fmt = logging.Formatter(
40+
'[%(asctime)s.%(msecs)03d] %(levelname)-8s %(message)s',
41+
datefmt='%Y-%m-%d %H:%M:%S'
42+
)
43+
logging.basicConfig(
44+
filename=log_path,
45+
encoding='utf-8',
46+
level=logging.INFO
47+
)
48+
logging.getLogger().addHandler(logging.StreamHandler())
49+
for handler in logging.getLogger().handlers:
50+
handler.setFormatter(log_fmt)
4451

45-
# Default parameters.
46-
params = {}
47-
params['root_path'] = root_path
48-
params['report_path'] = report_path
49-
params['consider_null'] = True
50-
params['consider_p9ms'] = True
51-
params['tolerance'] = 3.0
52-
params['fingerprint_ms'] = 50
53-
params['window_ms'] = 10
54-
params['step_ms'] = 0.20
55-
params['kernel_target'] = KernelTarget.DIGEST
56-
params['kernel_type'] = BiasKernel.RISING
57-
params['magic_offset'] = 2.0
52+
# Default parameters.
53+
params = {}
54+
params['root_path'] = root_path
55+
params['report_path'] = report_path
56+
params['consider_null'] = True
57+
params['consider_p9ms'] = True
58+
params['tolerance'] = 3.0
59+
params['fingerprint_ms'] = 50
60+
params['window_ms'] = 10
61+
params['step_ms'] = 0.20
62+
params['kernel_target'] = KernelTarget.DIGEST
63+
params['kernel_type'] = BiasKernel.RISING
64+
params['magic_offset'] = 2.0
5865

59-
# Recall parameters.
60-
header_str = f'+9ms or Null? v{_VERSION} (CLI)'
61-
logging.info(f"{'=' * 20}{header_str:^32s}{'=' * 20}")
62-
logging.info('Parameter settings:')
63-
for k, v in params.items():
64-
logging.info(f'\t{k} = {v}')
66+
# Recall parameters.
67+
header_str = f'+9ms or Null? v{_VERSION} (CLI)'
68+
logging.info(f"{'=' * 20}{header_str:^32s}{'=' * 20}")
69+
logging.info('Parameter settings:')
70+
for k, v in params.items():
71+
logging.info(f'\t{k} = {v}')
6572

66-
fingerprints = batch_process(**params)
73+
fingerprints = batch_process(**params)
6774

68-
logging.info('-' * 72)
69-
logging.info(f"Sync bias report: {len(fingerprints)} fingerprints processed in {root_path}")
75+
logging.info('-' * 72)
76+
logging.info(f"Sync bias report: {len(fingerprints)} fingerprints processed in {root_path}")
7077

71-
paradigm_count = {}
72-
for paradigm in ['+9ms', 'null', '????']:
73-
paradigm_map = {k: v for k, v in fingerprints.items() if guess_paradigm(v['bias_result']) == paradigm}
74-
logging.info(f"Files sync'd to {paradigm}: {len(paradigm_map)}")
75-
for k, v in paradigm_map.items():
76-
logging.info(f"\t{k:>50s}")
77-
logging.info(f"\t\tderived sync bias = {v['bias_result']:+0.1f} ms")
78-
paradigm_count[paradigm] = len(paradigm_map)
78+
paradigm_count = {}
79+
for paradigm in ['+9ms', 'null', '????']:
80+
paradigm_map = {k: v for k, v in fingerprints.items() if guess_paradigm(v['bias_result']) == paradigm}
81+
logging.info(f"Files sync'd to {paradigm}: {len(paradigm_map)}")
82+
for k, v in paradigm_map.items():
83+
logging.info(f"\t{k:>50s}")
84+
logging.info(f"\t\tderived sync bias = {v['bias_result']:+0.1f} ms")
85+
paradigm_count[paradigm] = len(paradigm_map)
7986

80-
paradigm_most = sorted([k for k in paradigm_count], key=lambda k: paradigm_count.get(k, 0))
81-
logging.info('=' * 72)
82-
logging.info(f'Pack sync paradigm: {paradigm_most[-1]}')
83-
logging.info('-' * 72)
87+
paradigm_most = sorted([k for k in paradigm_count], key=lambda k: paradigm_count.get(k, 0))
88+
logging.info('=' * 72)
89+
logging.info(f'Pack sync paradigm: {paradigm_most[-1]}')
90+
logging.info('-' * 72)
8491

85-
# Done!
92+
# Done!
93+
print('Exiting CLI mode. Thank you for playing!')
8694

nine-or-null/nine_or_null/gui.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ def __init__(self, *args, **kwargs):
2323
self.SetSize((540, 300))
2424

2525
label_preceding = wx.StaticText(self,
26-
label=re.sub('\n[ \t]+', '\n', """+9ms or Null? is a StepMania simfile sync bias utility.
26+
label=re.sub('\n[ \t]+', '\n', """+9ms or Null? is a StepMania simfile unbiasing utility.
2727
28-
This utility can determine whether the sync bias of a simfile or a pack is +9ms (In The Groove) or null (general StepMania) and offer to unify it under one of those two options.
28+
This utility can determine whether the sync bias of a simfile or a pack is +9ms (In The Groove) or null (general StepMania). A future version will also offer to unify it under one of those two options.
2929
It is not meant to perform a millisecond-perfect sync!
3030
3131
You can read more about the origins of the +9ms sync bias here:"""),
@@ -327,7 +327,7 @@ def __init__(self, *args, **kwargs):
327327
self.panel_plot.figure = Figure(dpi=48)
328328
gs = self.panel_plot.figure.add_gridspec(3) # hspace
329329
self.panel_plot.axes = gs.subplots(sharex=True, sharey=False)
330-
self.panel_plot.figure.suptitle('Audio fingerprint\nArtist - "Title"\nSync bias: +0.003 (probably null)')
330+
self.panel_plot.figure.suptitle('Sync fingerprint\nArtist - "Title"\nSync bias: +0.003 (probably null)')
331331
x_test = np.linspace(-50, 50, 101, endpoint=True)
332332
# self.panel_plot.axes[0].plot(x_test, np.sin(x_test / 10), 'r-')
333333
# self.panel_plot.axes[1].plot(x_test, np.sin(x_test / 12), 'g-')
@@ -610,9 +610,12 @@ def OnAbout(self, event):
610610
dlg.ShowModal()
611611

612612

613-
if __name__ == '__main__':
613+
def start_gui():
614614
app = wx.App()
615615
frame = NineOrNull(None, title=f'+9ms or Null? v{_VERSION}', style=wx.DEFAULT_FRAME_STYLE & ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX))
616616
frame.Show()
617617
app.MainLoop()
618618

619+
620+
if __name__ == '__main__':
621+
start_gui()

0 commit comments

Comments
 (0)