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
+
+
+
+ Control |
+ MIDI channel |
+ MIDI note number |
+ MIDI control change |
+ MIDI value |
+ supplement |
+
+
+ Swell Notes |
+ 1 |
+ 36-96 |
+ - |
+ - |
+ 61 notes |
+
+
+ Great Notes |
+ 2 |
+ 36-96 |
+ - |
+ - |
+ 61 notes |
+
+
+ Pedal Notes |
+ 3 |
+ 36-67 |
+ - |
+ - |
+ 32 notes |
+
+
+ Swell Expression Shade |
+ 4 |
+ - |
+ 14 |
+ 30-127 |
+ 30 is fully closed. 127 is fully opened. |
+
+
+ Great Expression Shade |
+ - |
+ 15 |
+ 30-127 |
+
+
+ Echo |
+ - |
+ 20:ON 110:OFF
For Hauptwerk virtual organ, corresponds to "Notation stop or hold-piston; CC20=on, CC110=off" |
+ 0 |
+ Echo 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 |
+ - |
+ 19 |
+ Tonal 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: