diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c45b52..78a352d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { "cSpell.ignoreWords": [ "Colour", - "mido" + "mido", + "curtime" ], "python.testing.pytestArgs": [ "test" diff --git a/README.md b/README.md index 6deaa67..3d46a92 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,17 @@ -# PlaySK Piano Roll Reader Ver3.5 +# PlaySK Piano Roll Reader Ver3.6 Optically reading a piano roll image, emulates expression and output midi signal in real-time. ![Overall System](./assets/Overall_System.webp) -The "virtual tracker bar" optically picks up roll holes then emulates note, pedal and expression code. The expression code is decoded to vacuum level (in inches of water) in real-time, then convert to MIDI velocity. +The "Virtual Tracker Bar" optically picks up roll holes then emulates note, pedal and expression code. The expression code is decoded to vacuum level (in inches of water) in real-time, then convert to MIDI velocity. Currently, 9 virtual tracker bars are available. +- Aeolian 176-note Duo-Art Pipe Organ ([MIDI output assignment](https://playsk-aeolian176note-midi-assignment.pages.dev/)) - Standard 88-note - Ampico B - Duo-Art -- Welte-Mignon Licensee -- Welte-Mignon T-98 (Green) -- Welte-Mignon T-100 (Red) +- Welte-Mignon T-100 (Red) / T-98 (Green) / Licensee - Philipps Duca (no expression. experimental) - Recordo version A / B - Artecho @@ -21,9 +20,7 @@ Currently, 9 virtual tracker bars are available. In the future, Ampico A will be supported. -Support image formats are `.cis`, `.jpg`, `.tif`, `.png`, `.bmp`. - -`.cis` supports various scanners such as stepper, wheel/shaft encoder, bi-color, twin-array. +Support image formats are `.CIS`, `.jpg`, `.tif`, `.png`, `.bmp`. The `.CIS` supports various scanners such as stepper, wheel/shaft encoder, bi-color, twin-array. ## Demo @@ -33,6 +30,9 @@ Support image formats are `.cis`, `.jpg`, `.tif`, `.png`, `.bmp`. - Reading a Red Welte T-100 roll with Software Synthesizer https://www.youtube.com/watch?v=WMEPW-UWhSU +- Reading an Aeolian 176-note Pipe Organ roll with Hauptwerk virtual organ + https://www.youtube.com/watch?v=N0Gm2g1ADjk + ## Donation I personally pay near $100 a year to codesign and notarize software for distribution. Your support greatly contributes to the continuous development and improvement of the Software. Please consider donating. @@ -57,12 +57,13 @@ If you check `☑ Manual Expression`, you can express dynamics using the keyboar ## Tips * The program picks up lighted holes of image. -* Automatically set the tempo if the input filename has the tempo keyword (except .cis) - * e.g.) `Ampico 52305 Clair de Lune tempo90.jpg` -> set the tempo to 90 automatically. - * If no keyword is given, the default tempo is set. 98 for the Welte T-100 and 80 for the others. -* Associate the program with .cis on right-click menu, you can run app by double-clicking .cis file. -* The roll acceleration emulating is done by spool diameter and roll thickness. -* The roll scrolling direction is downward. So the Welte T-100 image should be inverted. +* The vacuum level is emulated in inches of water and later converted to MIDI velocity. +* The roll acceleration is emulated by spool diameter and roll thickness. +* The roll tempo is automatically set. + * CIS images... from CIS header. + * Other images... from .ANN file if exists. Or from filename such as `Ampico 52305 Clair de Lune tempoXX.jpg`. +* Associate the program with .CIS on right-click menu, you can run app by double-clicking .CIS file. +* The roll scrolling direction is always downward. So the Welte T-100 image should be inverted. # For developers diff --git a/assets/Aeolian_176note_MIDI_setting.html b/assets/Aeolian_176note_MIDI_setting.html new file mode 100644 index 0000000..33a5ae7 --- /dev/null +++ b/assets/Aeolian_176note_MIDI_setting.html @@ -0,0 +1,274 @@ + + + +

PlaySK Piano Roll Reader

+

Aeolian 176-note tracker bar
+ MIDI Output Assignment

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlMIDI
channel
MIDI
note number
MIDI
control change
MIDI
value
supplement
Swell Notes136-96--61 notes
Great Notes236-96--61 notes
Pedal Notes336-67--32 notes
Swell Expression Shade4-1430-12730 is fully closed.
127 is fully opened.
Great Expression Shade-1530-127
Echo-20:ON
110:OFF

For Hauptwerk virtual organ, corresponds to
"Notation stop or hold-piston; CC20=on, CC110=off"
0Echo couples the Ehco division to upper holes and releases Swell. This is not implemented yet.
Swell Chimes-1
Swell Tremolo-2
Swell Harp-3
Swell Trumpet-4
Swell Oboe-5
Swell Vox Humana-6
Swell Diapason mf-7
Swell Flute 16-8
Swell Flute 4-9
Swell Flute p-10
Swell String Vibrato f-11
Swell String f-12
Swell String mf-13
Swell String p-14
Swell String pp-15
Swell Soft Chimes-17
Great Tremolo-18
Tonal-19Tonal turns on most stops. This is not implemented yet.
Great Harp-20
Pedal Bassoon 16-24
Pedal String 16-25
Pedal Flute f16-26
Pedal Flute p16-27
Great String pp-28
Great String p-29
Great String f-30
Great Flute p-31
Great Flute f-32
Great Flute 4-33
Great Diapason f-34
Great Piccolo-35
Great Clarinet-36
Great Trumpet-37
Great Chime Damper-38
+ +

Katz Sasaki
14 Dec 2024

+ PlaySK Piano Roll Reader + \ No newline at end of file diff --git a/assets/How to use Mac.png b/assets/How to use Mac.png index c74089c..4b119cb 100644 Binary files a/assets/How to use Mac.png and b/assets/How to use Mac.png differ diff --git a/assets/Overall_System.jpg b/assets/Overall_System.jpg deleted file mode 100644 index e808b03..0000000 Binary files a/assets/Overall_System.jpg and /dev/null differ diff --git a/assets/Overall_System.webp b/assets/Overall_System.webp index 3d0e896..2624857 100644 Binary files a/assets/Overall_System.webp and b/assets/Overall_System.webp differ diff --git a/assets/dmg-bg.png b/assets/dmg-bg.png deleted file mode 100644 index 2861529..0000000 Binary files a/assets/dmg-bg.png and /dev/null differ diff --git a/assets/dmg-bgx2.png b/assets/dmg-bgx2.png deleted file mode 100644 index e21c44d..0000000 Binary files a/assets/dmg-bgx2.png and /dev/null differ diff --git a/build_mac.sh b/build_mac.sh index e2352f7..5539765 100755 --- a/build_mac.sh +++ b/build_mac.sh @@ -18,4 +18,5 @@ pip-licenses --format=plain-vertical --with-license-file --no-license-path --out # copy files cp -p "3rd-party-license.txt" dist/ cp -p "assets/How to use Mac.png" dist/ -cp -pr src/playsk_config/ dist/playsk_config/ \ No newline at end of file +cp -pr src/playsk_config/ dist/playsk_config/ +cp -p "assets/Aeolian_176note_MIDI_setting.html" dist/ \ No newline at end of file diff --git a/build_mac.spec b/build_mac.spec index 9d69d1d..ab2f178 100644 --- a/build_mac.spec +++ b/build_mac.spec @@ -46,5 +46,5 @@ app = BUNDLE( name='PlaySK Piano Roll Reader.app', icon='src/playsk_config/PlaySK_icon.ico', bundle_identifier=None, - version='3.5.2' + version='3.6.0' ) diff --git a/build_win.bat b/build_win.bat index b53605e..e472821 100644 --- a/build_win.bat +++ b/build_win.bat @@ -18,4 +18,5 @@ pip-licenses --format=plain-vertical --with-license-file --no-license-path --out rem copy files xcopy /i /y "3rd-party-license.txt" ".\dist\PlaySK Piano Roll Reader\" xcopy /i /y ".\assets\How to use.png" ".\dist\PlaySK Piano Roll Reader\" -xcopy /s /i /y ".\src\playsk_config\" ".\dist\PlaySK Piano Roll Reader\playsk_config\" \ No newline at end of file +xcopy /s /i /y ".\src\playsk_config\" ".\dist\PlaySK Piano Roll Reader\playsk_config\" +xcopy /i /y ".\assets\Aeolian_176note_MIDI_setting.html" ".\dist\PlaySK Piano Roll Reader\" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a61f2ef..33ff582 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "PlaySK-Piano-Roll-Reader" -version = "3.5.2" +version = "3.6.0" description = "Optically reading a piano roll image, emulates expression and output midi signal in real-time." authors = ["nai-kon "] readme = "README.md" diff --git a/sample_scans/Aeolian 176-note 3198 Madam Butterfly Selections Part2.CIS b/sample_scans/Aeolian 176-note 3198 Madam Butterfly Selections Part2.CIS new file mode 100644 index 0000000..0129e03 Binary files /dev/null and b/sample_scans/Aeolian 176-note 3198 Madam Butterfly Selections Part2.CIS differ diff --git a/sample_scans/Aeolian 176-note 3604 Pagan Love Song.CIS b/sample_scans/Aeolian 176-note 3604 Pagan Love Song.CIS new file mode 100644 index 0000000..4b031b4 Binary files /dev/null and b/sample_scans/Aeolian 176-note 3604 Pagan Love Song.CIS differ diff --git a/sample_scans/Aeolian 176-note 3613 Symphony No 6 3rd Mvmt.CIS b/sample_scans/Aeolian 176-note 3613 Symphony No 6 3rd Mvmt.CIS new file mode 100644 index 0000000..224ecf9 Binary files /dev/null and b/sample_scans/Aeolian 176-note 3613 Symphony No 6 3rd Mvmt.CIS differ diff --git a/sample_scans/Aeolian 176-note 3617 Ah Sweet Mystery Of Life.CIS b/sample_scans/Aeolian 176-note 3617 Ah Sweet Mystery Of Life.CIS new file mode 100644 index 0000000..2236038 Binary files /dev/null and b/sample_scans/Aeolian 176-note 3617 Ah Sweet Mystery Of Life.CIS differ diff --git a/sample_scans/Aeolian 176-note 3618 O Holy Night.CIS b/sample_scans/Aeolian 176-note 3618 O Holy Night.CIS new file mode 100644 index 0000000..e6bb4f4 Binary files /dev/null and b/sample_scans/Aeolian 176-note 3618 O Holy Night.CIS differ diff --git a/sample_scans/Ampico B 100775 Liszt Concerto No1 tempo85.png b/sample_scans/Ampico B 100775 Liszt Concerto No1 tempo85.png deleted file mode 100644 index b793bc0..0000000 Binary files a/sample_scans/Ampico B 100775 Liszt Concerto No1 tempo85.png and /dev/null differ diff --git a/sample_scans/Ampico B 68711 To Spring tempo100.png b/sample_scans/Ampico B 68711 To Spring tempo100.png deleted file mode 100644 index 6a8694a..0000000 Binary files a/sample_scans/Ampico B 68711 To Spring tempo100.png and /dev/null differ diff --git a/sample_scans/Duo-Art 7241 Carmen Excerpts, Act II .CIS b/sample_scans/Duo-Art 7241 Carmen Excerpts, Act II .CIS new file mode 100644 index 0000000..c55816d Binary files /dev/null and b/sample_scans/Duo-Art 7241 Carmen Excerpts, Act II .CIS differ diff --git a/src/build_mac.spec b/src/build_mac.spec deleted file mode 100644 index 0f72c54..0000000 --- a/src/build_mac.spec +++ /dev/null @@ -1,50 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - - -a = Analysis( - ['src/main.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='PlaySK Piano Roll Reader', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='PlaySK Piano Roll Reader', -) -app = BUNDLE( - coll, - name='PlaySK Piano Roll Reader.app', - icon='src/config/PlaySK_icon.ico', - bundle_identifier="com.KatzSasaki.PlaySK", - version='3.5.2' -) diff --git a/src/cis_decoder/cis_decoder.pyx b/src/cis_decoder/cis_decoder.pyx index 298d8ed..f168724 100644 --- a/src/cis_decoder/cis_decoder.pyx +++ b/src/cis_decoder/cis_decoder.pyx @@ -8,8 +8,7 @@ cdef enum CurColor: ROLL = 1 MARK = 2 - -@cython.boundscheck(False) +# @cython.boundscheck(False) comment out for some broken CIS which needs bound check @cython.wraparound(False) @cython.cdivision(True) def _get_decode_params(cnp.ndarray[cnp.uint16_t, ndim=1] data, @@ -30,12 +29,12 @@ def _get_decode_params(cnp.ndarray[cnp.uint16_t, ndim=1] data, int encoder_state int pre_encoder_state = -1 float step - int chs = 1 + int(is_twin_array) + int(is_bicolor) + int chs = 1 + is_twin_array + is_bicolor list buf_lines = [] list reclock_map = [] cdef extern from "math.h": - double rint(double x) + long lrint(double x) # width if is_twin_array: @@ -61,7 +60,7 @@ def _get_decode_params(cnp.ndarray[cnp.uint16_t, ndim=1] data, # make re-clock map step = (ei - si) / float(lpt - 1) for val in range(lpt): - val = int(rint(si + val * step)) + val = lrint(si + val * step) reclock_map.append([val, height]) # [src line, dest line] height += 1 buf_lines = [] @@ -72,7 +71,7 @@ def _get_decode_params(cnp.ndarray[cnp.uint16_t, ndim=1] data, # adjust re-clock map for cur_line in range(len(reclock_map)): - reclock_map[cur_line][1] = height - reclock_map[cur_line][1] + reclock_map[cur_line][1] = height - reclock_map[cur_line][1] - 1 if not reclock_map: raise ValueError("No encoder clock signal found") @@ -83,12 +82,12 @@ def _get_decode_params(cnp.ndarray[cnp.uint16_t, ndim=1] data, return width, height, reclock_map -@cython.boundscheck(False) +# @cython.boundscheck(False) comment out for some broken CIS which needs bound check @cython.wraparound(False) def _decode_cis(cnp.ndarray[cnp.uint16_t, ndim=1] data, cnp.ndarray[cnp.uint8_t, ndim=2] out_img, int vert_px, int hol_px, bint is_bicolor, bint is_twin_array, bint is_clocked, - int twin_array_overlap, int twin_array_vsep, int end_padding_y, list reclock_map): + int twin_array_overlap, int twin_array_vsep, list reclock_map): # CIS file format # http://semitone440.co.uk/rolls/utils/cisheader/cis-format.htm#scantype @@ -108,7 +107,7 @@ def _decode_cis(cnp.ndarray[cnp.uint16_t, ndim=1] data, int ex # decode lines - for cur_line in range(vert_px + end_padding_y - 1, end_padding_y, -1): + for cur_line in range(vert_px - 1, 0, -1): last_pos = 0 cur_pix = ROLL while last_pos != hol_px: @@ -163,4 +162,4 @@ def _decode_cis(cnp.ndarray[cnp.uint16_t, ndim=1] data, if is_clocked: # reposition lines for sx, ex in reclock_map: - out_img[ex + end_padding_y] = out_img[sx + end_padding_y] \ No newline at end of file + out_img[ex] = out_img[sx] \ No newline at end of file diff --git a/src/cis_image.py b/src/cis_image.py index efb8dcf..19076bb 100644 --- a/src/cis_image.py +++ b/src/cis_image.py @@ -37,6 +37,7 @@ class CisImage: """ def __init__(self) -> None: self.desc = "" + self.file_path = "" self.scanner_type = ScannerType.UNKNOWN self.is_clocked = False self.is_twin_array = False @@ -55,6 +56,7 @@ def __init__(self) -> None: def load(self, path: str) -> bool: try: + self.file_path = path self._load_file(path) self._decode() return True @@ -118,7 +120,7 @@ def _get_decode_params_py(self) -> tuple[int, int, list[int, int]]: # adjust re-clock map for v in reclock_map: - v[1] = height - v[1] + v[1] = height - v[1] - 1 if not reclock_map: raise ValueError("No encoder clock signal found") @@ -126,11 +128,10 @@ def _get_decode_params_py(self) -> tuple[int, int, list[int, int]]: if height < self.vert_px: raise ValueError("Not support this type") - print(width, height, self.vert_px) return width, height, reclock_map # slow python version. only used for debugging - def _decode_cis_py(self, output_img, end_padding_y, twin_array_vert_sep, reclock_map) -> None: + def _decode_cis_py(self, output_img, twin_array_vert_sep, reclock_map) -> None: class CurColor(IntEnum): BG = auto() ROLL = auto() @@ -141,7 +142,7 @@ class CurColor(IntEnum): lyrics_color = 0 cur_idx = 0 twin_offset_x = self.hol_px - self.twin_array_overlap // 2 - for cur_line in range(self.vert_px + end_padding_y - 1, end_padding_y, -1): + for cur_line in range(self.vert_px - 1, 0, -1): # decode holes last_pos = 0 cur_pix = CurColor.ROLL @@ -194,7 +195,7 @@ class CurColor(IntEnum): if self.is_clocked: # reposition lines for src, dest in reclock_map: - output_img[dest + end_padding_y] = output_img[src + end_padding_y] + output_img[dest] = output_img[src] def _load_file(self, path: str) -> None: # CIS file format @@ -252,17 +253,14 @@ def _decode(self, use_cython=True) -> None: twin_array_vert_sep = math.ceil(self.twin_array_vert_sep * self.vert_res / 1000) # reserve decoded image with padding on start/end - start_padding_y = out_w // 2 - end_padding_y = out_w // 2 - self.decoded_img = np.full((out_h + start_padding_y + end_padding_y, out_w), 120, np.uint8) - self.decoded_img[out_h + end_padding_y:] = 255 + self.decoded_img = np.full((out_h + twin_array_vert_sep, out_w), 120, np.uint8) # decode if use_cython: _decode_cis(self.raw_img, self.decoded_img, self.vert_px, self.hol_px, self.is_bicolor, self.is_twin_array, self.is_clocked, - self.twin_array_overlap, twin_array_vert_sep, end_padding_y, reclock_map) + self.twin_array_overlap, twin_array_vert_sep, reclock_map) else: - self._decode_cis_py(self.decoded_img, end_padding_y, twin_array_vert_sep, reclock_map) + self._decode_cis_py(self.decoded_img, twin_array_vert_sep, reclock_map) if len(self.decoded_img) == 0: raise BufferError @@ -277,6 +275,6 @@ def _decode(self, use_cython=True) -> None: app = wx.App() s = time.time() obj = CisImage() - if obj.load("../sample_Scans/Ampico B 68991 Papillons.CIS"): + if obj.load("../test/test_images/clocked_single.CIS"): print(time.time() - s) - cv2.imwrite("decoded_cis.png", obj.decoded_img) + cv2.imwrite("unknown_scanner_gt.png", obj.decoded_img) diff --git a/src/controls.py b/src/controls.py index c261557..fef88ac 100644 --- a/src/controls.py +++ b/src/controls.py @@ -10,7 +10,7 @@ import wx.adv from config import ConfigMng from version import APP_TITLE, APP_VERSION, COPY_RIGHT -from wx.lib.agw.hyperlink import HyperLinkCtrl +from wx.adv import HyperlinkCtrl class BasePanel(wx.Panel): @@ -59,7 +59,7 @@ def __init__(self, parent, pos=(0, 0), size=(800, 600)): msg2 = wx.StaticText(self, wx.ID_ANY, "Please donate for continuous development of the software.") text_size = parent.get_scaled_textsize(15) msg2.SetFont(wx.Font(text_size, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_SEMIBOLD)) - lnk1 = HyperLinkCtrl(self, wx.ID_ANY, "Donate via PayPal", URL="https://paypal.me/KatzSasaki") + lnk1 = HyperlinkCtrl(self, wx.ID_ANY, "Donate via PayPal", url="https://paypal.me/KatzSasaki") lnk1.SetFont(wx.Font(text_size, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_SEMIBOLD)) text_size = parent.get_scaled_textsize(10) @@ -67,7 +67,7 @@ def __init__(self, parent, pos=(0, 0), size=(800, 600)): msg3.SetFont(wx.Font(text_size, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_SEMIBOLD)) msg4 = wx.StaticText(self, wx.ID_ANY, f"Version {APP_VERSION}") msg4.SetFont(wx.Font(text_size, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_SEMIBOLD)) - lnk2 = HyperLinkCtrl(self, wx.ID_ANY, "Project page on GitHub", URL="https://github.com/nai-kon/PlaySK-Piano-Roll-Reader") + lnk2 = HyperlinkCtrl(self, wx.ID_ANY, "Project page on GitHub", url="https://github.com/nai-kon/PlaySK-Piano-Roll-Reader") lnk2.SetFont(wx.Font(text_size, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_SEMIBOLD)) msg5 = wx.StaticText(self, wx.ID_ANY, COPY_RIGHT) msg5.SetFont(wx.Font(text_size, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_SEMIBOLD)) @@ -83,8 +83,6 @@ def __init__(self, parent, pos=(0, 0), size=(800, 600)): sizer.Add(msg5, 0, wx.ALIGN_CENTER) self.SetSizer(sizer) self.SetBackgroundColour("#555555") - lnk1.SetBackgroundColour("#555555") - lnk2.SetBackgroundColour("#555555") self.Layout() def start_worker(self) -> None: @@ -109,7 +107,7 @@ def on_destroy(self) -> None: class SpeedSlider(BasePanel): - def __init__(self, parent, pos=(0, 0), label="Tempo", tempo_range=(50, 140), val=80, callback=None): + def __init__(self, parent, pos=(0, 0), label="Tempo", tempo_range=(30, 140), val=80, callback=None): BasePanel.__init__(self, parent, wx.ID_ANY, pos) self.callback = callback self.label = label @@ -199,7 +197,7 @@ def __init__(self, parent, version): message = wx.StaticText(panel, label=f"New ver{version} has been released!") message.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) url = "https://github.com/nai-kon/PlaySK-Piano-Roll-Reader/releases/" - lnk = HyperLinkCtrl(panel, label=url, URL=url) + lnk = HyperlinkCtrl(panel, label=url, url=url) ok_button = wx.Button(panel, label="OK") ok_button.Bind(wx.EVT_BUTTON, self.on_ok) @@ -233,6 +231,7 @@ def fetch_latest_version(self) -> str | None: "X-Identifier": "PlaySK", "X-Platform": platform.system(), "X-Version": APP_VERSION, + "X-LastTracker": self.conf.last_tracker, }) with urllib.request.urlopen(req, timeout=10, context=context) as res: title = json.loads(res.read().decode("utf8")).get("name", None) diff --git a/src/input_editor.py b/src/input_editor.py index 7b5336e..096804a 100644 --- a/src/input_editor.py +++ b/src/input_editor.py @@ -1,3 +1,5 @@ +from pathlib import Path + import cv2 import numpy as np import wx @@ -16,7 +18,8 @@ def __init__(self, parent, cis: CisImage): sizer1.Add(wx.StaticText(self, label=self.get_show_text()), 1, wx.EXPAND | wx.ALL, border_size) convert_bw_btn = wx.Button(self, label="Convert Black pixel to White") sizer1.Add(convert_bw_btn, 1, wx.EXPAND | wx.ALL, border_size) - convert_bw_btn.Bind(wx.EVT_BUTTON, self.convert_bw) + save_btn = wx.Button(self, label="Save as PNG image") + sizer1.Add(save_btn, 1, wx.EXPAND | wx.ALL, border_size) sizer1.Add(wx.Button(self, wx.ID_CANCEL, label="Cancel"), 1, wx.EXPAND | wx.ALL, border_size) sizer1.Add(wx.Button(self, wx.ID_OK, label="OK"), 1, wx.EXPAND | wx.ALL, border_size) @@ -26,8 +29,13 @@ def __init__(self, parent, cis: CisImage): self.SetSizer(sizer2) self.Fit() - x, y = self.GetPosition() - self.SetPosition((x, 0)) + x, _ = self.GetPosition() + cur_w, _ = self.GetSize() + set_x = min(x, wx.Display().GetClientArea().width - cur_w - 1) + self.SetPosition((set_x, 0)) + + convert_bw_btn.Bind(wx.EVT_BUTTON, self.convert_bw) + save_btn.Bind(wx.EVT_BUTTON, self.save_img) def convert_bw(self, event): # some cis scan has black background so convert it to white @@ -36,6 +44,18 @@ def convert_bw(self, event): self.panel.set_image(self.cis.decoded_img) self.panel.Refresh() + def save_img(self, event) -> None: + # save decoded CIS as PNG image + with wx.FileDialog(self, "Save as PNG File", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + wildcard="PNG files (*.png)|*.png", + defaultFile=Path(self.cis.file_path).with_suffix(".png").name) as dlg: + if dlg.ShowModal() == wx.ID_OK: + path = dlg.GetPath() + with wx.BusyCursor(): + ret = cv2.imwrite(path, self.cis.decoded_img) + if not ret: + wx.MessageBox("Failed to save the image.", "Error") + def get_show_text(self): # get roll info text out = [f"Type: {self.cis.scanner_type.value}"] diff --git a/src/main_ui.py b/src/main_ui.py index 55e762a..8408f4d 100644 --- a/src/main_ui.py +++ b/src/main_ui.py @@ -14,11 +14,13 @@ WelcomeMsg, ) from midi_controller import MidiWrap +from organ_stop_indicator import OrganStopIndicator from player_mng import PlayerMng -from players import BasePlayer from roll_scroll import InputScanImg, load_scan +from tracker_bars import BasePlayer from vacuum_gauge import VacuumGauge from version import APP_TITLE +from wx.adv import HyperlinkCtrl class CallBack: @@ -59,28 +61,37 @@ def __init__(self): if platform.system() == "Windows": # wxpython on Windows does not support Darkmode self.SetBackgroundColour("#AAAAAA") - self.conf = ConfigMng() self.img_path = None + + # Initial Welcome Message self.spool = WelcomeMsg(self, size=(800, 600)) self.spool.start_worker() self.supported_imgs = (".cis", ".jpg", ".png", ".tif", ".bmp") + # Midi on/off button self.midi_btn = BaseButton(self, size=self.get_dipscaled_size(wx.Size((90, 50))), label="MIDI On") self.midi_btn.Bind(wx.EVT_BUTTON, self.midi_onoff) self.midi_btn.Disable() - + # File Open button self.file_btn = BaseButton(self, size=self.get_dipscaled_size(wx.Size((90, 50))), label="File") self.file_btn.Bind(wx.EVT_BUTTON, self.open_file) - + # Tempo slider self.speed = SpeedSlider(self, callback=self.speed_change) + # Auto Tracking on/off button self.tracking = TrackerCtrl(self) - + # Manual Expression on/off button self.manual_expression = BaseCheckbox(self, wx.ID_ANY, "Manual Expression") + # Vacuum Graph self.bass_vacuum_lv = VacuumGauge(self, caption="Bass Vacuum (inches of water)") self.treble_vacuum_lv = VacuumGauge(self, caption="Treble Vacuum (inches of water)") + # Organ Stop Indicator for Aeolian 176-note + self.organ_stop_indicator = OrganStopIndicator(self) + # Link for Aeolian 165-note Midi assignment document + self.organ_midi_map = HyperlinkCtrl(self, wx.ID_ANY, "MIDI Output Assignment Map", "https://playsk-aeolian176note-midi-assignment.pages.dev/", style=wx.adv.HL_ALIGN_LEFT) - self.adjust_btn = BaseButton(self, size=self.get_dipscaled_size(wx.Size((180, 40))), label="Adjust CIS Image") + # CIS Adjust button + self.adjust_btn = BaseButton(self, size=self.get_dipscaled_size(wx.Size((180, 35))), label="Adjust CIS Image") self.adjust_btn.Bind(wx.EVT_BUTTON, self.adjust_image) self.callback = CallBack(None, self.tracking, self.bass_vacuum_lv, self.treble_vacuum_lv) @@ -100,6 +111,8 @@ def __init__(self): self.sizer2.Add(self.manual_expression, flag=wx.EXPAND | wx.ALL, border=border_size) self.sizer2.Add(self.bass_vacuum_lv, flag=wx.EXPAND | wx.ALL, border=border_size) self.sizer2.Add(self.treble_vacuum_lv, flag=wx.EXPAND | wx.ALL, border=border_size) + self.sizer2.Add(self.organ_stop_indicator, flag=wx.EXPAND | wx.ALL, border=border_size) + self.sizer2.Add(self.organ_midi_map, flag=wx.EXPAND | wx.ALL, border=border_size) self.sizer2.Add(self.adjust_btn, flag=wx.EXPAND | wx.ALL, border=border_size) self.sizer3 = wx.BoxSizer(wx.HORIZONTAL) @@ -115,6 +128,7 @@ def __init__(self): self.SetDropTarget(self.droptarget) self.adjust_btn.Hide() + self.Bind(wx.EVT_SIZE, self.on_resize) self.Bind(wx.EVT_CLOSE, self.on_close) self.Show() @@ -129,16 +143,16 @@ def __init__(self): self.Bind(wx.EVT_KEY_UP, self.on_keyup) self.manual_expression.Bind(wx.EVT_CHECKBOX, self.on_check_manual_expression) - def get_dipscaled_size(self, size:wx.Size | int): + def get_dipscaled_size(self, size:wx.Size | int) -> int | wx.Size: if isinstance(size, int): return int(self.FromDIP(size) * self.conf.window_scale_ratio) else: return self.FromDIP(size) * self.conf.window_scale_ratio - def get_dpiscale_factor(self): + def get_dpiscale_factor(self) -> float: return self.GetDPIScaleFactor() * self.conf.window_scale_ratio if platform.system() == "Windows" else self.conf.window_scale_ratio - def get_scaled_textsize(self, size: int): + def get_scaled_textsize(self, size: int) -> int: return int(size * self.conf.window_scale_ratio) def create_status_bar(self): @@ -196,6 +210,20 @@ def create_status_bar(self): def post_status_msg(self, msg: str): wx.CallAfter(self.sbar.SetStatusText, text=msg, i=6) + def on_resize(self, event): + # selection of status bar needs manually re-position + # midi port sel + rect = self.sbar.GetFieldRect(1) + self.port_sel.SetPosition((rect.x, 0)) + # tracker bar sel + rect = self.sbar.GetFieldRect(4) + self.player_sel.SetPosition((rect.x, 0)) + # windows scale sel + rect = self.sbar.GetFieldRect(8) + self.scale_sel.SetPosition((rect.x, 0)) + + event.Skip() + def on_close(self, event): print("on_close called") self.conf.save_config() @@ -206,11 +234,17 @@ def on_close(self, event): self.Destroy() def on_keydown(self, event): + """ + manual expression key pressing + """ keycode = event.GetUnicodeKey() self.spool.set_pressed_key(keycode, True) self.callback.key_event(keycode, True) def on_keyup(self, event): + """ + manual expression key pressing + """ keycode = event.GetUnicodeKey() self.spool.set_pressed_key(keycode, False) self.callback.key_event(keycode, False) @@ -230,8 +264,7 @@ def change_player(self, event=None): idx = self.player_sel.GetSelection() name = self.player_sel.GetString(idx) self.conf.last_tracker = name - player_tmp = self.player_mng.get_player_obj(name, self.midiobj) - if player_tmp is not None: + if (player_tmp:= self.player_mng.get_player_obj(name, self.midiobj)) is not None: self.midiobj.all_off() self.midi_btn.SetLabel("MIDI On") player_tmp.tracker_offset = self.tracking.offset @@ -239,6 +272,24 @@ def change_player(self, event=None): self.callback.player = player_tmp self.callback.player.manual_expression = self.manual_expression.IsChecked() + if name == "Aeolian 176-note Pipe Organ": + self.manual_expression.SetValue(False) + self.spool.set_manual_expression(False) + self.callback.player.manual_expression = False + self.callback.player.init_stop_indicator(self.organ_stop_indicator) + self.manual_expression.Hide() + self.bass_vacuum_lv.Hide() + self.treble_vacuum_lv.Hide() + self.organ_stop_indicator.Show() + self.organ_midi_map.Show() + else: + self.manual_expression.Show() + self.bass_vacuum_lv.Show() + self.treble_vacuum_lv.Show() + self.organ_stop_indicator.Hide() + self.organ_midi_map.Hide() + self.Fit() + def change_scale(self, event=None): idx = self.scale_sel.GetSelection() select_scale = self.scale_sel.GetString(idx) @@ -260,8 +311,7 @@ def midi_off(self): self.midi_btn.SetLabel("MIDI On") def load_file(self, path: str, force_manual_adjust: bool=False): - ext = Path(path).suffix.lower() - if ext.lower() not in self.supported_imgs: + if Path(path).suffix.lower() not in self.supported_imgs: wx.MessageBox(f"Supported image formats are {' '.join(self.supported_imgs)}", "Unsupported file") return @@ -269,10 +319,9 @@ def load_file(self, path: str, force_manual_adjust: bool=False): if img is None: return self.img_path = path - if self.img_path.lower().endswith(".cis"): - self.adjust_btn.Show() - else: - self.adjust_btn.Hide() + self.adjust_btn.Show() if self.img_path.lower().endswith(".cis") else self.adjust_btn.Hide() + self.Fit() + self.callback.player.emulate_off() tmp = self.spool self.spool = InputScanImg(self, img, self.callback.player.spool_diameter, self.callback.player.roll_width, window_scale=self.conf.window_scale, callback=self.callback) @@ -287,7 +336,7 @@ def load_file(self, path: str, force_manual_adjust: bool=False): self.midi_btn.Enable() # Set tempo - self.speed.set("Tempo", (50, 140), tempo) + self.speed.set("Tempo", (30, 140), tempo) def open_file(self, event): tmp = ";".join(f"*{v}" for v in self.supported_imgs) diff --git a/src/midi_controller.py b/src/midi_controller.py index 328aad7..e7be796 100644 --- a/src/midi_controller.py +++ b/src/midi_controller.py @@ -40,19 +40,21 @@ def all_off(self) -> None: self.sustain_off() self.soft_off() self.hammer_lift_off() - [self.note_off(k) for k in range(128)] + [self.note_off(k, channel=0) for k in range(128)] + [self.note_off(k, channel=1) for k in range(128)] + [self.note_off(k, channel=2) for k in range(128)] self.output.reset() - def note_on(self, note: int, velocity: int) -> None: + def note_on(self, note: int, velocity: int, channel: int = 0) -> None: if self.enable: if self.hammer_lift: velocity -= 8 - self.output.send(Message("note_on", note=note, velocity=velocity)) + self.output.send(Message("note_on", note=note, velocity=velocity, channel=channel)) - def note_off(self, note: int, velocity: int = 90) -> None: + def note_off(self, note: int, velocity: int = 90, channel: int = 0) -> None: if self.enable: - self.output.send(Message("note_off", note=note, velocity=velocity)) + self.output.send(Message("note_off", note=note, velocity=velocity, channel=channel)) def sustain_on(self) -> None: if self.enable: @@ -76,6 +78,14 @@ def hammer_lift_on(self) -> None: def hammer_lift_off(self) -> None: self.hammer_lift = False + def control_change(self, number: int, value: int, channel: int = 0) -> None: + if self.enable: + self.output.send(Message("control_change", control=number, value=value, channel=channel)) + + def program_change(self, program_no: int, channel: int = 0) -> None: + if self.enable: + self.output.send(Message("program_change", program=program_no, channel=channel)) + if __name__ == "__main__": import time diff --git a/src/organ_stop_indicator.py b/src/organ_stop_indicator.py new file mode 100644 index 0000000..6183914 --- /dev/null +++ b/src/organ_stop_indicator.py @@ -0,0 +1,146 @@ +import math +import platform + +import wx +import wx.grid +from controls import BasePanel + + +class OrganStopIndicator(BasePanel): + def __init__(self, parent) -> None: + BasePanel.__init__(self, parent, wx.ID_ANY) + self.data = {} + self.grid = wx.grid.Grid(self) + self.grid.EnableGridLines(False) + # disable edit + self.grid.EnableEditing(False) + # disable header + self.grid.SetColLabelSize(0) + self.grid.SetRowLabelSize(0) + self.grid.SetCellHighlightPenWidth(0) + + # disable size change + self.grid.EnableDragColSize(False) + self.grid.EnableDragRowSize(False) + self.grid.EnableDragGridSize(False) + + self.cell_color_off = "#303030" + self.cell_color_on = "#f6b26b" + self.text_color_off = "white" + self.text_color_on = "black" + self.grid.SetDefaultCellBackgroundColour(self.cell_color_off) + + def init_stop(self, data: dict[str, dict[str, bool]]) -> None: + if self.grid.GetNumberRows() > 0: + # already created + self.change_stop(data) + return + + # calc each cell position + cols = 3 + cur_row = 0 + for part, stops in data.items(): + self.data[part] = {} + cur_row += 1 + rows = math.ceil(len(stops) / cols) + for i, stop in enumerate(stops): + row = i % rows + cur_row + col = i // rows + self.data[part][stop] = {"col": col, "row": row} + + cur_row += rows + + # create grid + self.grid.CreateGrid(cur_row, cols) + + # set header color + if platform.system() == "Windows": + header_bg_color = "#AAAAAA" + elif wx.SystemSettings.GetAppearance().IsDark(): + header_bg_color = "#362927" + else: + header_bg_color = "#F3ECEB" + + # set cells + cur_row = 0 + for part in data: + # header + self.grid.SetCellValue(cur_row, 0, part) + self.grid.SetCellBackgroundColour(cur_row, 0, header_bg_color) + self.grid.SetCellAlignment(cur_row, 0, wx.ALIGN_LEFT, wx.ALIGN_BOTTOM) + self.grid.SetCellSize(cur_row, 0, 1, 3) + + # cells + max_row = 0 + for stop, pos in self.data[part].items(): + row, col = pos["row"], pos["col"] + self.grid.SetCellValue(row, col, stop) + self.grid.SetCellAlignment(row, col, wx.ALIGN_CENTER, wx.ALIGN_CENTER) + max_row = max(row, max_row) + cur_row = max_row + 1 + + self.change_stop(data) + self.grid.AutoSize() + self.Fit() + + def _change_stop_inner(self, data: dict[str, dict[str, bool]]) -> None: + # change stop indicator on/off + for part, stops in data.items(): + for label, is_on in stops.items(): + pos = self.data[part].get(label, None) + if pos is None: + continue + + row, col = pos["row"], pos["col"] + if is_on: + self.grid.SetCellBackgroundColour(row, col, self.cell_color_on) + self.grid.SetCellTextColour(row, col, self.text_color_on) + else: + self.grid.SetCellBackgroundColour(row, col, self.cell_color_off) + self.grid.SetCellTextColour(row, col, self.text_color_off) + + self.Refresh() + + def change_stop(self, data: dict[str, dict[str, bool]]) -> None: + wx.CallAfter(self._change_stop_inner, data) + + +if __name__ == "__main__": + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(True) + app = wx.App() + frame = wx.Frame(None, wx.ID_ANY, size=(1000, 1000)) + panel1 = OrganStopIndicator(frame) + + data = { + "Swell":{ + "Chimes": False, + "Flute4": True, + "Tremolo": False, + "FluteP": True, + "Harp": False, + "String Vibrato f": False, + "Trumpet": False, + "String f": True, + "Oboe": False, + "String mf": True, + "Vox Humana": False, + "String p": False, + "Diapason mf": True, + "String pp": True, + "Flute16": False, + "Soft chimes": False, + }, + "Great":{ + "Chimes": False, + "Flute4": True, + "Tremolo": False, + "FluteP": True, + "Harp": False, + }, + } + panel1.init_stop(data) + + frame.Fit() + frame.Show() + app.MainLoop() diff --git a/src/player_mng.py b/src/player_mng.py index 84370d5..c9a7e61 100644 --- a/src/player_mng.py +++ b/src/player_mng.py @@ -2,7 +2,7 @@ import json import os -import players +import tracker_bars from midi_controller import MidiWrap @@ -17,8 +17,7 @@ def init_player_map(self) -> dict[str, str]: with open(path, encoding="utf-8") as f: conf = json.load(f) - cls_name = conf.get("base_class", None) - if cls_name is not None: + if (cls_name := conf.get("base_class", None)) is not None: fname = os.path.basename(path).replace(".json", "") conf_map[fname] = cls_name @@ -28,24 +27,24 @@ def init_player_map(self) -> dict[str, str]: def player_list(self) -> list[str]: return sorted(self.player_conf_map.keys()) - def get_player_obj(self, player_name: str, midiobj: MidiWrap) -> None | players.BasePlayer: + def get_player_obj(self, player_name: str, midiobj: MidiWrap) -> None | tracker_bars.BasePlayer: cls_name = self.player_conf_map.get(player_name, None) cls_map = { - "Player": players.BasePlayer, - "AmpicoB": players.AmpicoB, - "Duo-Art": players.DuoArt, - "WelteT100": players.WelteT100, - "WelteT98": players.WelteT98, - "WelteLicensee": players.WelteLicensee, - "PhillipsDuca": players.PhilippsDuca, - "RecordoA": players.RecordoA, - "RecordoB": players.RecordoB, - "Artecho": players.Artecho, - "Themodist": players.Themodist, - "Themodist_eValve": players.Themodist_eValve, + "Player": tracker_bars.BasePlayer, + "AmpicoB": tracker_bars.AmpicoB, + "Duo-Art": tracker_bars.DuoArt, + "WelteT100": tracker_bars.WelteT100, + "WelteT98": tracker_bars.WelteT98, + "WelteLicensee": tracker_bars.WelteLicensee, + "PhillipsDuca": tracker_bars.PhilippsDuca, + "RecordoA": tracker_bars.RecordoA, + "RecordoB": tracker_bars.RecordoB, + "Artecho": tracker_bars.Artecho, + "Themodist": tracker_bars.Themodist, + "Themodist_eValve": tracker_bars.Themodist_eValve, + "Aeolian176note": tracker_bars.Aeolian176note, } - clsobj = cls_map.get(cls_name, None) - if clsobj is not None: + if (clsobj := cls_map.get(cls_name, None)) is not None: confpath = os.path.join(self.conf_dir, f"{player_name}.json") return clsobj(confpath, midiobj) else: @@ -57,5 +56,5 @@ def get_player_obj(self, player_name: str, midiobj: MidiWrap) -> None | players. print(obj.player_list) print(type(obj.get_player_obj("88 Note white back", None))) assert obj.get_player_obj("not exists", None) is None - assert type(obj.get_player_obj("88 Note white back", None)) is players.Player - assert type(obj.get_player_obj("Ampico B white back", None)) is players.AmpicoB + assert type(obj.get_player_obj("88 Note white back", None)) is tracker_bars.Player + assert type(obj.get_player_obj("Ampico B white back", None)) is tracker_bars.AmpicoB diff --git a/src/playsk_config/Aeolian 176-note Pipe Organ.json b/src/playsk_config/Aeolian 176-note Pipe Organ.json new file mode 100644 index 0000000..48b6273 --- /dev/null +++ b/src/playsk_config/Aeolian 176-note Pipe Organ.json @@ -0,0 +1,239 @@ +{ + "note": "Aeolian 176-note Duo-Art Pipe Organ setting", + "base_class": "Aeolian176note", + "roll_width": 15.25, + "spool_diameter": 2.0, + "default_tempo": 40, + "expression": { + "vacuum": 6, + "stack_split_point": 43, + "expression_shade":{ + "note": "midi expression(ctrl 11) value in each shade position. 0 min (no sound), 127 max.", + "shade0": 30, + "shade1": 43, + "shade2": 58, + "shade3": 74, + "shade4": 91, + "shade5": 108, + "shade6": 127, + "min_to_max_second": 0.5 + } + }, + "tracker_holes": { + "is_dark_hole": false, + "on_brightness": 220, + "lowest_note": 15, + "swell_note": { + "x": [ + 164, + 172, + 181, + 189, + 198, + 207, + 215, + 224, + 232, + 241, + 250, + 258, + 267, + 275, + 284, + 293, + 301, + 310, + 318, + 327, + 336, + 344, + 353, + 361, + 370, + 378, + 387, + 396, + 404, + 413, + 421, + 430, + 439, + 447, + 456, + 464, + 473, + 482, + 490, + 499, + 507, + 516, + 525, + 533, + 542, + 550, + 559, + 567, + 576, + 585, + 593, + 602, + 610, + 619, + 628, + 636, + 645, + 653 + ], + "y": 291, + "w": 4, + "h": 4, + "on_apature": 0.1, + "off_apature": 0.05 + }, + "great_note": { + "x": [ + 159, + 168, + 177, + 185, + 194, + 202, + 211, + 220, + 228, + 237, + 245, + 254, + 263, + 271, + 280, + 288, + 297, + 305, + 314, + 323, + 331, + 340, + 348, + 357, + 366, + 374, + 383, + 391, + 400, + 409, + 417, + 426, + 434, + 443, + 452, + 460, + 469, + 477, + 486, + 494, + 503, + 512, + 520, + 529, + 537, + 546, + 555, + 563, + 572, + 580, + 589, + 598, + 606, + 615, + 623, + 632, + 640, + 649 + ], + "y": 305, + "w": 4, + "h": 4, + "on_apature": 0.1, + "off_apature": 0.05 + }, + "swell_controls": { + "x":[ + 26, + 35, + 43, + 52, + 61, + 69, + 78, + 86, + 95, + 104, + 112, + 121, + 129, + 138, + 147, + 155, + 662, + 671, + 679, + 688, + 696, + 705, + 714, + 722, + 731, + 739, + 748, + 756, + 765, + 774 + ], + "y": 291, + "w": 4, + "h": 4, + "on_apature": 0.15, + "off_apature": 0.05 + }, + "great_controls": { + "x":[ + 22, + 31, + 39, + 48, + 56, + 65, + 74, + 82, + 91, + 99, + 108, + 116, + 125, + 134, + 142, + 151, + 658, + 666, + 675, + 683, + 692, + 701, + 709, + 718, + 726, + 735, + 744, + 752, + 761, + 769 + ], + "y": 305, + "w": 4, + "h": 4, + "on_apature": 0.15, + "off_apature": 0.05 + } + } +} \ No newline at end of file diff --git a/src/playsk_config/config.json b/src/playsk_config/config.json index a189f98..0a3923f 100644 --- a/src/playsk_config/config.json +++ b/src/playsk_config/config.json @@ -1,6 +1,6 @@ { "last_midi_port": "Microsoft GS Wavetable Synth 0", "last_tracker": "Ampico B white back", - "update_notified_version": "3.5.2", + "update_notified_version": "3.6.0", "window_scale": "100%" } \ No newline at end of file diff --git a/src/roll_scroll.py b/src/roll_scroll.py index 26de48f..12bd3bf 100644 --- a/src/roll_scroll.py +++ b/src/roll_scroll.py @@ -2,7 +2,6 @@ import os os.environ["OPENCV_IO_MAX_IMAGE_PIXELS"] = pow(2, 42).__str__() - import re import threading import time @@ -55,7 +54,8 @@ def _load_img(path: str, default_tempo: int) -> tuple[np.ndarray | None, int]: n = np.fromfile(path, np.uint8) img = cv2.imdecode(n, cv2.IMREAD_COLOR) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - except Exception: + except Exception as e: + print(e) return None, default_tempo # search tempo from ANN file @@ -263,8 +263,7 @@ def draw_manual_expression(self, dc: wx.PaintDC) -> None: # cache for txt in ("Accent", "Intensity", "Bass", "Treble", "Lv1", "Lv2", "Lv4", "A", "S", "J", "K", "L"): - key = f"{txt}_txt_size" - if key not in self.draw_cache: + if (key := f"{txt}_txt_size") not in self.draw_cache: self.draw_cache[key] = dc.GetTextExtent(txt) txt = "Accent" @@ -430,10 +429,18 @@ def start_worker(self) -> None: resize_ratio = self.disp_w / self.src.shape[1] resize_h = int(self.src.shape[0] * resize_ratio) self.src = cv2.resize(self.src, dsize=(self.disp_w, resize_h)) + if True: + # add padding on start/end + pad_shape = [self.disp_h // 2 - 20, self.disp_w] + if self.src.ndim == 3: + pad_shape += [3] + padding = np.full(pad_shape, 255, self.src.dtype) + self.src = np.concatenate([padding, self.src, padding]) + if self.src.ndim == 2: self.src = cv2.cvtColor(self.src, cv2.COLOR_GRAY2RGB) - self.cur_y = self.src.shape[0] - 1 + self.cur_y = self.src.shape[0] self.crop_h = self.disp_h self.roll_dpi = resize_ratio * (self.right_side - self.left_side + 1) / self.roll_width self.set_tempo(self.tempo) diff --git a/src/tracker_bars/Aeolian176note.py b/src/tracker_bars/Aeolian176note.py new file mode 100644 index 0000000..8670d69 --- /dev/null +++ b/src/tracker_bars/Aeolian176note.py @@ -0,0 +1,333 @@ +import json + +import numpy as np +from midi_controller import MidiWrap +from organ_stop_indicator import OrganStopIndicator + +from .base_player import BasePlayer + + +class Aeolian176note(BasePlayer): + def __init__(self, confpath: str, midiobj: MidiWrap) -> None: + super().__init__(confpath, midiobj) + + with open(confpath, encoding="utf-8") as f: + conf = json.load(f) + + self.shade = conf["expression"]["expression_shade"] + self.pre_time = None + self.prevent_chattering_wait = 0.2 # toggle switch reaction threshold seconds to prevent chattering + self.stop_indicator = None + + # set church organ for GM sound + for ch in range(3): + self.midi.program_change(19, ch) + + self.init_controls() + + def init_stop_indicator(self, stop_indicator: OrganStopIndicator) -> None: + self.stop_indicator = stop_indicator + + stops = { + "Swell Stops": {k: v["is_on"] for k, v in self.ctrls["swell"].items() if "Pedal" not in k and "Shade" not in k}, + "Great Stops": {k: v["is_on"] for k, v in self.ctrls["great"].items() if "Pedal" not in k and "Shade" not in k}, + "Pedal Stops": {k.lstrip("Pedal "): v["is_on"] for part in self.ctrls for k, v in self.ctrls[part].items() if "Pedal" in k}, + } + self.stop_indicator.init_stop(stops) + + def init_controls(self) -> None: + """ + Aeolian Duo-Art Pipe Organ tracker bar + https://www.mmdigest.com/Gallery/Tech/Scales/Aeo176.html + + The expression shades are assigned to control change event. + Swell shade: ch=4(0x03) cc=14. Great shade: ch=4(0x03) cc=15. MIDI value 30(fully closed) to 127(fully open). + + The control holes are assigned to control change event. + Channel is 4(0x03) and control ON is cc=20, OFF is cc=110. + Each control holes are assigned to each MIDI values. + This corresponds to Hauptwerk Virtual Organ settings of "Notation stop or hold-piston; CC20=on, CC110=off". + """ + self.shade_change_rate = (self.shade["shade6"] - self.shade["shade0"]) / self.shade["min_to_max_second"] + self.swell_shade_val = self.shade["shade6"] + self.great_shade_val = self.shade["shade6"] + self.shade_error_detector = {"swell": [], "great": []} + + self.midi.control_change(14, self.swell_shade_val, 3) + self.midi.control_change(15, self.great_shade_val, 3) + + self.ctrls = { + # upper control holes of tracker bar + "swell":{ + "Echo" : {"hole_no": 0, "is_on": False, "last_time": 0, "midi_val": 0}, # Currently not implemented + "Chime" : {"hole_no": 1, "is_on": False, "last_time": 0, "midi_val": 1}, + "Tremolo" : {"hole_no": 2, "is_on": False, "last_time": 0, "midi_val": 2}, + "Harp" : {"hole_no": 3, "is_on": False, "last_time": 0, "midi_val": 3}, + "Trumpet" : {"hole_no": 4, "is_on": False, "last_time": 0, "midi_val": 4}, + "Oboe" : {"hole_no": 5, "is_on": False, "last_time": 0, "midi_val": 5}, + "Vox Humana" : {"hole_no": 6, "is_on": False, "last_time": 0, "midi_val": 6}, + "Diapason mf" : {"hole_no": 7, "is_on": False, "last_time": 0, "midi_val": 7}, + "Flute 16" : {"hole_no": 8, "is_on": False, "last_time": 0, "midi_val": 8}, + "Flute 4" : {"hole_no": 9, "is_on": False, "last_time": 0, "midi_val": 9}, + "Flute p" : {"hole_no": 10, "is_on": False, "last_time": 0, "midi_val": 10}, + "String Vibrato f" : {"hole_no": 11, "is_on": False, "last_time": 0, "midi_val": 11}, + "String f" : {"hole_no": 12, "is_on": False, "last_time": 0, "midi_val": 12}, + "String mf" : {"hole_no": 13, "is_on": False, "last_time": 0, "midi_val": 13}, + "String p" : {"hole_no": 14, "is_on": False, "last_time": 0, "midi_val": 14}, + "String pp" : {"hole_no": 15, "is_on": False, "last_time": 0, "midi_val": 15}, + "Shade1" : {"hole_no": 16, "is_on": True}, + "Shade2" : {"hole_no": 17, "is_on": True}, + "Shade3" : {"hole_no": 18, "is_on": True}, + "Shade4" : {"hole_no": 19, "is_on": True}, + "Shade5" : {"hole_no": 20, "is_on": True}, + "Shade6" : {"hole_no": 21, "is_on": True}, + # "Extension" : {"hole_no": 22, "is_on": False}, + # "LCW1" : {"hole_no": 23, "is_on": False, "last_time": 0, "midi_val": }, + # "LCW2" : {"hole_no": 24, "is_on": False, "last_time": 0, "midi_val": }, + "Soft Chime" : {"hole_no": 25, "is_on": False, "last_time": 0, "midi_val": 17}, + # "Reroll" : {"hole_no": , "is_on": False, "last_time": 0, "midi_val": }, + # "Ventil" : {"hole_no": , "is_on": False, "last_time": 0, "midi_val": }, + # "Normal" : {"hole_no": , "is_on": False, "last_time": 0, "midi_val": }, + "Pedal to Swell" : {"hole_no": 29, "is_on": False}, + }, + "great":{ + # lower control holes of tracker bar + "Tremolo" : {"hole_no": 0, "is_on": False, "last_time": 0, "midi_val": 18}, + "Tonal" : {"hole_no": 1, "is_on": False, "last_time": 0, "midi_val": 19}, # Currently not implemented + "Harp" : {"hole_no": 2, "is_on": False, "last_time": 0, "midi_val": 20}, + # "Extension" : {"hole_no": 3, "is_on": False}, + # "Pedal 2nd octave" : {"hole_no": 4, "is_on": False}, + # "Pedal 3rd octave" : {"hole_no": 5, "is_on": False}, + "Shade1" : {"hole_no": 6, "is_on": True}, + "Shade2" : {"hole_no": 7, "is_on": True}, + "Shade3" : {"hole_no": 8, "is_on": True}, + "Shade4" : {"hole_no": 9, "is_on": True}, + "Shade5" : {"hole_no": 10, "is_on": True}, + "Shade6" : {"hole_no": 11, "is_on": True}, + "Pedal Bassoon 16" : {"hole_no": 12, "is_on": False, "last_time": 0, "midi_val": 24}, + "Pedal String 16" : {"hole_no": 13, "is_on": False, "last_time": 0, "midi_val": 25}, + "Pedal Flute f16" : {"hole_no": 14, "is_on": False, "last_time": 0, "midi_val": 26}, + "Pedal Flute p16" : {"hole_no": 15, "is_on": False, "last_time": 0, "midi_val": 27}, + "String pp" : {"hole_no": 16, "is_on": False, "last_time": 0, "midi_val": 28}, + "String p" : {"hole_no": 17, "is_on": False, "last_time": 0, "midi_val": 29}, + "String f" : {"hole_no": 18, "is_on": False, "last_time": 0, "midi_val": 30}, + "Flute p" : {"hole_no": 19, "is_on": False, "last_time": 0, "midi_val": 31}, + "Flute f" : {"hole_no": 20, "is_on": False, "last_time": 0, "midi_val": 32}, + "Flute 4" : {"hole_no": 21, "is_on": False, "last_time": 0, "midi_val": 33}, + "Diapason f" : {"hole_no": 22, "is_on": False, "last_time": 0, "midi_val": 34}, + "Piccolo" : {"hole_no": 23, "is_on": False, "last_time": 0, "midi_val": 35}, + "Clarinet" : {"hole_no": 24, "is_on": False, "last_time": 0, "midi_val": 36}, + "Trumpet" : {"hole_no": 25, "is_on": False, "last_time": 0, "midi_val": 37}, + "Chime Damper" : {"hole_no": 26, "is_on": False, "last_time": 0, "midi_val": 38}, + # "LCW3" : {"hole_no": , "is_on": False, "last_time": 0, "midi_val": }, + # "LCW4" : {"hole_no": , "is_on": False, "last_time": 0, "midi_val": }, + # "Ventil" : {"hole_no": , "is_on": False, "last_time": 0, "midi_val": } + }, + } + + [self.midi.control_change(110, v, 3) for v in range(128)] # all off + self.pedal_all_off = True + + if self.stop_indicator is not None: + stops = { + "Swell Stops": {k: v["is_on"] for k, v in self.ctrls["swell"].items() if "Pedal" not in k and "Shade" not in k}, + "Great Stops": {k: v["is_on"] for k, v in self.ctrls["great"].items() if "Pedal" not in k and "Shade" not in k}, + "Pedal Stops": {k.lstrip("Pedal "): v["is_on"] for part in self.ctrls for k, v in self.ctrls[part].items() if "Pedal" in k}, + } + self.stop_indicator.change_stop(stops) + + def emulate_off(self) -> None: + super().emulate_off() + self.init_controls() + + def emulate_notes(self) -> None: + offset = 36 # 15 + 21 + velocity = 64 + + # notes + for part, extension_port, midi_ch in zip(("swell", "great"), (22, 3), (0, 1)): + note = self.holes[f"{part}_note"] + notes_on = [key + offset for key in note["to_open"].nonzero()[0]] + notes_off = [key + offset for key in note["to_close"].nonzero()[0]] + + # extension + if self.holes[f"{part}_controls"]["to_open"][extension_port]: + # already ON notes when extension begin + notes_on.extend([key + offset + 12 for key in note["is_open"].nonzero()[0] if key in (46, 47, 48)]) + elif self.holes[f"{part}_controls"]["is_open"][extension_port]: + notes_on.extend([key + offset + 12 for key in note["to_open"].nonzero()[0] if key in (46, 47, 48)]) + notes_off.extend([key + offset + 12 for key in note["to_close"].nonzero()[0] if key in (46, 47, 48)]) + elif self.holes[f"{part}_controls"]["to_close"][extension_port]: + notes_off.extend([key + offset for key in range(58, 61)]) + + # send note midi signal + [self.midi.note_on(note, velocity, midi_ch) for note in notes_on] + [self.midi.note_off(note, channel=midi_ch) for note in notes_off] + + # pedal notes + if self.ctrls["great"]["Pedal Bassoon 16"]["is_on"] or \ + self.ctrls["great"]["Pedal Flute f16"]["is_on"] or \ + self.ctrls["great"]["Pedal Flute p16"]["is_on"] or \ + self.ctrls["great"]["Pedal String 16"]["is_on"]: + + self.pedal_all_off = False + note = self.holes["great_note"] + if self.ctrls["swell"]["Pedal to Swell"]["is_on"]: + note = self.holes["swell_note"] + + # pedal notes ON + pedal_notes_on = [key + offset for key in note["to_open"].nonzero()[0] if key < 13] + pedal_notes_off = [key + offset for key in note["to_close"].nonzero()[0] if key < 13] + + # pedal 2nd octave notes + great_controls = self.holes["great_controls"] + if great_controls["to_open"][4] or great_controls["to_open"][5]: + # already ON notes when extension begin + pedal_notes_on.extend([key + offset + 12 for key in note["is_open"].nonzero()[0] if key < 13]) + elif great_controls["is_open"][4] or great_controls["is_open"][5]: + pedal_notes_on.extend([key + offset + 12 for key in note["to_open"].nonzero()[0] if key < 13]) + pedal_notes_off.extend([key + offset + 12 for key in note["to_close"].nonzero()[0] if key < 13]) + elif great_controls["to_close"][4] or great_controls["to_close"][5]: + pedal_notes_off.extend([key + offset for key in range(12, 25)]) + + # pedal 3rd octave notes + if great_controls["to_open"][5]: + # already ON notes when extension begin + pedal_notes_on.extend([key + offset + 24 for key in note["is_open"].nonzero()[0] if key < 8]) + elif great_controls["is_open"][5]: + pedal_notes_on.extend([key + offset + 24 for key in note["to_open"].nonzero()[0] if key < 8]) + pedal_notes_off.extend([key + offset + 24 for key in note["to_close"].nonzero()[0] if key < 8]) + elif great_controls["to_close"][5]: + pedal_notes_off.extend([key + offset for key in range(24, 32)]) + + # send MIDI signal + [self.midi.note_on(note, velocity, channel=2) for note in pedal_notes_on] + [self.midi.note_off(note, channel=2) for note in pedal_notes_off] + + elif not self.pedal_all_off: + # all off 32 notes + [self.midi.note_off(k + offset, channel=2) for k in range(0, 32)] + self.pedal_all_off = True + + def emulate_controls(self, curtime: float) -> None: + if self.pre_time is None: + self.pre_time = curtime + delta_time = curtime - self.pre_time + + # check toggle switch holes + for part, ctrls in self.ctrls.items(): + for key, val in ctrls.items(): + controls = self.holes[f"{part}_controls"] + hole_no = val["hole_no"] + if not controls["to_open"][hole_no]: + continue + + if "last_time" in val: + if curtime - val["last_time"] < self.prevent_chattering_wait: + # skip to prevent toggle switch chattering + continue + else: + val["last_time"] = curtime + + # toggle switch function + val["is_on"] = not val["is_on"] + + # update stop panel + if self.stop_indicator is not None and "Shade" not in key: + part_name = "Swell Stops" if part == "swell" else "Great Stops" + part_name = "Pedal Stops" if "Pedal" in key else part_name + self.stop_indicator.change_stop({part_name: {key.lstrip("Pedal "): val["is_on"]}}) + + if (note_no := val.get("midi_val")) is not None: + cc_no = 20 if val["is_on"] else 110 + self.midi.control_change(cc_no, note_no, channel=3) + + if key == "Pedal to Swell": + # reset all pedal notes + [self.midi.note_off(k, channel=2) for k in range(128)] + + # fix shade error + self.fix_shade_error() + + # swell expression shade position + target_val = self.shade["shade0"] + for no in range(1, 6 + 1): + if self.ctrls["swell"][f"Shade{no}"]["is_on"]: + target_val = self.shade[f"shade{no}"] + + if target_val > self.swell_shade_val: + self.swell_shade_val += delta_time * self.shade_change_rate + self.swell_shade_val = min(self.swell_shade_val, target_val) + self.midi.control_change(14, int(self.swell_shade_val), 3) + elif target_val < self.swell_shade_val: + self.swell_shade_val -= delta_time * self.shade_change_rate + self.swell_shade_val = max(self.swell_shade_val, target_val) + self.midi.control_change(14, int(self.swell_shade_val), 3) + + # great expression shade position + target_val = self.shade["shade0"] + for no in range(1, 6 + 1): + if self.ctrls["great"][f"Shade{no}"]["is_on"]: + target_val = self.shade[f"shade{no}"] + + if target_val > self.great_shade_val: + self.great_shade_val += delta_time * self.shade_change_rate + self.great_shade_val = min(self.great_shade_val, target_val) + self.midi.control_change(15, int(self.great_shade_val), 3) + elif target_val < self.great_shade_val: + self.great_shade_val -= delta_time * self.shade_change_rate + self.great_shade_val = max(self.great_shade_val, target_val) + self.midi.control_change(15, int(self.great_shade_val), 3) + + self.pre_time = curtime + + def fix_shade_error(self) -> None: + """ + Sometimes, shade errors occurs due to inconsistent on/off for each shade caused by perforation error etc... + So if the shade perforations are in order 3→2→1 to close, force reset all shade off + """ + for part in ("swell", "great"): + for no in range(1, 4): + hole_no = self.ctrls[part][f"Shade{no}"]["hole_no"] + if self.holes[f"{part}_controls"]["to_close"][hole_no]: + if no == 3: + self.shade_error_detector[part] = [] # reset list + self.shade_error_detector[part].append(no) + + # There were perforations in order 3, 2, 1 → shade is fully closed + if self.shade_error_detector[part] == [3, 2, 1]: + self.shade_error_detector[part] = [] + # set all shade to off to fix error + for no in range(1, 7): + self.ctrls[part][f"Shade{no}"]["is_on"] = False + + if len(self.shade_error_detector[part]) > 3: + # if list length is too much, reset + self.shade_error_detector[part] = [] + + def emulate(self, frame: np.ndarray, curtime: float) -> None: + if self.emulate_enable: + self.during_emulate_evt.clear() + + self.auto_track(frame) + self.holes.set_frame(frame, self.tracker_offset) + self.emulate_controls(curtime) + self.emulate_notes() + + self.during_emulate_evt.set() + +if __name__ == "__main__": + import os + import time + + import numpy as np + from midi_controller import MidiWrap + midiobj = MidiWrap() + player = Aeolian176note(os.path.join("playsk_config", "Aeolian 176-note Pipe Organ.json"), midiobj) + frame = np.full((600, 800, 3), 100, np.uint8) + start = time.perf_counter() + for _ in range(10000): + player.emulate(frame, time.perf_counter()) + end = time.perf_counter() + t = end - start + print(t, "per", (t / 10000) * 1000, "ms") diff --git a/src/players/AmpicoB.py b/src/tracker_bars/AmpicoB.py similarity index 98% rename from src/players/AmpicoB.py rename to src/tracker_bars/AmpicoB.py index 2dc7e9a..d08fc7e 100644 --- a/src/players/AmpicoB.py +++ b/src/tracker_bars/AmpicoB.py @@ -102,9 +102,6 @@ def calc_crescendo(self, curtime): elif (amplifier["to_close"] and self.amp_cres_pos < 0.3) or amplifier["to_open"]: self.amp_lock_range = [0, 1.0] - if amplifier["to_close"]: - print(self.amp_cres_pos, self.amp_lock_range) - if slow_cres["is_open"]: if fast_cres["is_open"] or amplifier["is_open"]: # fast crescendo diff --git a/src/players/Artecho.py b/src/tracker_bars/Artecho.py similarity index 100% rename from src/players/Artecho.py rename to src/tracker_bars/Artecho.py diff --git a/src/players/DuoArt.py b/src/tracker_bars/DuoArt.py similarity index 99% rename from src/players/DuoArt.py rename to src/tracker_bars/DuoArt.py index 2a60b9e..dd2964a 100644 --- a/src/players/DuoArt.py +++ b/src/tracker_bars/DuoArt.py @@ -40,7 +40,6 @@ def emulate_off(self): self.theme_delay_que = deque([self.theme_min] * 10, maxlen=10) def emulate_expression(self, curtime): - # accomp 1->2->4->8 accomp_pos = sum([v * b for v, b in zip((1, 2, 4, 8), self.holes["accomp"]["is_open"])]) accomp_vacuum = self.accomp_min + (self.accomp_max - self.accomp_min) * (accomp_pos / 15) diff --git a/src/players/PhilippsDuca.py b/src/tracker_bars/PhilippsDuca.py similarity index 100% rename from src/players/PhilippsDuca.py rename to src/tracker_bars/PhilippsDuca.py diff --git a/src/players/RecordoA.py b/src/tracker_bars/RecordoA.py similarity index 100% rename from src/players/RecordoA.py rename to src/tracker_bars/RecordoA.py diff --git a/src/players/RecordoB.py b/src/tracker_bars/RecordoB.py similarity index 100% rename from src/players/RecordoB.py rename to src/tracker_bars/RecordoB.py diff --git a/src/players/Themodist.py b/src/tracker_bars/Themodist.py similarity index 100% rename from src/players/Themodist.py rename to src/tracker_bars/Themodist.py diff --git a/src/players/Themodist_eValve.py b/src/tracker_bars/Themodist_eValve.py similarity index 66% rename from src/players/Themodist_eValve.py rename to src/tracker_bars/Themodist_eValve.py index e1af0be..14f3ac9 100644 --- a/src/players/Themodist_eValve.py +++ b/src/tracker_bars/Themodist_eValve.py @@ -1,5 +1,3 @@ -from mido import Message - from .Themodist import Themodist @@ -15,14 +13,14 @@ def emulate_expression(self, curtime): # send e-valve midi signal if self.holes["bass_snakebite"]["to_open"]: - self.midi.output.send(Message("note_on", note=self.bass_snake_midi_no, velocity=64)) + self.midi.note_on(self.bass_snake_midi_no, velocity=64) elif self.holes["bass_snakebite"]["to_close"]: - self.midi.output.send(Message("note_off", note=self.bass_snake_midi_no, velocity=64)) + self.midi.note_off(self.bass_snake_midi_no, velocity=64) if self.holes["treble_snakebite"]["to_open"]: - self.midi.output.send(Message("note_on", note=self.treble_snake_midi_no, velocity=64)) + self.midi.note_on(self.treble_snake_midi_no, velocity=64) elif self.holes["treble_snakebite"]["to_close"]: - self.midi.output.send(Message("note_off", note=self.treble_snake_midi_no, velocity=64)) + self.midi.note_off(self.treble_snake_midi_no, velocity=64) def emulate_pedals(self): super().emulate_pedals() @@ -30,9 +28,9 @@ def emulate_pedals(self): # send e-valve midi signal sustain = self.holes["sustain"] if sustain["to_open"]: - self.midi.output.send(Message("note_on", note=self.sustein_midi_no, velocity=64)) + self.midi.note_on(self.sustein_midi_no, velocity=64) elif sustain["to_close"]: - self.midi.output.send(Message("note_off", note=self.sustein_midi_no, velocity=64)) + self.midi.note_off(self.sustein_midi_no, velocity=64) if __name__ == "__main__": @@ -42,7 +40,7 @@ def emulate_pedals(self): import numpy as np from midi_controller import MidiWrap midiobj = MidiWrap() - player = Themodist(os.path.join("playsk_config", "Themodist white back.json"), midiobj) + player = Themodist(os.path.join("playsk_config", "Themodist e-Valve.json"), midiobj) frame = np.full((600, 800, 3), 100, np.uint8) start = time.perf_counter() for _ in range(10000): diff --git a/src/players/WelteLicensee.py b/src/tracker_bars/WelteLicensee.py similarity index 79% rename from src/players/WelteLicensee.py rename to src/tracker_bars/WelteLicensee.py index 01fca53..52e817b 100644 --- a/src/players/WelteLicensee.py +++ b/src/tracker_bars/WelteLicensee.py @@ -5,13 +5,13 @@ class WelteLicensee(WelteT100): def __init__(self, confpath, midiobj): super().__init__(confpath, midiobj) - self.bass_slow_cres_rate = self.mf_hook_pos / 2.5 # min to mf takes 2.5sec - self.bass_slow_decres_rate = self.mf_hook_pos / 2.5 # mf to min takes 2.5sec + self.bass_slow_cres_rate = self.mf_hook_pos / 2.45 # min to mf takes 2.45sec + self.bass_slow_decres_rate = self.mf_hook_pos / 2.45 # mf to min takes 2.45sec self.bass_fast_cres_rate = 1 / 0.58 self.bass_fast_decres_rate = 1 / 0.15 - self.treble_slow_cres_rate = self.mf_hook_pos / 2.5 # min to mf takes 2.5sec - self.treble_slow_decres_rate = self.mf_hook_pos / 2.5 # mf to min takes 2.5sec + self.treble_slow_cres_rate = self.mf_hook_pos / 2.45 # min to mf takes 2.5sec + self.treble_slow_decres_rate = self.mf_hook_pos / 2.45 # mf to min takes 2.5sec self.treble_fast_cres_rate = 1 / 0.58 self.treble_fast_decres_rate = 1 / 0.15 diff --git a/src/players/WelteT100.py b/src/tracker_bars/WelteT100.py similarity index 91% rename from src/players/WelteT100.py rename to src/tracker_bars/WelteT100.py index 24ea592..c7e965f 100644 --- a/src/players/WelteT100.py +++ b/src/tracker_bars/WelteT100.py @@ -10,7 +10,7 @@ def __init__(self, confpath, midiobj): self.mf_hook_pos = 0.47 self.loud_pos = 0.7 - self.min_vacuum = 5.5 # in W.G + self.min_vacuum = 5.7 # in W.G self.max_vacuum = 35 # in W.G self.cres_pos_to_vacuum = np.poly1d(np.polyfit((0, self.mf_hook_pos, 1), (self.min_vacuum, 20, self.max_vacuum), 2)) @@ -76,6 +76,15 @@ def calc_crescendo(self, curtime): self.pre_time = curtime delta_time = curtime - self.pre_time + # # note on vacuum drop emulation + # on_notes = self.holes["note"]["to_open"].nonzero()[0] + # if on_notes.size > 0: + # for key in on_notes: + # if key < self.stack_split: + # self.bass_cres_pos -=0.004 + # else: + # self.treble_cres_pos -= 0.004 + # bass cres_pos_min = 0 cres_pos_max = 1 @@ -87,7 +96,7 @@ def calc_crescendo(self, curtime): if self.bass_cres_state == "slow_cres": self.bass_cres_pos += delta_time * self.bass_slow_cres_rate - if not self.bass_mf_hook: + if not (self.bass_mf_hook or self.holes["bass_forz_forte"]["is_open"]): self.bass_cres_pos = min(self.bass_cres_pos, self.loud_pos) elif self.bass_cres_state == "slow_decres": self.bass_cres_pos -= delta_time * self.bass_slow_decres_rate @@ -111,7 +120,7 @@ def calc_crescendo(self, curtime): if self.treble_cres_state == "slow_cres": self.treble_cres_pos += delta_time * self.treble_slow_cres_rate - if not self.treble_mf_hook: + if not (self.treble_mf_hook or self.holes["treble_forz_forte"]["is_open"]): self.treble_cres_pos = min(self.treble_cres_pos, self.loud_pos) elif self.treble_cres_state == "slow_decres": self.treble_cres_pos -= delta_time * self.treble_slow_decres_rate diff --git a/src/players/WelteT98.py b/src/tracker_bars/WelteT98.py similarity index 100% rename from src/players/WelteT98.py rename to src/tracker_bars/WelteT98.py diff --git a/src/players/__init__.py b/src/tracker_bars/__init__.py similarity index 90% rename from src/players/__init__.py rename to src/tracker_bars/__init__.py index 2137d06..7c875bd 100644 --- a/src/players/__init__.py +++ b/src/tracker_bars/__init__.py @@ -1,3 +1,4 @@ +from .Aeolian176note import Aeolian176note from .AmpicoB import AmpicoB from .Artecho import Artecho from .base_player import BasePlayer @@ -12,6 +13,7 @@ from .WelteT100 import WelteT100 __all__ = [ + "Aeolian176note", "AmpicoB", "Artecho", "BasePlayer", @@ -19,9 +21,9 @@ "PhilippsDuca", "RecordoA", "RecordoB", + "Themodist", + "Themodist_eValve", "WelteLicensee", "WelteT98", "WelteT100", - "Themodist", - "Themodist_eValve", ] diff --git a/src/players/base_player.py b/src/tracker_bars/base_player.py similarity index 90% rename from src/players/base_player.py rename to src/tracker_bars/base_player.py index 4df6cd5..c23ccdf 100644 --- a/src/players/base_player.py +++ b/src/tracker_bars/base_player.py @@ -1,6 +1,5 @@ import json import threading -from typing import final import numpy as np import wx @@ -27,7 +26,11 @@ def __init__(self, conf): self.group_by_size.setdefault(key, {"pos": [], "pos_xs": None, "pos_ys": None, "on_apatures": [], "off_apatures": []}) si = len(self.group_by_size[key]["pos"]) - xs = v["x"] if isinstance(v["x"], list) else [v["x"]] + xs = [v["x"]] + if isinstance(v["x"], list): + xs = v["x"] + elif isinstance(v["x"], dict): + xs = list(v["x"].values()) tmp = [(x, v["y"], x + v["w"], v["y"] + v["h"]) for x in xs] self.group_by_size[key]["pos"].extend(tmp) tmp = [v["on_apature"]] * len(xs) @@ -73,13 +76,13 @@ def set_frame(self, frame, xoffset): v["to_close"] = v["is_open"] & (open_ratios < v["off_apatures"]) # hole is closed just now v["is_open"] ^= (v["to_open"] | v["to_close"]) # hole is open or close - def all_off(self): + def all_off(self) -> None: for v in self.group_by_size.values(): v["is_open"] &= False v["to_open"] &= False v["to_close"] &= False - def draw(self, wxdc: wx.PaintDC): + def draw(self, wxdc: wx.PaintDC) -> None: if self.open_pen is None: self.open_pen = wx.Pen((200, 0, 0)) if self.close_pen is None: @@ -91,7 +94,7 @@ def draw(self, wxdc: wx.PaintDC): wxdc.DrawRectangleList(self.draw_rects, pens) wxdc.SetLogicalOrigin(0, 0) - def __getitem__(self, key): + def __getitem__(self, key: str) -> dict: hole_size, idx = self.group_by_name[key] ret = { "pos": self.group_by_size[hole_size]["pos"][idx], @@ -103,7 +106,7 @@ def __getitem__(self, key): class BasePlayer: - def __init__(self, confpath, midiobj: MidiWrap): + def __init__(self, confpath: str, midiobj: MidiWrap) -> None: self.midi = midiobj # load tracker config @@ -145,25 +148,28 @@ def __init__(self, confpath, midiobj: MidiWrap): ord("L"): {"press": False, "vacuum": 15}, } + # set piano sound for GM sound + self.midi.program_change(0, channel=0) + def calc_velocity(self): idx = np.digitize([self.bass_vacuum, self.treble_vacuum], bins=self.velocity_bins) return self.velocity[0] + idx - def emulate_off(self): + def emulate_off(self) -> None: self.emulate_enable = False self.during_emulate_evt.wait(timeout=1) self.holes.all_off() self.midi.all_off() - def emulate_on(self): + def emulate_on(self) -> None: self.emulate_enable = True - def auto_track(self, frame): + def auto_track(self, frame) -> None: if not self.auto_tracking: return # find roll edge - roi = np.array([frame[250:350:5, 0:7], frame[250:350:5, 793:800]]) + roi = np.array([frame[270:330:5, 0:7], frame[270:330:5, 793:800]]) if self.is_dark_hole: left_end, right_end = (roi > self.on_bright).all(axis=3).sum(axis=2).mean(axis=1) else: @@ -171,8 +177,7 @@ def auto_track(self, frame): self.tracker_offset = int(right_end - left_end) - @final - def emulate(self, frame, curtime): + def emulate(self, frame, curtime: float) -> None: if self.emulate_enable: self.during_emulate_evt.clear() @@ -192,17 +197,16 @@ def expression_key_event(self, key: int, pressed: bool) -> None: if key == self.treble_accent_key: self.treble_accent = pressed - accomp_map = self.manual_exp_map.get(key, None) - if accomp_map is not None: + if (accomp_map := self.manual_exp_map.get(key, None)) is not None: if accomp_map["press"] and not pressed: accomp_map["press"] = False if not accomp_map["press"] and pressed: accomp_map["press"] = True - def emulate_expression(self, curtime): + def emulate_expression(self, curtime: float) -> None: pass - def emulate_manual_expression(self, curtime): + def emulate_manual_expression(self, curtime: float) -> None: if not self.manual_expression: return @@ -216,7 +220,7 @@ def emulate_manual_expression(self, curtime): if self.treble_accent: self.treble_vacuum = min(self.treble_vacuum + 6, self.max_vacuum) - def emulate_pedals(self): + def emulate_pedals(self) -> None: # sustain pedal sustain = self.holes["sustain"] if sustain["to_open"]: @@ -233,7 +237,7 @@ def emulate_pedals(self): elif soft["to_close"]: self.midi.hammer_lift_off() - def emulate_notes(self): + def emulate_notes(self) -> None: note = self.holes["note"] offset = self.holes.lowest_note + 21 @@ -247,7 +251,7 @@ def emulate_notes(self): for key in note["to_close"].nonzero()[0]: self.midi.note_off(key + offset) - def draw_tracker(self, wxdc: wx.PaintDC): + def draw_tracker(self, wxdc: wx.PaintDC) -> None: # draw tracker frame wxdc.SetPen(wx.Pen((0, 100, 100))) wxdc.DrawLineList([(0, 275, 799, 275), (0, 325, 799, 325)]) diff --git a/src/version.py b/src/version.py index a0560ee..cbff2b3 100644 --- a/src/version.py +++ b/src/version.py @@ -1,4 +1,4 @@ APP_TITLE = "PlaySK Piano Roll Reader" -APP_VERSION = "3.5.2" -COPY_RIGHT = "(C)Sasaki Katsumasa 2014-2024" +APP_VERSION = "3.6.0" +COPY_RIGHT = "(C)Sasaki Katsumasa 2014-2025" diff --git a/src/version_info.txt b/src/version_info.txt index 1d12aae..2dd15d2 100644 --- a/src/version_info.txt +++ b/src/version_info.txt @@ -4,8 +4,8 @@ VSVersionInfo( ffi=FixedFileInfo( # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # Set not needed items to zero 0. - filevers=(3, 5, 2, 0), - prodvers=(3, 5, 2, 0), + filevers=(3, 6, 0, 0), + prodvers=(3, 6, 0, 0), # Contains a bitmask that specifies the valid bits 'flags'r mask=0x3f, # Contains a bitmask that specifies the Boolean attributes of the file. @@ -28,10 +28,10 @@ VSVersionInfo( StringTable( u'040904B0', [StringStruct(u'FileDescription', u'PlaySK Piano Roll Reader'), - StringStruct(u'FileVersion', u'3.5.2.0'), - StringStruct(u'LegalCopyright', u'(C)Sasaki Katsumasa 2014-2024'), + StringStruct(u'FileVersion', u'3.6.0.0'), + StringStruct(u'LegalCopyright', u'(C)Sasaki Katsumasa 2014-2025'), StringStruct(u'ProductName', u'PlaySK Piano Roll Reader'), - StringStruct(u'ProductVersion', u'3.5.2.0')]) + StringStruct(u'ProductVersion', u'3.6.0.0')]) ]), VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) ] diff --git a/test/test_AmpicoB.py b/test/test_AmpicoB.py index acad14f..d559051 100644 --- a/test/test_AmpicoB.py +++ b/test/test_AmpicoB.py @@ -5,7 +5,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -13,7 +13,7 @@ class TestAmpicoB: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.AmpicoB("src/playsk_config/Ampico B white back.json", midiobj) + obj = tracker_bars.AmpicoB("src/playsk_config/Ampico B white back.json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_Artecho.py b/test/test_Artecho.py index 2ad7aba..7572483 100644 --- a/test/test_Artecho.py +++ b/test/test_Artecho.py @@ -4,7 +4,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -12,7 +12,7 @@ class TestArtecho: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.Artecho("src/playsk_config/Artecho white back (experimental).json", midiobj) + obj = tracker_bars.Artecho("src/playsk_config/Artecho white back (experimental).json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_DuoArt.py b/test/test_DuoArt.py index 497ec68..3459eea 100644 --- a/test/test_DuoArt.py +++ b/test/test_DuoArt.py @@ -6,7 +6,7 @@ sys.path.append("src/") from collections import deque -import players +import tracker_bars from midi_controller import MidiWrap @@ -14,7 +14,7 @@ class TestDuoArt: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.DuoArt("src/playsk_config/Duo-Art white back.json", midiobj) + obj = tracker_bars.DuoArt("src/playsk_config/Duo-Art white back.json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_RecordoA.py b/test/test_RecordoA.py index 303a7f4..18d46d2 100644 --- a/test/test_RecordoA.py +++ b/test/test_RecordoA.py @@ -4,7 +4,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -12,7 +12,7 @@ class TestRecordoA: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.RecordoA("src/playsk_config/Recordo A (rare) white back.json", midiobj) + obj = tracker_bars.RecordoA("src/playsk_config/Recordo A (rare) white back.json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_RecordoB.py b/test/test_RecordoB.py index 837105c..13ab83d 100644 --- a/test/test_RecordoB.py +++ b/test/test_RecordoB.py @@ -4,7 +4,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -12,7 +12,7 @@ class TestRecordoB: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.RecordoB("src/playsk_config/Recordo B white back.json", midiobj) + obj = tracker_bars.RecordoB("src/playsk_config/Recordo B white back.json", midiobj) return obj @pytest.mark.parametrize("open_ports, expect", [ diff --git a/test/test_Themodist.py b/test/test_Themodist.py index 5967106..893c163 100644 --- a/test/test_Themodist.py +++ b/test/test_Themodist.py @@ -4,7 +4,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -12,7 +12,7 @@ class TestThemodist: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.Themodist("src/playsk_config/Themodist white back.json", midiobj) + obj = tracker_bars.Themodist("src/playsk_config/Themodist white back.json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_Themodist_eValve.py b/test/test_Themodist_eValve.py new file mode 100644 index 0000000..35a6d29 --- /dev/null +++ b/test/test_Themodist_eValve.py @@ -0,0 +1,79 @@ +import sys + +import numpy as np +import pytest + +sys.path.append("src/") +from midi_controller import MidiWrap +from tracker_bars.Themodist_eValve import Themodist_eValve + + +class TestThemodist_eValve: + @pytest.fixture + def player(self) -> Themodist_eValve: + midiobj = MidiWrap() + return Themodist_eValve("src/playsk_config/Themodist e-Valve.json", midiobj) + + def test_emulate_off(self, player): + player.bass_vacuum = player.treble_vacuum = player.accent_vacuum + player.emulate_off() + assert player.bass_vacuum == player.base_vacuum + assert player.treble_vacuum == player.base_vacuum + + def test_accent(self, player, mocker): + # bass accent on + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["bass_snakebite"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + midi_mock = mocker.patch("midi_controller.MidiWrap.note_on") + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + midi_mock.assert_called_with(player.bass_snake_midi_no, velocity=64) + assert player.bass_vacuum == player.accent_vacuum + assert player.treble_vacuum == player.base_vacuum + + # bass accent off + frame[y1:y2, x1:x2, :] = 0 + midi_mock = mocker.patch("midi_controller.MidiWrap.note_off") + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + midi_mock.assert_called_with(player.bass_snake_midi_no, velocity=64) + assert player.bass_vacuum == player.base_vacuum + assert player.treble_vacuum == player.base_vacuum + + # treble accent on + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["treble_snakebite"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + midi_mock = mocker.patch("midi_controller.MidiWrap.note_on") + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + midi_mock.assert_called_with(player.treble_snake_midi_no, velocity=64) + assert player.bass_vacuum == player.base_vacuum + assert player.treble_vacuum == player.accent_vacuum + + # treble accent off + frame[y1:y2, x1:x2, :] = 0 + midi_mock = mocker.patch("midi_controller.MidiWrap.note_off") + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + midi_mock.assert_called_with(player.treble_snake_midi_no, velocity=64) + assert player.bass_vacuum == player.base_vacuum + assert player.treble_vacuum == player.base_vacuum + + def test_sustain(self, player, mocker): + # sustein on + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["sustain"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + midi_mock = mocker.patch("midi_controller.MidiWrap.note_on") + player.holes.set_frame(frame, 0) + player.emulate_pedals() + midi_mock.assert_called_with(player.sustein_midi_no, velocity=64) + + # sustein off + frame[y1:y2, x1:x2, :] = 0 + midi_mock = mocker.patch("midi_controller.MidiWrap.note_off") + player.holes.set_frame(frame, 0) + player.emulate_pedals() + midi_mock.assert_called_with(player.sustein_midi_no, velocity=64) diff --git a/test/test_WelteLicensee.py b/test/test_WelteLicensee.py new file mode 100644 index 0000000..8dca6a5 --- /dev/null +++ b/test/test_WelteLicensee.py @@ -0,0 +1,133 @@ +import sys + +import numpy as np +import pytest + +sys.path.append("src/") +import tracker_bars +from midi_controller import MidiWrap + + +class TestWelteLicensee: + @pytest.fixture + def player(self): + midiobj = MidiWrap() + obj = tracker_bars.WelteLicensee("src/playsk_config/Welte Licensee white back.json", midiobj) + return obj + + def test_emulate_off(self, player): + player.bass_cres_pos = 1 + player.bass_cres_state = "slow_cres" + player.bass_mf_hook = True + player.treble_cres_pos = 1 + player.treble_cres_state = "slow_cres" + player.treble_mf_hook = True + player.bass_vacuum = 30 + player.treble_vacuum = 30 + player.emulate_off() + assert player.bass_cres_pos == 0 + assert player.bass_cres_state == "slow_decres" + assert not player.bass_mf_hook + assert player.treble_cres_pos == 0 + assert player.treble_cres_state == "slow_decres" + assert not player.treble_mf_hook + assert player.bass_vacuum == player.min_vacuum + assert player.treble_vacuum == player.min_vacuum + + def test_pedal(self, player, mocker): + # sustain on + frame = np.full((600, 800, 3), 0, np.uint8) + sustain_on_mock = mocker.patch("midi_controller.MidiWrap.sustain_on") + x1, y1, x2, y2 = player.holes["sustain_on"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_pedals() + sustain_on_mock.assert_called_once() + + # sustain off + frame = np.full((600, 800, 3), 0, np.uint8) + sustain_off_mock = mocker.patch("midi_controller.MidiWrap.sustain_off") + x1, y1, x2, y2 = player.holes["sustain_off"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_pedals() + sustain_off_mock.assert_called_once() + + # hammer rail on + frame = np.full((600, 800, 3), 0, np.uint8) + sustain_on_mock = mocker.patch("midi_controller.MidiWrap.hammer_lift_on") + x1, y1, x2, y2 = player.holes["soft_on"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_pedals() + sustain_on_mock.assert_called_once() + + # hammer rail off + frame = np.full((600, 800, 3), 0, np.uint8) + sustain_off_mock = mocker.patch("midi_controller.MidiWrap.hammer_lift_off") + x1, y1, x2, y2 = player.holes["soft_off"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_pedals() + sustain_off_mock.assert_called_once() + + def test_emulate_expression(self, player): + # bass mf on/off + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["bass_mf_on"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert player.bass_mf_hook + + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["bass_mf_off"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert not player.bass_mf_hook + + # bass slow crescend on/off + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["bass_cresc_forte"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert player.bass_cres_state == "slow_cres" + + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["bass_cresc_piano"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert player.bass_cres_state == "slow_decres" + + # treble mf on/off + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["treble_mf_on"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert player.treble_mf_hook + + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["treble_mf_off"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert not player.treble_mf_hook + + # treble slow crescend on/off + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["treble_cresc_forte"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert player.treble_cres_state == "slow_cres" + + frame = np.full((600, 800, 3), 0, np.uint8) + x1, y1, x2, y2 = player.holes["treble_cresc_piano"]["pos"][0] + frame[y1:y2, x1:x2, :] = 255 + player.holes.set_frame(frame, 0) + player.emulate_expression(0) + assert player.treble_cres_state == "slow_decres" \ No newline at end of file diff --git a/test/test_WelteT100.py b/test/test_WelteT100.py index ddb5400..1be1ebc 100644 --- a/test/test_WelteT100.py +++ b/test/test_WelteT100.py @@ -4,7 +4,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -12,7 +12,7 @@ class TestWelteT100: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.WelteT100("src/playsk_config/Welte T100 white back.json", midiobj) + obj = tracker_bars.WelteT100("src/playsk_config/Welte T100 white back.json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_WelteT98.py b/test/test_WelteT98.py index 7165d3c..c62eb80 100644 --- a/test/test_WelteT98.py +++ b/test/test_WelteT98.py @@ -4,7 +4,7 @@ import pytest sys.path.append("src/") -import players +import tracker_bars from midi_controller import MidiWrap @@ -12,7 +12,7 @@ class TestWelteT98: @pytest.fixture def player(self): midiobj = MidiWrap() - obj = players.WelteT98("src/playsk_config/Welte T98 white back.json", midiobj) + obj = tracker_bars.WelteT98("src/playsk_config/Welte T98 white back.json", midiobj) return obj def test_emulate_off(self, player): diff --git a/test/test_images/clocked_single_gt.png b/test/test_images/clocked_single_gt.png index eb14370..cb9a81b 100644 Binary files a/test/test_images/clocked_single_gt.png and b/test/test_images/clocked_single_gt.png differ diff --git a/test/test_images/stepper_bicolor_gt.png b/test/test_images/stepper_bicolor_gt.png index 0e12ba4..e2dd072 100644 Binary files a/test/test_images/stepper_bicolor_gt.png and b/test/test_images/stepper_bicolor_gt.png differ diff --git a/test/test_images/stepper_single_gt.png b/test/test_images/stepper_single_gt.png index 175c6c4..bc2cb3f 100644 Binary files a/test/test_images/stepper_single_gt.png and b/test/test_images/stepper_single_gt.png differ diff --git a/test/test_images/stepper_twin (twinned)_gt.png b/test/test_images/stepper_twin (twinned)_gt.png index 6710b33..7aadb14 100644 Binary files a/test/test_images/stepper_twin (twinned)_gt.png and b/test/test_images/stepper_twin (twinned)_gt.png differ diff --git a/test/test_images/stepper_twin_gt.png b/test/test_images/stepper_twin_gt.png index afea9fe..d1ada56 100644 Binary files a/test/test_images/stepper_twin_gt.png and b/test/test_images/stepper_twin_gt.png differ diff --git a/test/test_images/unknown_scanner_gt.png b/test/test_images/unknown_scanner_gt.png index 6f89bb2..b746291 100644 Binary files a/test/test_images/unknown_scanner_gt.png and b/test/test_images/unknown_scanner_gt.png differ diff --git a/test/test_player.py b/test/test_player.py index 7e9fe47..a116d01 100644 --- a/test/test_player.py +++ b/test/test_player.py @@ -9,7 +9,7 @@ sys.path.append("src/") from midi_controller import MidiWrap -from players.base_player import BasePlayer, TrackerHoles +from tracker_bars.base_player import BasePlayer, TrackerHoles class TestTrackerHoles: diff --git a/test/test_player_mng.py b/test/test_player_mng.py index f606a7f..5e5bf5c 100644 --- a/test/test_player_mng.py +++ b/test/test_player_mng.py @@ -3,7 +3,8 @@ import pytest sys.path.append("src/") -import players +import tracker_bars +from midi_controller import MidiWrap from player_mng import PlayerMng @@ -36,18 +37,23 @@ def test_player_list(self, player_mng): "Welte T98 white back", "Themodist white back", "Themodist e-Valve", + "Aeolian 176-note Pipe Organ", ]) assert player_names == gt_names def test_get_player_obj(self, player_mng): - assert player_mng.get_player_obj("not exists player", None) is None - assert type(player_mng.get_player_obj("Ampico B white back", None)) is players.AmpicoB - assert type(player_mng.get_player_obj("Duo-Art white back", None)) is players.DuoArt - assert type(player_mng.get_player_obj("Philipps Duca (no expression)", None)) is players.PhilippsDuca - assert type(player_mng.get_player_obj("88 Note white back", None)) is players.BasePlayer - assert type(player_mng.get_player_obj("Welte Licensee white back", None)) is players.WelteLicensee - assert type(player_mng.get_player_obj("Welte T100 white back", None)) is players.WelteT100 - assert type(player_mng.get_player_obj("Recordo A (rare) white back", None)) is players.RecordoA - assert type(player_mng.get_player_obj("Recordo B white back", None)) is players.RecordoB - assert type(player_mng.get_player_obj("Artecho white back (experimental)", None)) is players.Artecho - assert type(player_mng.get_player_obj("Welte T98 white back", None)) is players.WelteT98 + midiobj = MidiWrap() + assert player_mng.get_player_obj("not exists player", midiobj) is None + assert type(player_mng.get_player_obj("Ampico B white back", midiobj)) is tracker_bars.AmpicoB + assert type(player_mng.get_player_obj("Duo-Art white back", midiobj)) is tracker_bars.DuoArt + assert type(player_mng.get_player_obj("Philipps Duca (no expression)", midiobj)) is tracker_bars.PhilippsDuca + assert type(player_mng.get_player_obj("88 Note white back", midiobj)) is tracker_bars.BasePlayer + assert type(player_mng.get_player_obj("Welte Licensee white back", midiobj)) is tracker_bars.WelteLicensee + assert type(player_mng.get_player_obj("Welte T100 white back", midiobj)) is tracker_bars.WelteT100 + assert type(player_mng.get_player_obj("Recordo A (rare) white back", midiobj)) is tracker_bars.RecordoA + assert type(player_mng.get_player_obj("Recordo B white back", midiobj)) is tracker_bars.RecordoB + assert type(player_mng.get_player_obj("Artecho white back (experimental)", midiobj)) is tracker_bars.Artecho + assert type(player_mng.get_player_obj("Welte T98 white back", midiobj)) is tracker_bars.WelteT98 + assert type(player_mng.get_player_obj("Themodist white back", midiobj)) is tracker_bars.Themodist + assert type(player_mng.get_player_obj("Themodist e-Valve", midiobj)) is tracker_bars.Themodist_eValve + assert type(player_mng.get_player_obj("Aeolian 176-note Pipe Organ", midiobj)) is tracker_bars.Aeolian176note diff --git a/test/test_versions.py b/test/test_versions.py index 308fd54..02f90c3 100644 --- a/test/test_versions.py +++ b/test/test_versions.py @@ -15,8 +15,8 @@ def test_verions(): # check copyright year cur_yyyy = datetime.date.today().year matched = re.findall(r"-(\d{4})", COPY_RIGHT, flags=re.MULTILINE) - yyyy = int(matched[0]) if matched else "" - assert cur_yyyy == yyyy + yyyy = int(matched[0]) if matched else 0 + assert cur_yyyy <= yyyy <= cur_yyyy + 1 # accept +1 year # check version on build_mac.spec with open("build_mac.spec") as f: @@ -46,7 +46,7 @@ def test_verions(): matched = re.findall(r"^\s*StringStruct\(u'LegalCopyright',.*-(\d{4})'\),?", text, flags=re.MULTILINE) yyyy = int(matched[0]) if matched else "" - assert cur_yyyy == yyyy + assert cur_yyyy <= yyyy <= cur_yyyy + 1 # accept +1 year # check pyproject.toml with open("pyproject.toml", "rb") as f: