├── hachiko_bapu
├── cli_tools
│ ├── __init__.py
│ ├── srt2mid.py
│ └── lrc_merge.py
├── __init__.py
├── instrument.sf2
├── fluidsynth.h
├── hachitools.py
├── FluidSynth.py
├── synthesize.py
├── funutils.py
├── hachi_groups.py
├── hachi.py
├── tkgui_utils.py
└── tkgui.py
├── requirements.txt
├── Hachiko.png
├── highpitch_instrument.sf2
├── LICENSE
├── setup.py
├── .gitignore
└── README.md
/hachiko_bapu/cli_tools/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ["lrc_merge", "srt2mid"]
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pygame>=1.8
2 | sf2utils>=0.9
3 | srt>=3.0
4 | mido>=1.2
5 |
--------------------------------------------------------------------------------
/Hachiko.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/duangsuse-valid-projects/Hachiko/HEAD/Hachiko.png
--------------------------------------------------------------------------------
/hachiko_bapu/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ["hachi", "hachi_groups", "hachitools", "synthesize"]
2 |
--------------------------------------------------------------------------------
/highpitch_instrument.sf2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/duangsuse-valid-projects/Hachiko/HEAD/highpitch_instrument.sf2
--------------------------------------------------------------------------------
/hachiko_bapu/instrument.sf2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/duangsuse-valid-projects/Hachiko/HEAD/hachiko_bapu/instrument.sf2
--------------------------------------------------------------------------------
/hachiko_bapu/fluidsynth.h:
--------------------------------------------------------------------------------
1 | void* new_fluid_settings();
2 | void delete_fluid_settings(void* settings);
3 |
4 | void* new_fluid_synth(void* settings);
5 | void delete_fluid_synth(void* synth);
6 |
7 | int fluid_settings_setstr(void* settings, char* name, char* str);
8 | int fluid_settings_setnum(void* settings, char* name, double val);
9 |
10 | int fluid_settings_setint(void* settings, char* name, int val);
11 |
12 | int fluid_synth_sfload(void* synth, char* filename, int update_midi_presets);
13 | int fluid_synth_sfunload(void* synth, int sfid, int update_midi_presets);
14 |
15 | int fluid_synth_program_select(void* synth, int chan, int sfid, int bank, int preset);
16 |
17 | int fluid_synth_noteon(void* synth, int chan, int key, int vel);
18 | int fluid_synth_noteoff(void* synth, int chan, int key);
19 |
20 | void fluid_synth_write_s16(void* synth, int len, void* lbuf, int loff, int lincr, void* rbuf, int roff, int rincl);
21 |
22 | void* new_fluid_audio_driver(void* settings, void* synth);
23 |
24 | void delete_fluid_audio_driver(void* driver);
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 duangsuse's code
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | from setuptools import setup, find_packages
5 |
6 | def parse_requirements(requirements):
7 | with open(requirements) as reqf:
8 | items = [line.strip("\n") for line in reqf if not line.startswith("#")]
9 | return list(filter(lambda s: s.strip() != "", items))
10 |
11 | setup(
12 | name="hachiko-bapu", version="0.1.8",
13 | python_requires=">=3.5",
14 | author="duangsuse", author_email="fedora-opensuse@outlook.com",
15 | url="https://github.com/duangsuse-valid-projects/Hachiko",
16 | description="Simple pygame GUI tool for creating pitch timeline",
17 | long_description="""
18 | Simple tool for creating pitch timeline, this program divides midi creation into pitches and timeline part.
19 |
20 | When creating timeline, press A to give position/duration information, and use S to split different notes directly when holding A.
21 |
22 | This program requires system FluidSynth library to run, this package also provides command utility srt2mid and lrc_merge.
23 | """,
24 | classifiers=[
25 | "Intended Audience :: End Users/Desktop",
26 | "License :: OSI Approved :: MIT License",
27 | "Operating System :: OS Independent",
28 | "Topic :: Multimedia",
29 | "Topic :: Multimedia :: Sound/Audio",
30 | "Topic :: Utilities"
31 | ],
32 |
33 | packages=find_packages(),
34 | package_data={ "": ["*.sf2"] },
35 | install_requires=parse_requirements("requirements.txt"),
36 | extras_require={
37 | "synthesize buffer": ["numpy>=1.0"],
38 | "funutils codegen": ["pyparsing>=2.4"]
39 | },
40 | entry_points={
41 | "console_scripts": [
42 | "hachiko = hachiko_bapu.hachi:main",
43 | "hachiko-groups = hachiko_bapu.hachi_groups:main",
44 | "srt2mid = hachiko_bapu.cli_tools.srt2mid:main",
45 | "lrc_merge = hachiko_bapu.cli_tools.lrc_merge:main"
46 | ]
47 | })
48 |
--------------------------------------------------------------------------------
/hachiko_bapu/hachitools.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Optional, List, TypeVar, Generic
2 | T = TypeVar("T"); R = TypeVar("R")
3 |
4 | from threading import Timer
5 | from os import environ
6 |
7 | SEC_MS = 1000
8 |
9 | def htmlColor(c:str): return tuple(int(c[i-1:i+1], 16) for i in range(1, len(c), 2))
10 | def grayColor(n:int): return (n,n,n)
11 |
12 | def env(name:str, transform:Callable[[str],T], default:T) -> T:
13 | return transform(environ[name]) if name in environ else default
14 |
15 | def timeout(n_sec:float, op):
16 | timer = Timer(n_sec, op); timer.start()
17 | return timer
18 |
19 | class NonlocalReturn(Exception):
20 | def __init__(self, value = None):
21 | super().__init__(value)
22 | @property
23 | def value(self): return self.args[0]
24 |
25 | class Fold(Generic[T, R]):
26 | def __init__(self): pass
27 | def accept(self, value:T): pass
28 | def finish(self) -> R: pass
29 |
30 | class AsList(Generic[T], Fold[T, List[T]]):
31 | def __init__(self):
32 | self.items = []
33 | def accept(self, value):
34 | self.items.append(value)
35 | def finish(self): return self.items
36 |
37 | class RefUpdate(Generic[T]):
38 | def __init__(self, initial:T):
39 | self._item = initial; self.last_item:Optional[T] = None
40 | @property
41 | def item(self): return self._item
42 | def _updated(self): self.last_item = self._item
43 |
44 | def hasUpdate(self):
45 | has_upd = self.last_item != self.item
46 | self._updated() # used in check loop
47 | return has_upd
48 | def show(self, item:T):
49 | self._updated()
50 | self._item = item
51 | def slides(self, n_sec, *items:T):
52 | stream = iter(items)
53 | def showNext():
54 | nonlocal timeouts
55 | try:
56 | self.show(next(stream))
57 | timeouts[0] = timeout(n_sec, showNext)
58 | except StopIteration: pass
59 | timeouts = [timeout(n_sec, showNext)]
60 | return timeouts
61 |
62 | class SwitchCall:
63 | def __init__(self, op, op1):
64 | self.flag = False
65 | self.op, self.op1 = op, op1
66 | def __call__(self):
67 | self.op() if self.flag else self.op1()
68 | self.flag = not self.flag
69 |
--------------------------------------------------------------------------------
/hachiko_bapu/FluidSynth.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from .funutils import findLibrary, createLibrary
3 | # DO NOT EDIT
4 | #This file was generated by ./funutils.py fluidsynth.h FluidSynth libfluidsynth-2 libfluidsynth-1 libfluidsynth fluidsynth
5 |
6 | lib_names = ['libfluidsynth-2', 'libfluidsynth-1', 'libfluidsynth', 'fluidsynth']
7 | lib_path = findLibrary('FluidSynth', lib_names)
8 | cfunc = createLibrary(lib_path)
9 |
10 | from ctypes import c_char_p, c_double, c_int, c_void_p
11 |
12 | new_fluid_settings = cfunc('new_fluid_settings', c_void_p)
13 | delete_fluid_settings = cfunc('delete_fluid_settings', None,
14 | ('settings', c_void_p))
15 | new_fluid_synth = cfunc('new_fluid_synth', c_void_p,
16 | ('settings', c_void_p))
17 | delete_fluid_synth = cfunc('delete_fluid_synth', None,
18 | ('synth', c_void_p))
19 | fluid_settings_setstr = cfunc('fluid_settings_setstr', c_int,
20 | ('settings', c_void_p), ('name', c_char_p), ('str', c_char_p))
21 | fluid_settings_setnum = cfunc('fluid_settings_setnum', c_int,
22 | ('settings', c_void_p), ('name', c_char_p), ('val', c_double))
23 | fluid_settings_setint = cfunc('fluid_settings_setint', c_int,
24 | ('settings', c_void_p), ('name', c_char_p), ('val', c_int))
25 | fluid_synth_sfload = cfunc('fluid_synth_sfload', c_int,
26 | ('synth', c_void_p), ('filename', c_char_p), ('update_midi_presets', c_int))
27 | fluid_synth_sfunload = cfunc('fluid_synth_sfunload', c_int,
28 | ('synth', c_void_p), ('sfid', c_int), ('update_midi_presets', c_int))
29 | fluid_synth_program_select = cfunc('fluid_synth_program_select', c_int,
30 | ('synth', c_void_p), ('chan', c_int), ('sfid', c_int), ('bank', c_int), ('preset', c_int))
31 | fluid_synth_noteon = cfunc('fluid_synth_noteon', c_int,
32 | ('synth', c_void_p), ('chan', c_int), ('key', c_int), ('vel', c_int))
33 | fluid_synth_noteoff = cfunc('fluid_synth_noteoff', c_int,
34 | ('synth', c_void_p), ('chan', c_int), ('key', c_int))
35 | fluid_synth_write_s16 = cfunc('fluid_synth_write_s16', None,
36 | ('synth', c_void_p), ('len', c_int), ('lbuf', c_void_p), ('loff', c_int), ('lincr', c_int), ('rbuf', c_void_p), ('roff', c_int), ('rincl', c_int))
37 | new_fluid_audio_driver = cfunc('new_fluid_audio_driver', c_void_p,
38 | ('settings', c_void_p), ('synth', c_void_p))
39 | delete_fluid_audio_driver = cfunc('delete_fluid_audio_driver', None,
40 | ('driver', c_void_p))
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
131 | # Project specified
132 | *.srt
133 | *.mid
134 | *.mp4
135 | *.ogg
136 | *.quicktime
137 | *.srt~
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hachiko [](https://pypi.org/project/hachiko-bapu/) [](https://github.com/duangsuse-valid-projects/Hachiko/wiki)
2 |
3 |
4 |

5 |
6 |
7 | Simple tool for creating pitch timeline, this program divides midi creation into `pitches` and `timeline` part.
8 |
9 | When creating timeline, press A to give position/duration information, and use S to split different notes directly when holding A
10 |
11 | ```python
12 | # Twinkle Little Star
13 | [45, 45, 52, 52, 54, 54, 52, 50, 50, 49, 49, 47, 47, 45, 52, 52, 50, 50, 49, 49, 47, 52, 52, 50, 50, 49, 49, 47, 45, 45, 52, 52, 54, 54, 52, 50, 50, 49, 49, 47, 47, 45]
14 | ```
15 |
16 | > Tip: press K in pitch window, and input code in console (__it's recommended to launch this application in console__)
17 |
18 | The name of the project is *Hachiko*, inspired by the golden yellow Akita dog - ハチ公, which is in homophonic with "扒公" (means melody extraction "耳 Copy" or "扒谱").
19 |
20 | ## Installing
21 |
22 | ~~There's no need for system-wide installation, just use the script `hachi.py`~~
23 |
24 | Since version 1.1, setting up the installation using `setuptools` is required.
25 |
26 | > For Windows installation guide, please read [this wiki](https://github.com/duangsuse-valid-projects/Hachiko/wiki/Windows-Installation)
27 |
28 | ```bash
29 | python3 setup.py install # can be --user or sudo
30 | ```
31 |
32 | or get it from PyPI:
33 |
34 | ```bash
35 | pip3 install hachiko-bapu
36 | #v or, for the latest version
37 | pip3 install --upgrade git+https://github.com/duangsuse-valid-projects/Hachiko
38 | ```
39 |
40 | System library [FluidSynth](https://github.com/FluidSynth/fluidsynth) is required to run this application.
41 |
42 | ### Old installation-free version
43 |
44 | See [release 1.0](https://github.com/duangsuse-valid-projects/Hachiko/releases/tag/v1.0), get a zip(or tar) and uncompress:
45 |
46 | ```bash
47 | pip install --user -r requirements.txt
48 | python3 hachi.py
49 | # use midnotes.py to replace srt2mid.py print-notes
50 | ```
51 |
52 | ## UI Control / Basic Routine
53 |
54 | Hachiko is self documented, so just use the program.
55 |
56 | ```bash
57 | hachiko -h
58 | ```
59 |
60 | > NOTE: For the first time using GUI, you can spend more time learning hot keys
61 |
62 | Once `puzi.srt` is generated, you can use `srt2mid puzi.srt` to transform it into MIDI file
63 |
64 | Btw, you can use pitches from extrenal tool (like [Synthesizer V Editor](https://synthesizerv.com/) or [MidiEditor](https://www.midieditor.org/)) extracted by `srt2mid print-notes puzi.mid` instead of built-in approach
65 |
66 | Btw, there's also an option to use [MELODIA Algorithm](https://github.com/duangsuse-valid-projects/audio_to_midi_melodia) to extract pitches directly from music
67 |
68 | ## Tool `srt2mid` and `lrc_merge`
69 |
70 | [srt2mid.py](hachiko/cli_tools/srt2mid.py) can be used to make conversation between SRT / MIDI File format
71 |
72 | Output filename is determined automatically from input path, and SRT representation of MIDI track will be timeline of integer(note pitch)s.
73 |
74 | The default mode, "from", means "from srt to mid", and when extracting lyrics from mid file you have to use "back-lyrics" instead.
75 |
76 | ```plain
77 | Usage: srt2mid [ from/back/back-lyrics/print-notes ] files...
78 | ```
79 |
80 | [lrc_merge.py](hachiko/cli_tools/lrc_merge.py) can be used to merge words-based lyrics into sentence-based lyrics
81 |
82 | ```plain
83 | usage: lrc_merge [-h] [-dist n_sec] [-min-len n_sec] [-o name] [-sep word_seprator] (path / 'lrc')
84 | ```
85 |
86 | + `dist` max distance for words in same sentence, default `0.8`
87 | + `min-len` min duration for the last word in sentence (only when `lrc` input is used)
88 |
89 | Execute `lrc_merge -h` to see full details
90 |
--------------------------------------------------------------------------------
/hachiko_bapu/cli_tools/srt2mid.py:
--------------------------------------------------------------------------------
1 | #!/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | from typing import List, Iterator, cast
5 |
6 | from datetime import timedelta
7 | from srt import Subtitle, parse as srt_parse, compose as srt_compose
8 | from mido import Message, MetaMessage, MidiFile, MidiTrack
9 |
10 | from sys import getdefaultencoding
11 | from os import environ
12 |
13 | SEC_MS = 1000
14 |
15 | def env(name, transform, default): return transform(environ[name]) if name in environ else default
16 | SINGLE_TRACK = env("SINGLE_TRACK", bool, False)
17 | TICKS_PER_BEAT = env("TICKS_PER_BEAT", int, 500) #480? both for srt->mid and mid<-srt
18 | NOTE_BASE = env("NOTE_BASE", int, 45)
19 |
20 | def transform(srtz:Iterator[Subtitle], is_lyrics:bool) -> MidiFile:
21 | out = MidiFile(charset=getdefaultencoding(), ticks_per_beat=TICKS_PER_BEAT)
22 | track = MidiTrack()
23 | out.tracks.append(track)
24 |
25 | timeof = lambda dt: int(dt.total_seconds()*SEC_MS)
26 | t0 = 0
27 | for srt in srtz:
28 | t1 = timeof(srt.start)
29 | t2 = timeof(srt.end)
30 | if is_lyrics: #v const branches
31 | track.append(MetaMessage("lyrics", text=srt.content, time=t1-t0))
32 | note = NOTE_BASE if is_lyrics else int(srt.content) #< pitch from
33 | track.append(Message("note_on", note=note, time=0 if is_lyrics else t1-t0))
34 | track.append(Message("note_off", note=note, time=t2-t1))
35 | t0 = t2
36 |
37 | return out
38 |
39 | def transformBack(notez:Iterator[Message], is_lyrics:bool, k_time:float) -> List[Subtitle]:
40 | out = []
41 | def read(ty, blanks = ["set_tempo"]):
42 | note = next(notez)
43 | if note.type != ty:
44 | if note.type == "end_of_track": raise StopIteration("EOT")
45 | while note.type in blanks: note = next(notez) #< jump off!
46 | if note.type != ty: raise ValueError(f"unexpected msg {note}, expecting {ty}")
47 | return note
48 |
49 | timeof = lambda n: timedelta(seconds=n/k_time)
50 | t_acc = timedelta(); lyric = None
51 | index = 1 #< for subtitles
52 | while True:
53 | try:
54 | if is_lyrics: lyric = read("lyrics")
55 | on = read("note_on")
56 | t_on = timeof(lyric.time if is_lyrics else on.time)
57 | t_off = timeof(read("note_off").time)
58 | except StopIteration: break #v pitch back
59 | out.append(Subtitle(index, t_acc+t_on, t_acc+t_on+t_off, lyric.text if is_lyrics else str(on.note) ))
60 | t_acc += (t_on + t_off)
61 | index += 1
62 |
63 | return out
64 |
65 |
66 | def newPath(f, ext):
67 | return f.name.rsplit(".")[0] + f".{ext}"
68 |
69 | def fromSrtFile(f, is_lyrics):
70 | transform(srt_parse(f.read()), is_lyrics).save(newPath(f, "mid"))
71 |
72 | def backMidFile(f, is_lyrics):
73 | midi = MidiFile(f.name, charset=getdefaultencoding(), ticks_per_beat=TICKS_PER_BEAT)
74 | (notes, k_time) = (cast(MidiTrack, max(midi.tracks, key=len)), SEC_MS) if SINGLE_TRACK else (midi, 1)
75 |
76 | text_srt = srt_compose(transformBack(iter(notes), is_lyrics, float(k_time)))
77 | with open(newPath(f, "srt"), "w+") as srtf: srtf.write(text_srt)
78 |
79 | def midiNotes(path): #< merged from old midnotes.py
80 | for i, track in enumerate(MidiFile(path).tracks):
81 | msgs = [track[index] for index in range(0, len(track), 2)] #note-on -- note-off
82 | for msg in msgs:
83 | note = msg.dict().get("note")
84 | if note != None: yield note
85 |
86 | modes = {
87 | "from": lambda f: fromSrtFile(f, False),
88 | "from-lyrics": lambda f: fromSrtFile(f, True),
89 | "back": lambda f: backMidFile(f, False),
90 | "back-lyrics": lambda f: backMidFile(f, True),
91 | "print-notes": lambda f: print(list(midiNotes(f.name)))
92 | }
93 |
94 | from sys import argv
95 | def main(args = argv[1:]):
96 | if len(args) < 1:
97 | print(f"Usage: srt2mid [ {'/'.join(modes.keys())} ] files...")
98 | return
99 | mname = args[0]
100 | (mode, paths) = (modes["from"], args[0:]) if mname not in modes else (modes[mname], args[1:])
101 | for path in paths:
102 | with open(path, "r") as ins: mode(ins)
103 |
104 | if __name__ == "__main__": main()
105 |
--------------------------------------------------------------------------------
/hachiko_bapu/synthesize.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from typing import Tuple, List
4 |
5 | from ctypes import sizeof, create_string_buffer, c_int16
6 | from .funutils import *
7 | from sf2utils.sf2parse import Sf2File
8 |
9 | try: import numpy
10 | except ImportError: pass
11 |
12 | drivers = ["alsa", "oss", "jack", "pulseaudio", "portaudio", "sndmgr", "coreaudio", "dsound", "waveout"]
13 | platform_drivers = {"linux": "alsa", "windows": "dsound", "macos": "coreaudio"}
14 |
15 | from .funutils import isNonnegative, isInbounds
16 | from .FluidSynth import *
17 |
18 | def fluid_synth_write_s16_stereo(synth, n: int, n_channel=2) -> List[int]:
19 | """Return generated samples in stereo 16-bit format"""
20 | buf = create_string_buffer(n*n_channel*sizeof(c_int16))
21 | fluid_synth_write_s16(synth, n, buf, 0, n_channel, buf, 1, n_channel)
22 | return numpy.frombuffer(buf.raw, dtype=numpy.int16) #origin: copy buf[:]
23 |
24 | class Synth:
25 | """Synth represents a FluidSynth synthesizer"""
26 | def __init__(self, gain=0.2, samplerate=44100, channels=256):
27 | self.settings = new_fluid_settings()
28 | self.synth = new_fluid_synth(self.settings)
29 | for (k, v) in { b"synth.gain": gain,
30 | b"synth.sample-rate": samplerate,
31 | b"synth.midi-channels": channels }.items(): self.setting(k, v)
32 | self.audio_driver = None
33 | def __del__(self):
34 | if self.audio_driver != None: delete_fluid_audio_driver(self.audio_driver)
35 | delete_fluid_synth(self.synth)
36 | delete_fluid_settings(self.settings)
37 |
38 | def setting(self, key, value):
39 | bk = key.encode() if isinstance(key, str) else key
40 | sets = self.settings
41 | if isinstance(value, int):
42 | fluid_settings_setint(sets, bk, value)
43 | elif isinstance(value, float):
44 | fluid_settings_setnum(sets, bk, value)
45 | elif isinstance(value, str):
46 | fluid_settings_setstr(sets, bk, value.encode())
47 |
48 | def sfload(self, filename, update_midi_preset=0) -> int:
49 | return fluid_synth_sfload(self.synth, filename.encode(), update_midi_preset)
50 | def sfunload(self, sfid, update_midi_preset=0):
51 | fluid_synth_sfunload(self.synth, sfid, update_midi_preset)
52 | def program_select(self, chan, sfid, bank, preset):
53 | fluid_synth_program_select(self.synth, chan, sfid, bank, preset)
54 |
55 | def noteon(self, chan, key, vel=127):
56 | require(chan, isNonnegative, "bad channel")
57 | require(key, isInbounds(0, 128), "bad key")
58 | require(vel, isInbounds(0, 128), "bad velocity")
59 | fluid_synth_noteon(self.synth, chan, key, vel)
60 | def noteoff(self, chan, key):
61 | require(chan, isNonnegative, "bad channel")
62 | require(key, isInbounds(0, 128), "bad key")
63 | fluid_synth_noteoff(self.synth, chan, key)
64 |
65 | def start(self, driver=platform_drivers[platform()], device=None):
66 | """driver could be any str in drivers"""
67 | require(driver, drivers.__contains__, "unsupported driver")
68 | self.setting(b"audio.driver", driver)
69 | if device is not None: self.setting(f"audio.{driver}.device", device)
70 | self.audio_driver = new_fluid_audio_driver(self.settings, self.synth)
71 | def get_samples(self, n=1024) -> List[int]:
72 | """Generate audio samples
73 | Returns ndarray containing n audio samples.
74 | If synth is set to stereo output(default) the array will be size 2*n.
75 | """
76 | return fluid_synth_write_s16_stereo(self.synth, n)
77 |
78 | class NoteSynth(Synth):
79 | def __init__(self, sample_rate):
80 | super().__init__(samplerate=sample_rate)
81 | self.sample_rate = sample_rate
82 | self.last_pitch = (-1)
83 | @staticmethod
84 | def getFontPresets(path_sfont) -> List[Tuple[int, int, str]]:
85 | with open(path_sfont, "rb") as fbuf:
86 | sf = Sf2File(fbuf) #< parse sf2
87 | return [(p.bank, p.preset, p.name) for p in sf.build_presets() if len(p.bags) != 0]
88 |
89 | def setFont(self, path_sfont, idx_preset=0):
90 | presets = NoteSynth.getFontPresets(path_sfont)
91 | require(presets, hasIndex(idx_preset), "preset outbounds")
92 | preset = presets[idx_preset]
93 | (bank, patch, _) = preset
94 | self.program_select(0, self.sfload(path_sfont), bank, patch)
95 |
96 | def noteon(self, pitch): return super().noteon(0, pitch)
97 | def noteoff(self, pitch=None):
98 | if pitch != None: super().noteoff(0, pitch)
99 | else: super().noteoff(0, self.last_pitch)
100 | def noteSwitch(self, pitch):
101 | if self.last_pitch != (-1):
102 | self.noteoff()
103 | self.noteon(pitch)
104 | self.last_pitch = pitch
105 |
106 | def sampleNote(self, n_sec) -> List[int]:
107 | return self.get_samples(self.sample_rate*n_sec)
108 |
--------------------------------------------------------------------------------
/hachiko_bapu/funutils.py:
--------------------------------------------------------------------------------
1 | #!/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | from ctypes import CDLL, CFUNCTYPE
5 | from ctypes.util import find_library
6 |
7 | from sys import platform as sys_platform
8 | from os.path import isfile, abspath
9 | from itertools import chain
10 |
11 | def let(transform, value):
12 | return transform(value) if value != None else value
13 |
14 | def require(value, p, message):
15 | if not p(value): raise ValueError(f"{message}: {value}")
16 |
17 | isNotNone = lambda it: it != None
18 | isNonnegative = lambda it: it >= 0
19 | def isInbounds(start, stop):
20 | return lambda it: it in range(start, stop)
21 |
22 | def hasIndex(i):
23 | return lambda xs: i in range(0, len(xs))
24 |
25 | def flatMap(f, xs):
26 | for ys in map(f, xs):
27 | for y in ys: yield y
28 |
29 | def takePipeIf(p, transform, value):
30 | res = transform(value)
31 | return res if p(res) else value
32 |
33 | def findLibrary(name, lib_names, solver=lambda name: [f"./{name}.dll"]) -> str:
34 | paths = filter(isNotNone, map(find_library, lib_names))
35 | for dlls in map(solver, lib_names):
36 | paths = chain(paths, filter(isfile, dlls))
37 |
38 | path = next(paths, None) #< appended dll scan
39 | if path == None: raise ImportError(f"couldn't find the {name} library")
40 | else: return takePipeIf(isfile, abspath, str(path)) #< only if found file, not libname in PATH
41 |
42 | def createLibrary(path, mode = 1):
43 | lib = CDLL(path)
44 | def cfunc(name, t_result, *args):
45 | t_args = tuple(arg[1] for arg in args)
46 | extras = tuple((mode, arg[0]) for arg in args)
47 | return CFUNCTYPE(t_result, *t_args)((name, lib), extras)
48 | return cfunc
49 |
50 | def platform(opts = {"linux": ["linux"], "windows": ["win", "cygwin"], "macos": ["darwin"]}):
51 | for (v, ks) in opts.items():
52 | for k in ks:
53 | if sys_platform.lower().startswith(k): return v
54 | raise ValueError(f"unsupported platform: {sys_platform}")
55 |
56 |
57 | global cdecls #v define a subset of C Header
58 | if __name__ == "__main__":
59 | import pyparsing as pas
60 | LBRACE, RBRACE, SEMI = map(pas.Suppress, "();")
61 | identifier = pas.Word(pas.alphas, pas.alphanums + "_")
62 | typename = pas.Word(identifier.bodyCharsOrig + "*")
63 | cdecl_arg = typename("type") + identifier("name")
64 | cdecl = typename("t_result") + identifier("fname") +LBRACE+ pas.Optional(pas.delimitedList(cdecl_arg)) +RBRACE+SEMI
65 | cdecls = pas.ZeroOrMore(pas.Group(cdecl))
66 |
67 | ctype = {
68 | "void": "None", "void*": "c_void_p", "char*": "c_char_p",
69 | "int": "c_int", "double": "c_double"
70 | }.__getitem__
71 | def post_cdecl(m):
72 | """t_result fname(type name, ...args);"""
73 | t_result, fname = m[0:2]
74 | if len(m) == 2: return (fname, ctype(t_result), [])
75 | args = [(m[i+1], ctype(m[i])) for i in range(2, len(m), 2)]
76 | return (fname, ctype(t_result), args)
77 |
78 | from sys import argv
79 | from os.path import isfile
80 |
81 | def isInsideModule(): return isfile("__init__.py")
82 |
83 | def codegen(path_dst, path_header, name, lib_names):
84 | output = open(path_dst, "w+", encoding="utf-8")
85 | def line(text = ""): output.write(text); output.write("\n")
86 |
87 | def preheader():
88 | pkg_path = ".funutils" if isInsideModule() else "funutils"
89 | line("# -*- coding: utf-8 -*-")
90 | line(f"from {pkg_path} import findLibrary, createLibrary")
91 | line("# DO NOT EDIT"); line(f"#This file was generated by {' '.join(argv)}")
92 | def libdefs():
93 | line(f"lib_names = {repr(lib_names)}")
94 | line(f"lib_path = findLibrary({repr(name)}, lib_names)")
95 | line(f"cfunc = createLibrary(lib_path)")
96 | def cimport(decls):
97 | type_refs = flatMap(lambda it: [it[1]] + [arg[1] for arg in it[2]], decls)
98 | imports = set(filter(lambda it: it.startswith("c_"), type_refs))
99 | imports_code = f"from ctypes import {', '.join(sorted(imports))}"
100 | line(imports_code); print(imports_code)
101 | def cdefs(decls):
102 | for decl in decls:
103 | rest = "" if len(decl[2]) == 0 else ",\n " + ", ".join([f"({repr(name)}, {ty})" for (name, ty) in decl[2]])
104 | line(f"{decl[0]} = cfunc({repr(decl[0])}, {decl[1]}{rest})")
105 |
106 | preheader()
107 | line()
108 | libdefs()
109 | line()
110 | with open(path_header, "r") as header:
111 | decls = list(map(post_cdecl, cdecls.parseFile(header)))
112 | cimport(decls)
113 | line()
114 | cdefs(decls)
115 | output.close()
116 |
117 | def main(argv = argv):
118 | if len(argv) < 4:
119 | print(f"Usage: {argv[0]} header name lib_names")
120 | return
121 | header, name = argv[1:3]
122 | codegen(f"{name}.py", header, name, argv[3:])
123 |
124 | if __name__ == '__main__': main()
125 |
--------------------------------------------------------------------------------
/hachiko_bapu/cli_tools/lrc_merge.py:
--------------------------------------------------------------------------------
1 | #!/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | '''
5 | This tool can convert ungrouped lrc stream to List[List[LrcNote]]
6 | LrcNote is Tuple[int, str] (seconds, content)
7 | ...wait, it's actually Subtitle(no, start=seconds, end, content)
8 |
9 | Model: Lrc / LrcLines(Subtitle[]) / Srt
10 |
11 | read: str -> Lrc; dump: LrcLines -> str;
12 | str (into)<=>(from) LrcLines
13 | '''
14 |
15 | from datetime import timedelta
16 | from srt import Subtitle, compose
17 | from srt import parse as fromSrt
18 |
19 | from os import linesep
20 |
21 | def require(value, p, msg = "bad"):
22 | if not p(value): raise ValueError(f"{msg}: {value}")
23 |
24 | def zipWithNext(xs):
25 | require(xs, lambda it: len(it) > 2, "must >2")
26 | for i in range(1, len(xs)):
27 | yield (xs[i-1], xs[i])
28 |
29 | def zipTakeWhile(predicate, xs):
30 | require(xs, lambda it: len(it) > 1)
31 | col = [xs[0]]
32 | for (a, b) in zipWithNext(xs):
33 | if not predicate(a, b):
34 | yield col
35 | col = []
36 | col.append(b)
37 | yield col #< even predicate matches
38 |
39 | def flatMap(transform, xs):
40 | res = []
41 | for ys in map(transform, xs):
42 | for y in ys: res.append(y)
43 | return res
44 |
45 | def map2D(f, xss):
46 | ''' map [[a]] with function f '''
47 | return map(lambda xs: [f(x) for x in xs], xss)
48 |
49 | def cfgOrDefault(value, f, x):
50 | return value if value != None else f(x)
51 |
52 |
53 | from re import compile
54 | PAT_LRC_ENTRY = compile(r"[\[<](\d{2}):(\d{2}).(\d{2,3})[>\]] ?([^<\n]*)")
55 |
56 | sepDeft = lambda line: ("" if all(map(lambda w: len(w) == 1, line)) else " ")
57 |
58 | def readLrc(text):
59 | def readEntry(g): return (int(g[0])*60 + int(g[1]) + int(g[2].zfill(3)) / 1000, g[3]) # [mm:ss.xx] content
60 | return [readEntry(e) for e in PAT_LRC_ENTRY.findall(text)]
61 |
62 | def dumpLrc(lrc_lines, sep = None, surr1 = "[]", surr2 = "<>"):
63 | def header(t, surr): return "%s%02i:%02i.%02i%s" %(surr[0], t/60, t%60, t%1.0 * 100, surr[1])
64 | def formatLine(line):
65 | (t_fst, s_fst) = line[0]
66 | fmtFst = header(t_fst, surr1)+s_fst
67 | sep1 = cfgOrDefault(sep, sepDeft, map(lambda note: note[1], line))
68 | return fmtFst +sep1+ sep1.join([header(t, surr2) + s for (t, s) in line[1:]])
69 | return linesep.join(map(formatLine, lrc_lines))
70 |
71 |
72 | def fromLrc(text, min_len):
73 | td = lambda t: timedelta(seconds=t)
74 | return [Subtitle(i+1, td(t), td(t+min_len), s) for i, (t, s) in enumerate(readLrc(text))]
75 |
76 | def intoLrc(lines, sep=None): #v use join folding in dumpLrc
77 | return dumpLrc(map2D(lambda srt: (srt.start.total_seconds(), srt.content), lines), sep)
78 |
79 | def intoSrt(srts, sep=None):
80 | def newContent(line):
81 | words = [srt.content for srt in line]
82 | return cfgOrDefault(sep, sepDeft, words).join(words)
83 | time = lambda it: it.start
84 | return [Subtitle(i+1, min(line, key=time).start, min(T1[0].start,max(line, key=time).end), newContent(line)) for ((i, line),(_,T1)) in zipWithNext(list(enumerate(srts)))] #clip "), ".")
89 |
90 | from sys import argv
91 | def main(args = argv[1:]):
92 | from argparse import ArgumentParser
93 | app = ArgumentParser("lrc_merge",
94 | description="merge simple timeline LRC into line-splited LRC",
95 | epilog="if the result is truncated, try to split your input in lines")
96 | app.add_argument("-dist", type=float, default=0.8, help="max distance for words in same sentence")
97 | app.add_argument("-min-len", type=float, default=0.0, help="min duration for last word in sentence (LRC only)")
98 | app.add_argument("-o", type=str, default="a.srt", help="ouput SRT file")
99 | app.add_argument("-sep", type=str, default=None, help="word seprator (or decided automatically from sentence)")
100 | app.add_argument("file", type=str, help="input SRT file (or 'lrc' and input from stdin)")
101 |
102 | cfg = app.parse_args(args)
103 | use_lrc = cfg.file == "lrc"
104 | inSameLine = lambda a, b: abs((a.start if use_lrc else a.end) - b.start).total_seconds() < cfg.dist
105 |
106 | #v regex findall has input size limitations...
107 | data = list(flatMap(lambda t: fromLrc(t, cfg.min_len), readLines("lrc")) if use_lrc else fromSrt(open(cfg.file).read()))
108 | print(" ".join([f"{srt.start.total_seconds()};{srt.content}" for srt in data]))
109 |
110 | print("== lyrics")
111 | result = list(zipTakeWhile(inSameLine, data) )
112 | print(intoLrc(result, cfg.sep))
113 |
114 | with open(cfg.o, "w+") as srtf:
115 | srtf.write("".join(x.to_srt()for x in intoSrt(result, cfg.sep)))
116 |
117 | #netease http://lrc.opqnext.com/editor/1842025914
118 | if __name__ == "__main__": main()
119 |
--------------------------------------------------------------------------------
/hachiko_bapu/hachi_groups.py:
--------------------------------------------------------------------------------
1 | from argparse import ArgumentParser, FileType
2 | from json import loads, dumps
3 |
4 |
5 | from .tkgui_utils import startFile, Backend
6 | from .tkgui_utils import guiCodegen as c
7 | Backend.TTk.use()
8 |
9 | from .tkgui import TkGUI, TkWin, MenuItem, TreeWidget, nop, Timeout, callThreadSafe, thunkifySync, delay, runAsync, rescueWidgetOption
10 | from tkinter import Menu
11 |
12 | import threading, time, requests
13 | import os
14 |
15 | app = ArgumentParser(prog="hachi-groups", description="GUI tool for recording lyric sentences with hachi")
16 | app.add_argument("music", type=FileType("r"), help="music BGM to play")
17 | app.add_argument("-seek-minus", type=float, default=3.0, help="back-seek before playing the sentence")
18 | app.add_argument("-mix-multi", action="store_true", default=False, help="give multi-track mix")
19 | app.add_argument("-o", type=str, default="mix.mid", help="mixed output file")
20 | app.add_argument("-replay", type=FileType("r"), default=None, help="MIDI File to replay")
21 | app.add_argument("-import", type=str, default=None, help="import a sentence list")
22 |
23 | #GUI: ($lyric @ $n s .Rec-Edit .Play)[] (input-lyric @ input-n s .Add .Remove_Last) (input-JSON .Mix .Delete .Export) (-) ($music) (slider-volume)
24 | rescueWidgetOption["relief"] = lambda _: None
25 |
26 | class GUI(TkGUI):
27 | def up(self):
28 | self.a.set("wtf")
29 | self.ui.removeChild(self.ui.lastChild)
30 | GUI.ThreadDemo().run("Thread Demo")
31 | def pr(self):
32 | print(self.c.get())
33 | self.ui.removeChild(self.ui.childs[5])
34 | def layout(self):
35 | _ = self.underscore
36 | c.setAttr(self, "a", _.var(str, "some"))
37 | c.setAttr(self, "b", _.var(bool))
38 | c.setAttr(self, "c", _.var(int))
39 | def addChild(): self.ui.appendChild(_.text("hhh"))
40 | return _.verticalLayout(
41 | _.button("Yes", self.quit),
42 | _.text(self.a),
43 | _.button("Change", self.up),
44 | _.horizontalLayout(_.text("ex"), _.text("wtf"), _.button("emmm",addChild), _.text("aa")),
45 | _.input("hel"),
46 | _.separator(),
47 | _.withScroll(_.vert, _.by("ta", _.textarea("wtf"))),
48 | _.by("ah", _.text("ah")),
49 | _.checkBox("Some", self.b),
50 | _.horizontalLayout(_.radioButton("Wtf", self.c, 1, self.pr), _.radioButton("emm", self.c, 2, self.pr)),
51 | _.horizontalLayout(
52 | _.by("sbar", _.scrollBar(_.vert)),
53 | _.verticalLayout(
54 | _.by("lbox", _.listBox(("1 2 3 apple juicy lamb clamp banana "*20).split(" "))),
55 | _.by("hsbar", _.scrollBar(_.hor))
56 | )
57 | ),
58 | _.withScroll(_.both, _.by("box", _.listBox(("1 2 3 apple juicy lamb clamp banana "*20).split(" ")))),
59 | _.comboBox(self.a, "hello cruel world".split(" ")),
60 | _.spinBox(range(0, 100+1, 10)),
61 | _.slider(range(0, 100+1, 2), orient=_.hor),
62 | _.button("hello", self.run1),
63 | _.button("split", self.run2),
64 | _.menuButton("kind", _.menu(MenuItem.CheckBox("wtf", self.b), MenuItem.RadioButton("emm", self.c, 9)), relief=_.raised),
65 | _.labeledBox("emmm", _.button("Dangerous", self.run3))
66 | )
67 | def run1(self): GUI.Layout1().run("Hello")
68 | def run2(self): a=GUI.SplitWin(); a.runCode(a.getCode()) #.run("Split")
69 | def run3(self): print(self.ta.marker["insert"])
70 | def setup(self):
71 | _ = self.underscore
72 | _.bindYScrollBar(self.lbox, self.sbar)
73 | _.bindXScrollBar(self.lbox, self.hsbar)
74 | themes = self.listThemes()
75 | themez = iter(themes)
76 | self.ah["text"] = ",".join(themes)
77 | def nextTheme(event):
78 | nonlocal themez
79 | try: self.theme = next(themez)
80 | except StopIteration:
81 | themez = iter(themes)
82 | self.ah.bind(_.Events.click, nextTheme)
83 | self.ah.bind(_.Events.mouseR, _.makeMenuPopup(_.menu(*[MenuItem.named(it, nop) for it in "Cut Copy Paste Reload".split(" ")], MenuItem.sep, MenuItem.named("Rename", nop))))
84 | self.initLooper()
85 |
86 | class Layout1(TkWin):
87 | def layout(self):
88 | _ = self.underscore
89 | return _.verticalLayout(
90 | _.text("Hello world"),
91 | _.by("can", _.canvas((250, 300)))
92 | )
93 | def setup(self):
94 | menubar = self.menu(self.tk,
95 | MenuItem.named("New", nop),
96 | MenuItem.named("Open", lambda: GUI.DoNothing().run("x")),
97 | MenuItem.SubMenu("Help", [MenuItem.named("Index...", nop), MenuItem.sep, MenuItem.named("About", nop)])
98 | )
99 | self.setMenu(menubar)
100 | self.setSizeBounds((200,100))
101 | self.addSizeGrip()
102 | self.can["bg"] = "blue"
103 | coord = (10, 50, 240, 210)
104 | self.can.create_arc(coord, start=0, extent=150, fill="red")
105 | class SplitWin(TkWin):
106 | def layout(self):
107 | _ = self.underscore
108 | return _.withFill(_.splitter(_.hor,
109 | _.text("left pane"),
110 | _.splitter(_.vert,
111 | _.text("top pane"),
112 | _.text("bottom pane")
113 | )
114 | ))
115 | class DoNothing(TkWin):
116 | def __init__(self):
117 | super().__init__()
118 | self.nodes = dict()
119 | self.ftv:TreeWidget
120 | def layout(self):
121 | _ = self.underscore
122 | return _.withFill(_.tabWidget(
123 | ("Tab 1", _.text("a")),
124 | ("Tab 2", _.verticalLayout(_.text("Lets dive into the world of computers"))),
125 | ("TabTree", _.by("tv", _.treeWidget())),
126 | ("File Man", _.by("ftv", _.treeWidget()))
127 | ))
128 | def setup(self):
129 | self.tv.makeTree(["Name", "Desc"], [
130 | "GeeksforGeeks",
131 | ("Computer Science", [
132 | ["Algorithm", "too hard"],
133 | ["Data structure", "just right"]
134 | ]),
135 | ("GATE papers", [
136 | "2018", "2019"
137 | ]),
138 | ("Programming Languages", [
139 | "Python", "Java"
140 | ])
141 | ])
142 | self.tv.item("GATE papers").moveTo("GeeksforGeeks")
143 | abspath = os.path.abspath(".")
144 | #self.ftv.makeTree(["Name"], [(abspath, ["a"])])
145 | self.ftv.heading('#0', text='Project tree', anchor='w')
146 | self.insertNode(self.ftv.rootItem, abspath, abspath)
147 | self.ftv.bind(TreeWidget.onOpen.name, self.openNode)
148 | def insertNode(self, parent, text, abspath):
149 | node = parent.addChild(text)
150 | if os.path.isdir(abspath):
151 | self.nodes[node] = abspath
152 | node.addChild(None)
153 | def openNode(self, event):
154 | node = self.ftv.focusItem
155 | abspath = self.nodes.pop(node, None)
156 | if abspath:
157 | print(abspath)
158 | node.removeChilds()
159 | for p in os.listdir(abspath):
160 | self.insertNode(node, p, os.path.join(abspath, p))
161 | else: startFile(node.id)
162 | class ThreadDemo(TkWin):
163 | def __init__(self):
164 | super().__init__()
165 | self.ta = None
166 | _ = self.underscore
167 | self.active = _.var(str)
168 | self.confirmed = _.var(str)
169 | def layout(self):
170 | _ = self.underscore
171 | return _.verticalLayout(
172 | _.by("ta", _.textarea()),
173 | _.createLayout(_.hor, 0, _.text("Total active cases: ~"), _.text(self.active)),
174 | _.createLayout(_.vert, 0, _.text("Total confirmed cases:"), _.text(self.confirmed)),
175 | _.button("Refresh", self.on_refresh)
176 | )
177 | url = "https://api.covid19india.org/data.json"
178 | def on_refresh(self):
179 | runAsync(thunkifySync(requests.get, self.url), self.on_refreshed)
180 | runAsync(delay(1000), lambda ms: self.ta.insert("end", "233"))
181 |
182 | def on_refreshed(self, page):
183 | data = loads(page.text)
184 | #print(data)
185 | self.active.set(data["statewise"][0]["active"])
186 | self.confirmed.set(data["statewise"][0]["confirmed"])
187 | self.btn_refresh["text"] = "Data refreshed"
188 | def setup(self):
189 | self.setSizeBounds((220, 70))
190 | threading.Thread(target=self.thread_target).start()
191 | def thread_target(self):
192 | callThreadSafe(lambda: self.setSize(self.size, (0,0)))
193 | def addText(text): callThreadSafe(lambda: self.ta.insert("end", text))
194 | addText('doing things...\n')
195 | time.sleep(1)
196 | addText('doing more things...\n')
197 | time.sleep(2)
198 | addText('done')
199 |
200 | from sys import argv
201 | from .tkgui_utils import Codegen
202 | def main(args = argv[1:]):
203 | cfg = app.parse_args(args)
204 | gui = GUI()
205 | #gui.run("Application")
206 | Codegen.useDebug = True
207 | gui.runCode(gui.getCode(False), GUI=gui, TkGUI=gui)
208 |
--------------------------------------------------------------------------------
/hachiko_bapu/hachi.py:
--------------------------------------------------------------------------------
1 | #!/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | from typing import Any, Callable, Optional, TypeVar, Generic
5 | A = TypeVar("A"); T = TypeVar("T")
6 |
7 | from argparse import ArgumentParser, FileType
8 | from time import time
9 | from datetime import timedelta
10 |
11 | from srt import Subtitle, compose
12 | from json import loads, dumps, JSONDecodeError
13 |
14 | from os import environ, system #v disable prompt
15 | environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
16 | import pygame
17 |
18 | from .hachitools import *
19 | from .synthesize import NoteSynth
20 | from pkg_resources import resource_filename
21 | from .funutils import let
22 |
23 | def splitAs(type, transform = int, delim = ","):
24 | return lambda it: type(transform(s) for s in it.split(delim))
25 |
26 | WINDOW_DIMEN = env("DIMEN", splitAs(tuple), (300,300))
27 | NAME_STDOUT = "-"
28 |
29 | backgroundColor = env("COLOR_BACK", htmlColor, grayColor(0x30))
30 | textColor = env("COLOR_TEXT", htmlColor, grayColor(0xfa))
31 | fontName = env("FONT_NAME", str, "Arial")
32 | fontSize = env("FONT_SIZE", int, 36)
33 |
34 | askMethod = env("ASK_METHOD", str, "tk")
35 | playDuration = env("PLAY_DURATION", splitAs(list, transform=float), [0.3, 0.5, 1.5])
36 | cmdOnDone = env("HACHIKO_DONE", str, "srt2mid out")
37 |
38 | INSTRUMENT_SF2 = env("SFONT", str, resource_filename(__name__, "instrument.sf2"))
39 | sampleRate = env("SAMPLE_RATE", int, 44100)
40 | synth = NoteSynth(sampleRate) #< used twice
41 |
42 | bgmVolume = env("BGM_VOLUME", float, None)
43 | bgmSpeed = env("BGM_SPEED", float, None) #TODO
44 |
45 | OCTAVE_NAMES = ["C","Cs","D","Ds","E","F","Fs","G","Gs","A","As","B"]
46 | OCTAVE_MAX_VALUE = 12
47 |
48 | BLACK_KEYS = [1, 3, 6, 8, 10]
49 |
50 | from string import digits
51 | def dumpOctave(pitch):
52 | octave, n = divmod(pitch, OCTAVE_MAX_VALUE)
53 | return f"{OCTAVE_NAMES[octave]}_{n} " + ("b" if n in BLACK_KEYS else "") + f"'{pitch}"
54 | def readOctave(octa):
55 | octave, n = octa.rstrip(f"b'{digits}").split("_")
56 | return OCTAVE_NAMES.index(octave)*OCTAVE_MAX_VALUE + int(n)
57 |
58 | def blockingAskThen(onDone:Callable[[T], Any], name:str, transform:Callable[[str],T], placeholder:Optional[str] = None):
59 | if askMethod == "input":
60 | if placeholder != None: print(placeholder, file=stderr)
61 | answer = input(f"{name}?> ")
62 | if answer != "": onDone(transform(answer))
63 | elif askMethod == "tk":
64 | from tkinter import Tk
65 | from tkinter.simpledialog import askstring
66 | tk = Tk(); tk.withdraw() #< born hidden
67 |
68 | answer = askstring(f"Asking for {name}", f"Please input {name}:", initialvalue=placeholder or "")
69 | tk.destroy()
70 | if answer != None: onDone(transform(answer))
71 | else: raise ValueError(f"unknown asking method {askMethod}")
72 |
73 | app = ArgumentParser(prog="hachi", description="Simple tool for creating pitch timeline",
74 | epilog="In pitch window, [0-9] select pitch; [Enter] add; [Backspace] remove last\n"+
75 | f"Useful env-vars: SAMPLE_RATE, BGM_VOLUME, SFONT (sf2 path), ASK_METHOD (tk/input); pygame {pygame.ver}")
76 | app.add_argument("-note-base", type=int, default=45, help="pitch base number")
77 | app.add_argument("-note-preset", type=int, default=0, help=f"SoundFont ({INSTRUMENT_SF2}) preset index, count from 0")
78 | app.add_argument("-seq", type=str, default=None, help="sequence given in pitch editor window")
79 | app.add_argument("-play", type=FileType("r"), default=None, help="music file used for playing")
80 | app.add_argument("-play-seek", type=float, default=0.0, help="initial seek for player")
81 | app.add_argument("-o", type=str, default="puzi.srt", help="output subtitle file path (default puzi.srt, can be - for stdout)")
82 |
83 | class ActionHandler(Generic[A, T]):
84 | def actions(self, ctx:A, key:T): pass
85 |
86 | class RecordKeys(AsList, ActionHandler[RefUpdate, str]):
87 | def actions(self, ctx, key):
88 | if key == '\x08': # bool:
243 | """Run the connected callbacks(ignore result) and print errors. If one callback requested [stopChain], return False"""
244 | for (op, args, kwargs, stack_info) in self._callbacks:
245 | try: op(*args, **kwargs)
246 | except EventCallback.CallbackBreak: return False
247 | except Exception:
248 | # it's important that this does NOT call sys.stderr.write directly
249 | # because sys.stderr is None when running in windows, None.write is error
250 | (trace, rest) = traceback.format_exc().split("\n", 1)
251 | print(trace, file=stderr)
252 | print(stack_info+rest, end="", file=stderr)
253 | break
254 | return True
255 |
256 | class FutureResult:
257 | '''pending operation result, use [getValue] / [getValueOr] to wait'''
258 | def __init__(self):
259 | self._cond = threading.Event()
260 | self._value = None
261 | self._error = None
262 |
263 | def setValue(self, value):
264 | self._value = value
265 | self._cond.set()
266 |
267 | def setError(self, exc):
268 | self._error = exc
269 | self._cond.set()
270 |
271 | def getValueOr(self, on_error):
272 | self._cond.wait()
273 | if self._error != None: on_error(self._error)
274 | return self._value
275 | def getValue(self): return self.getValueOr(FutureResult.rethrow)
276 | def fold(self, done, fail):
277 | self._cond.wait()
278 | return done(self._value) if self._error == None else fail(self._error)
279 | @staticmethod
280 | def rethrow(ex): raise ex
281 |
282 | class EventPoller:
283 | '''after-event loop operation dispatcher for Tk'''
284 | def __init__(self):
285 | assert threading.current_thread() is threading.main_thread()
286 | self._main_thread_ident = threading.get_ident() #< faster than threading.current_thread()
287 | self._init_looper_done = False
288 | self._call_queue = queue.Queue() # (func, args, kwargs, future)
289 | self.tk:Tk; self.on_quit:EventCallback
290 | def isThreadMain(self): return threading.get_ident() == self._main_thread_ident
291 | def initLooper(self, poll_interval_ms=(1_000//20) ):
292 | assert self.isThreadMain(), MSG_CALL_FROM_THR_MAIN
293 | assert not self._init_looper_done, MSG_CALLED_TWICE #< there is a race condition, but just ignore this
294 |
295 | timer_id = None
296 | def poller():
297 | nonlocal timer_id
298 | while True:
299 | try: item = self._call_queue.get(block=False)
300 | except queue.Empty: break
301 |
302 | (func, args, kwargs, future) = item
303 | try: value = func(*args, **kwargs)
304 | except Exception as ex: future.setError(ex)
305 | else: future.setValue(value)
306 |
307 | timer_id = self.tk.tk.call("after", poll_interval_ms, TCL_CMD_POLLER)
308 | self.tk.tk.createcommand(TCL_CMD_POLLER, poller)
309 |
310 | def quit_cancel_poller():
311 | if timer_id != None: self.tk.after_cancel(timer_id)
312 |
313 | self.on_quit += quit_cancel_poller
314 |
315 | poller()
316 | self._init_looper_done = True
317 |
318 | def callThreadSafe(self, op, args, kwargs) -> FutureResult:
319 | if self.isThreadMain():
320 | return op(*args, **kwargs)
321 |
322 | if not self._init_looper_done: raise NOT_THREADSAFE
323 |
324 | future = FutureResult()
325 | self._call_queue.put((op, args, kwargs, future))
326 | return future
327 |
328 |
329 | # boilerplates
330 | class id_dict(dict):
331 | '''try to store objects, use its identity to force store unhashable types'''
332 | def get(self, key): return super().get(id(key))
333 | def __getitem__(self, key): return super().__getitem__(id(key))
334 | def __setitem__(self, key, value): return super().__setitem__(id(key), value)
335 | def __delitem__(self, key): return super().__delitem__(id(key))
336 | def __contains__(self, key): return super().__contains__(id(key))
337 | class id_set(set):
338 | '''same as [id_dict]'''
339 | def add(self, value): super().add(id(value))
340 | def remove(self, value): super().remove(id(value))
341 | def __contains__(self, value): return super().__contains__(id(value))
342 |
343 | guiCodegen = Codegen()
344 |
--------------------------------------------------------------------------------
/hachiko_bapu/tkgui.py:
--------------------------------------------------------------------------------
1 | from tkinter import Frame, PanedWindow, LEFT, TOP, RIGHT, BOTTOM, Label, Button, Entry, Text, INSERT, END, DISABLED
2 | from tkinter import Radiobutton, Checkbutton, Listbox, SINGLE, MULTIPLE, BROWSE, Scrollbar, Scale, Spinbox
3 | from tkinter import LabelFrame, Menu, Menubutton, Canvas, PhotoImage
4 | from tkinter import Tk, Toplevel, X, Y, BOTH, FLAT, RAISED, HORIZONTAL, VERTICAL
5 | from tkinter import StringVar, BooleanVar, IntVar, DoubleVar
6 | from tkinter import Widget as TkWidget
7 | import tkinter.messagebox as tkMsgBox
8 | import tkinter.filedialog as tkFileMsgBox
9 |
10 | from functools import wraps
11 | from typing import NamedTuple
12 |
13 | from .tkgui_utils import EventCallback, EventPoller, guiBackend, Backend, Codegen
14 | from .tkgui_utils import guiCodegen as c
15 |
16 | from tkinter.ttk import Style, Separator, Progressbar, Combobox, Sizegrip, Notebook, Treeview
17 | if guiBackend == Backend.TTk:
18 | from tkinter.ttk import * # TTK support
19 |
20 | from typing import Callable, TypeVar, Any, Optional, Union, Tuple, MutableMapping
21 |
22 | '''
23 | This is a declarative wrapper for Python's tkinter GUI layout support.
24 | Common knowledges on Tk:
25 | - there's three layout managers: pack(box-layout), grid, posit(absolute)
26 | - in pack there's three common property: master(parent), side(gravity), fill(size-match-parent), expand(able-to-change)
27 | - packing order is useful: e.g. you have a full-size listBox with yScrollBar, then pack scrollBar first
28 | - use keyword arg(k=v) in constructor or item.configure(k=v)/item[k]=v for widget setup
29 | - Tk should be singleton, use Toplevel for a new window
30 | - Parallelism: Tk cannot gurantee widgets can be updated correctly from other threads, set event loop [Tk.after] instead
31 | Notice:
32 | - this lib uses [widget] decorator to make first arg(parent) *curried*(as first arg in returned lambda)
33 | - two main Box layout (VBox, HBox) .appendChild can accept (curried widget ctor)/widget-value/(widget list ctor)
34 | - use .childs for children list of a layout, since .children is a Tk property
35 | - use shorthand: _ = self.underscore
36 | - spinBox&slider: range(start, stop) is end-exclusive, so 1..100 represented as range(1,100+1)
37 | - rewrite layout&setup (do setXXX setup or bind [TkGUI.connect] listener) and call run(title) to start GUI application
38 |
39 | MenuItems: OpNamed(named), SubMenu, Sep(sep), CheckBox, RadioButton
40 | Widgets(button/bar/line/box): button, radioButton, menuButton; scrollBar, progressBar; slider, text;
41 | (string:)input, textarea, (number:)spinBox, (boolean:)checkBox, (listing:)listBox, comboBox;
42 | menu, separator, canvas, treeWidget
43 | Containers: HBox(horizontalLayout), VBox(verticalLayout); labeledBox,
44 | splitter, tabWidget, withFill, withScroll
45 |
46 | Aux funs:
47 | - _.fill(widget) can make a widget(.e) packed at specified side/fill
48 | - _.var(type) can create a Tk value storage
49 | - _.by(name, widget) can dynamic set widget as self.name attribute
50 |
51 | Features adopted from Teek:
52 | - init_threads to add a global event poller from threads (but this lib is not globally thread safe :( )
53 | - make textarea marks from float to (line,col)
54 | - remove Widget.after&after_cancel, use Timeout objects with global creator
55 |
56 | TODO:
57 | - Adopt Font, Color, Pen/Palette objects
58 | - Adopt Image, ScreenDistance(dimension), Varaible (maybe, but _.by is enough?)
59 | - Adopt Extras: Links for Textarea, and tooltips for all widgets
60 | - Make window object like GTK: isDecorated, modal, position
61 | '''
62 |
63 | T = TypeVar("T"); R = TypeVar("R")
64 | def mayGive1(value:T, op_obj:Union[Callable[[T], R], R]) -> R:
65 | '''creates a [widget]. If TkGUI switch to use DSL tree-data construct, this dynamic-type trick can be removed'''
66 | return op_obj(value) if callable(op_obj) else op_obj
67 |
68 | def kwargsNotNull(**kwargs):
69 | to_del = []
70 | for key in kwargs:
71 | if kwargs[key] == None: to_del.append(key)
72 | for key in to_del: del kwargs[key]
73 | return kwargs
74 |
75 | rescueWidgetOption:MutableMapping[str, Callable[[str], Tuple[str, Any]]] = {}
76 |
77 | from re import search
78 | from _tkinter import TclError
79 |
80 | def widget(op):
81 | '''make a "create" with kwargs configuration = lambda parent: '''
82 | def curry(*args, **kwargs):
83 | kwargs1 = kwargs
84 | def createWidget(p): #< cg: NO modify
85 | try: return op(p, *args, **kwargs1)
86 | except TclError as e:
87 | mch = search("""unknown option "-([^"]+)"$""", str(e))
88 | if mch != None:
89 | opt = mch.groups()[0]
90 | rescue = rescueWidgetOption.get(opt)
91 | if rescue != None: # dirty hack for tk/ttk configure compat
92 | subst = rescue(kwargs1[opt])
93 | if subst == None: del kwargs1[opt]
94 | else: kwargs1[subst[0]] = subst[1]
95 | return createWidget(p)
96 | raise e
97 | return createWidget
98 | return curry
99 |
100 | def nop(*arg): pass
101 |
102 | class EventName:
103 | def __init__(self, name:str):
104 | self.name = "on%s" %name.capitalize() if name.isalnum() else name
105 | def __str__(self):
106 | return self.name
107 | __repr__ = __str__
108 |
109 | class MenuItem:
110 | def __init__(self, name):
111 | self.name = name
112 | class MenuItem:
113 | class OpNamed(MenuItem):
114 | def __init__(self, name, op):
115 | super().__init__(name); self.op = op
116 | @staticmethod
117 | def named(name, op): return MenuItem.OpNamed(name, op)
118 | class SubMenu(MenuItem):
119 | def __init__(self, name, childs):
120 | super().__init__(name); self.childs = childs
121 | class Sep(MenuItem):
122 | def __init__(self): super().__init__("|")
123 | sep = Sep()
124 | class CheckBox(MenuItem):
125 | def __init__(self, name, dst):
126 | super().__init__(name); self.dst=dst
127 | class RadioButton(MenuItem):
128 | def __init__(self, name, dst, value):
129 | super().__init__(name); self.dst,self.value = dst,value
130 |
131 | class Textarea(Text):
132 | def __init__(self, master=None, **kwargs):
133 | super().__init__(master=master, **kwargs)
134 | self.marker = Textarea.MarkerPos(self)
135 | class LineCol(NamedTuple("LineCol", [("line", int), ("col", int)])): #v text indexes comparing inherited.
136 | def __repr__(self): return f"LineCol({self.line}:{self.col})"
137 |
138 | class MarkerPos:
139 | def __init__(self, outter):
140 | self._outter = outter
141 | def coerceInBounds(self, index):
142 | o = self._outter
143 | if index < o.start: return o.start
144 | if index > o.end: return o.end
145 | return index
146 | def stepFrom(self, loc, chars=0, indices=0, lines=0):
147 | code = "%d.%d %+d lines %+d chars %+d indices" % (loc.line, loc.col, lines, chars, indices)
148 | return self.coerceInBounds(self[code])
149 |
150 | def __getitem__(self, name) -> "Textarea.LineCol":
151 | (line, col) = map(int, self._outter.index(name).split("."))
152 | return Textarea.LineCol(line, col)
153 | def __setitem__(self, name, pos):
154 | self._outter.mark_set(name, "%i.%i" %(pos.line, pos.col))
155 | def __delitem__(self, name):
156 | self._outter.mark_unset(name)
157 |
158 | @property
159 | def start(self): return Textarea.LineCol(1, 0)
160 | @property
161 | def end(self): return self.marker["end - 1 char"]
162 | @property
163 | def wrap(self): return self["wrap"]
164 | @wrap.setter
165 | def wrap(self, v): self["wrap"] = v
166 |
167 | class TreeWidget(Treeview):
168 | def makeTree(self, headings, tree):
169 | '''[tree] is a (tuple name, childs for nested), or str list'''
170 | self["columns"] = headings
171 | for (i, hd) in enumerate(headings): self.heading("#%d" %i, text=hd, anchor="w")
172 | def insert(nd, src):
173 | self.insert(nd, END, src, text=str(src))
174 | def insertRec(nd, src):
175 | if isinstance(src, tuple):
176 | (name, childs) = src
177 | insert(nd, name)
178 | for it in childs: insertRec(name, it)
179 | elif isinstance(src, list):
180 | self.insert(nd, END, src[0], text=str(src[0]), values=src[1:])
181 | else: insert(nd, src)
182 | for leaf in tree: insertRec("", leaf) # required for texts in root
183 | class TreeItem:
184 | def __init__(self, outter, id):
185 | self._outter:TreeWidget = outter
186 | self.id = id
187 | def __eq__(self, other): return self.id == other.id
188 | def __hash__(self): return self.id.__hash__()
189 | def wrap(self, id): return TreeWidget.TreeItem(self._outter, id)
190 | def isExists(self): return self._outter.exists(self.id)
191 | def __getitem__(self, index): return self._outter.set(self.id, index)
192 | def __setitem__(self, index, v): return self._outter.set(self.id, index, v)
193 | def focus(self):
194 | self._outter.see(self.id)
195 | self._outter.focus(self.id)
196 | def remove(self):
197 | self._outter.delete(self.id)
198 | def removeChilds(self):
199 | self._outter.delete(*self._outter.get_children(self.id))
200 | def detach(self):
201 | self._outter.detach(self.id)
202 | def moveTo(self, dst):
203 | self._outter.move(self.id, dst, END)
204 | def addChild(self, text, values=None, is_open=False) -> "TreeItem":
205 | child = self._outter.insert(self.id, END, text, text=(text or ""), open=is_open, **kwargsNotNull(values=values))
206 | return self.wrap(child)
207 | @property
208 | def parent(self) -> "TreeItem":
209 | id = self._outter.parent(self.id)
210 | return self.wrap(id) if id != "" else None
211 | @property
212 | def childs(self): return [self.wrap(it) for it in self._outter.get_children(self.id)]
213 |
214 | def item(self, id): return TreeWidget.TreeItem(self, id)
215 | @property
216 | def focusItem(self): return self.item(self.focus())
217 | @property
218 | def selectedItems(self): return [self.item(id) for id in self.selection()]
219 | def selectItems(self, items):
220 | self.selection(items=[it.id for it in items])
221 | @property
222 | def rootItem(self): return self.item("")
223 | onOpen = EventName("<>")
224 |
225 | class BaseTkGUI:
226 | def __init__(self, root):
227 | self.tk:Toplevel = root
228 | self.ui:Optional[TkWidget] = None #>layout
229 | self.style:Style = Style(self.tk)
230 | def layout(self) -> "Widget":
231 | '''
232 | (FAILED since Python has NO overriding) I'm sorry about adding so many kwargs, but Python is not real OOP (just obj.op_getter property-based),
233 | there's no name can be implicitly(w/o "self") solved in scope -- inner class, classmethod, staticmethod, property, normal defs
234 | so only global/param/local can be used without boilerplates, I've choosen keyword args.
235 | '''
236 | raise NotImplementedError("main layout")
237 | def setup(self): pass
238 |
239 | def var(self, type, initial=None, var_map = {str: StringVar, bool: BooleanVar, int: IntVar, float: DoubleVar}):
240 | variable = c.named("var", c.callNew(var_map[type], self.tk))
241 | if initial != None: c.invoke(variable, "set", initial)
242 | return variable # may in ctor, no codegen autoname
243 | def by(self, attr, e_ctor):
244 | def createAssign(p):
245 | e = mayGive1(p, e_ctor)
246 | c.setAttr(self, attr, e); return e
247 | return createAssign
248 | @property
249 | def underscore(self) -> "BaseTkGUI": return self
250 |
251 | def run(self, title="App"):
252 | self.tk.wm_deiconify()
253 | self.tk.wm_title(title)
254 | self.ui = mayGive1(self.tk, self.layout())
255 | self.ui.pack()
256 | self.setup()
257 | self.focus(); self.tk.mainloop()
258 | def getCode(self, run=False) -> str:
259 | '''gets code for layout&widgets, note codes in __init__ (e.g. var) is ignored (replace with _.by(attr,it) in layout)'''
260 | Codegen.isEnabled = True
261 | if run: self.run("Codegen Running")
262 | ui = self.ui or mayGive1(self.tk, self.layout())
263 | # give missing epilog assign
264 | c.setAttr(self, "treeUI", ui)
265 | code = c.getCode()
266 | Codegen.isEnabled = False
267 | c.clear()
268 | return code
269 | def runCode(self, code, **extra_names):
270 | '''run generated code, then show result self.treeUI'''
271 | codeRef = compile(code, "", "exec")
272 | exec(codeRef, globals(), {"tkgui": TkGUI.root, "root": TkGUI.root.tk, **extra_names})
273 | self.treeUI.pack()
274 | self.ui = self.ui or self.treeUI #:dytype
275 | self.setup()
276 | self.focus(); self.tk.mainloop()
277 | @property
278 | def title(self) -> str: return self.tk.wm_title()
279 | @title.setter
280 | def title(self, v): c.invoke(self.tk, "wm_title", v)
281 | @staticmethod
282 | def _interpSize(code): return tuple(int(d) for d in code[0:code.index("+")].split("x"))
283 | @property
284 | def size(self) -> tuple:
285 | code = self.tk.wm_geometry()
286 | return c.call(TkGUI._interpSize, code)
287 | def setSize(self, dim, xy=None):
288 | '''sets the actual size/position of window'''
289 | code = "x".join(str(i) for i in dim)
290 | if xy != None: code += "+%d+%d" %(xy[0],xy[1])
291 | c.invoke(self.tk, "wm_geometry", code)
292 | def setSizeBounds(self, min:tuple, max:tuple=None):
293 | '''set [min] to (1,1) if no limit'''
294 | c.invoke(self.tk, "wm_minsize", min[0], min[1])
295 | if max: c.invoke(self.tk, "wm_maxsize", max[0], max[1])
296 | def setIcon(self, path:str):
297 | try: c.invoke(self.tk, "wm_iconphoto", c.callNew(PhotoImage, file=path) ) #cg:note
298 | except TclError: self.tk.wm_iconbitmap(path)
299 | def setWindowAttributes(self, attrs): self.tk.wm_attributes(*attrs)
300 | @property
301 | def screenSize(self):
302 | return (self.tk.winfo_screenwidth(), self.tk.winfo_screenheight() )
303 |
304 | def focus(self): self.tk.focus_set()
305 | def listThemes(self): return self.style.theme_names()
306 | @property
307 | def theme(self): return self.style.theme_use()
308 | @theme.setter
309 | def theme(self, v): return self.style.theme_use(v) #cg:no
310 | def addSizeGrip(self):
311 | sg = c.callNew(Sizegrip, self.ui)
312 | c.invoke(sg, "pack", side=RIGHT)
313 |
314 | class Widget: #TODO more GUI framework support
315 | def pack(self): pass
316 | def forget(self): pass
317 | def destroy(self): pass
318 | def bind(self, event_name:EventName, callback): return super().bind(event_name.name, callback)
319 | class TkWidgetDelegate(Widget):
320 | def __init__(self, e):
321 | super().__init__()
322 | self.e:TkWidget = e
323 | def pack(self, **kwargs): return self.e.pack(**kwargs)
324 | def forget(self): return self.e.forget()
325 | def destroy(self): return self.e.destroy()
326 | def bind(self, event_name, callback): return self.e.bind(event_name, callback)
327 | def __getitem__(self, key): return self.e[key]
328 | def __setitem__(self, key, v): self.e[key] = v
329 | def configure(self, cnf=None, **kwargs): self.e.configure(cnf, **kwargs)
330 | config = configure
331 |
332 | class Box(Frame, Widget):
333 | def __init__(self, parent, pad, is_vertical):
334 | super().__init__(parent)
335 | self.childs = []
336 | self.pad,self.is_vertical = pad,is_vertical
337 | def pack(self, **kwargs):
338 | super().pack(**kwargs)
339 | if len(self.childs) == 0: return
340 | self.childs[0].pack(side=(TOP if self.is_vertical else LEFT) )
341 | for it in self.childs[1:]: self._appendChild(it)
342 | def destroy(self):
343 | for it in self.childs: self.removeChild(it)
344 |
345 | def _appendChild(self, e):
346 | if self.is_vertical: e.pack(side=TOP, fill=Y, pady=self.pad)
347 | else: e.pack(side=LEFT, fill=X, padx=self.pad)
348 | def appendChild(self, e_ctor):
349 | e = mayGive1(self, e_ctor)
350 | if isinstance(e, list):
351 | for it in e: self._appendChild(it)
352 | self.childs.extend(e)
353 | else:
354 | self._appendChild(e)
355 | self.childs.append(e)
356 | def removeChild(self, e):
357 | e.forget()
358 | try: e.destory()
359 | except AttributeError: pass
360 | self.childs.remove(e)
361 | @property
362 | def firstChild(self): return self.childs[0]
363 | @property
364 | def lastChild(self): return self.childs[-1]
365 | class HBox(Box):
366 | def __init__(self, parent, pad=3):
367 | super().__init__(parent, pad, False)
368 | class VBox(Box):
369 | def __init__(self, parent, pad=5):
370 | super().__init__(parent, pad, True)
371 |
372 | class ScrollableFrame(Frame):
373 | def __init__(self, parent, orient):
374 | super().__init__(parent)
375 | self.oreint = orient
376 | self.hbar:Scrollbar=None; self.vbar:Scrollbar=None
377 | self.item:TkWidget=None
378 | def pack(self, **kwargs):
379 | super().pack(**kwargs)
380 | both = (self.oreint == BOTH)
381 | o = self.oreint
382 | if o == HORIZONTAL or both:
383 | self.hbar = Scrollbar(self, orient=HORIZONTAL)
384 | self.hbar.pack(side=BOTTOM, fill=X)
385 | if o == VERTICAL or both:
386 | self.vbar = Scrollbar(self, orient=VERTICAL)
387 | self.vbar.pack(side=RIGHT, fill=Y)
388 | self.item.pack()
389 | if self.hbar: TkGUI.bindXScrollBar(self.item, self.hbar)
390 | if self.vbar: TkGUI.bindYScrollBar(self.item, self.vbar)
391 |
392 | class PackSideFill(TkWidgetDelegate):
393 | def __init__(self, e, side:Optional[str], fill):
394 | super().__init__(e)
395 | self.side,self.fill = side,fill
396 | def reside(self, new_side):
397 | return type(self)(self.e, new_side, self.fill)
398 | def pack(self, *args, **kwargs):
399 | kwargs.update({"side": self.side or kwargs.get("side"), "fill": self.fill, "expand": self.fill == BOTH})
400 | self.e.pack(*args, **kwargs)
401 | def set(self, *args): return self.e.set(*args)
402 |
403 | @staticmethod
404 | def _createLayout(ctor_box, p, items):
405 | box = c.callNew(ctor_box, p)
406 | c.named("lh" if isinstance(box, TkGUI.HBox) else "lv", box)
407 | c.setAttr(box, "childs", list(mayGive1(box, it) for it in items))
408 | return box
409 | @staticmethod
410 | def verticalLayout(*items): return lambda p: TkGUI._createLayout(TkGUI.VBox, p, items)
411 | @staticmethod
412 | def horizontalLayout(*items): return lambda p: TkGUI._createLayout(TkGUI.HBox, p, items)
413 | @staticmethod
414 | @widget
415 | def createLayout(p, orient, pad, *items):
416 | box = c.named("box", c.callNew(TkGUI.HBox, p, pad) if orient == HORIZONTAL else c.callNew(TkGUI.VBox, p, pad))
417 | c.setAttr(box, "childs", list(mayGive1(box, it) for it in items))
418 | return box
419 |
420 | @staticmethod
421 | @widget
422 | def menu(p, *items, use_default_select = False):
423 | e_menu = c.callNew(Menu, p, tearoff=use_default_select)
424 | for it in items:
425 | if isinstance(it, MenuItem.OpNamed):
426 | c.invoke(e_menu, "add_command", label=it.name, command=it.op)
427 | elif isinstance(it, MenuItem.SubMenu):
428 | child = TkGUI.menu(*it.childs, use_default_select)(e_menu) # cg:flatten
429 | c.invoke(e_menu, "add_cascade", label=it.name, menu=child)
430 | elif isinstance(it, MenuItem.Sep): c.invoke(e_menu, "add_separator")
431 | elif isinstance(it, MenuItem.CheckBox): c.invoke(e_menu, "add_checkbutton", label=it.name, variable=it.dst)
432 | elif isinstance(it, MenuItem.RadioButton): c.invoke(e_menu, "add_radiobutton", label=it.name, variable=it.dst, value=it.value)
433 | return e_menu
434 | def setMenu(self, menu_ctor):
435 | c.setItem(self.tk, "menu", menu_ctor(self.tk))
436 | def makeMenuPopup(self, menu_ctor):
437 | menu = mayGive1(self.tk, menu_ctor)
438 | def popup(event):
439 | try: menu.tk_popup(event.x_root, event.y_root)
440 | finally: menu.grab_release()
441 | return popup
442 |
443 | @staticmethod #^ layouts v button/bar/slider/box
444 | @widget
445 | def text(p, valr, **kwargs):
446 | kwargs["textvariable" if isinstance(valr, StringVar) else "text"] = valr
447 | return c.named("t", c.callNew(Label, p, **kwargs))
448 | @staticmethod
449 | @widget
450 | def textarea(p, placeholder=None, readonly=False, **kwargs):
451 | text = c.callNew(Textarea, p, **kwargs)
452 | if placeholder != None: c.invoke(text, "insert", INSERT, placeholder)
453 | if readonly: c.setItem(text, "state", DISABLED)
454 | return text
455 | @staticmethod
456 | @widget
457 | def button(p, text, on_click, **kwargs):
458 | return c.named("btn", c.callNew(Button, p, text=text, command=on_click, **kwargs))
459 | @staticmethod
460 | @widget
461 | def radioButton(p, text, dst, value, on_click=nop):
462 | return c.callNew(Radiobutton, p, text=text, variable=dst, value=value, command=on_click)
463 | @staticmethod
464 | @widget
465 | def menuButton(p, text, menu_ctor, **kwargs):
466 | menub = c.callNew(Menubutton, p, text=text, **kwargs)
467 | c.setItem(menub, "menu", mayGive1(menub, menu_ctor))
468 | return menub
469 | @staticmethod
470 | @widget
471 | def input(p, placeholder="", **kwargs):
472 | ent = c.named("ent", c.callNew(Entry, p, **kwargs))
473 | c.invoke(ent, "delete", 0, END)
474 | c.invoke(ent, "insert", 0, placeholder)
475 | return ent
476 | @staticmethod
477 | @widget
478 | def spinBox(p, range:range, **kwargs):
479 | if range.step != 1: return c.callNew(Spinbox, p, values=tuple(range), **kwargs)
480 | else: return c.callNew(Spinbox, p, from_=range.start, to=range.stop-1, **kwargs)
481 | @staticmethod
482 | @widget
483 | def slider(p, range:range, **kwargs):
484 | if guiBackend == Backend.Tk: kwargs["resolution"] = range.step
485 | return c.callNew(Scale, p, from_=range.start, to=range.stop-1, **kwargs)
486 | @staticmethod
487 | @widget
488 | def checkBox(p, text_valr, dst, a=True, b=False, on_click=nop):
489 | '''make [text_valr] and [dst] points to same if you want to change text when checked'''
490 | valr = text_valr
491 | cbox = c.named("ckbox", c.callNew(Checkbutton, p, **{"textvariable" if isinstance(valr, StringVar) else "text": valr},
492 | variable=dst, onvalue=a, offvalue=b, command=nop))
493 | return cbox
494 | @staticmethod
495 | @widget
496 | def listBox(p, items, mode=SINGLE, **kwargs):
497 | mode1 = BROWSE if mode == SINGLE else mode
498 | lbox = c.named("lbox", c.callNew(Listbox, p, selectmode=mode1, **kwargs))
499 | for (i, it) in enumerate(items): c.invoke(lbox, "insert", i, it)
500 | return lbox
501 | @staticmethod
502 | @widget
503 | def comboBox(p, dst, items):
504 | cmbox = c.named("cbox", c.callNew(Combobox, p, textvariable=dst, values=items))
505 | return cmbox
506 | @staticmethod
507 | @widget
508 | def scrollBar(p, orient=VERTICAL):
509 | scroll = c.callNew(Scrollbar, p, orient=orient)
510 | sbar = c.callNew(TkGUI.PackSideFill, scroll, None, Y if orient==VERTICAL else X)
511 | return c.named("sbar", sbar)
512 | @staticmethod
513 | @widget
514 | def progressBar(p, dst, orient=HORIZONTAL):
515 | return c.CallNew(Progressbar, p, variable=dst, orient=orient)
516 |
517 | @staticmethod
518 | @widget
519 | def separator(p, orient=HORIZONTAL):
520 | return c.callNew(Separator, p, orient=orient)
521 | @staticmethod
522 | @widget
523 | def canvas(p, dim, **kwargs):
524 | (width,height) = dim
525 | return c.callNew(Canvas, p, width=width, height=height, **kwargs)
526 | @staticmethod
527 | @widget
528 | def treeWidget(p, mode=SINGLE):
529 | mode1 = BROWSE if mode == SINGLE else mode
530 | treev = c.callNew(TreeWidget, p, selectmode=mode1)
531 | return treev
532 |
533 | @staticmethod
534 | @widget
535 | def labeledBox(p, text, *items, **kwargs):
536 | box = c.callNew(LabelFrame, p, text=text, **kwargs)
537 | lbox = c.callNew(TkGUI.PackSideFill, box, None, BOTH)
538 | for it in items: c.invoke(mayGive1(lbox.e, it), "pack")
539 | return c.named("lbox", lbox)
540 | @staticmethod
541 | @widget
542 | def splitter(p, orient, *items, weights=None, **kwargs): #TODO
543 | paned_win = c.callNew(PanedWindow, p, orient=orient, **kwargs)
544 | for it in items: c.invoke(paned_win, "add", mayGive1(paned_win, it))
545 | return paned_win
546 | @staticmethod
547 | @widget
548 | def tabWidget(p, *entries):
549 | '''you may want tabs to fill whole window, use [fill].'''
550 | tab = c.named("tab", c.callNew(Notebook, p))
551 | for (name, e_ctor) in entries:
552 | e = mayGive1(tab, e_ctor)
553 | if isinstance(e, TkGUI.Box): c.invoke(e, "pack") # in tabs, should pack early
554 | c.invoke(tab, "add", e, text=name)
555 | return tab
556 | @staticmethod
557 | @widget
558 | def withFill(p, e_ctor, fill=BOTH, side=None):
559 | filler = c.named("filler", c.callNew(TkGUI.PackSideFill, mayGive1(p, e_ctor), side, fill))
560 | return filler
561 | @staticmethod
562 | @widget
563 | def withScroll(p, orient, e_ctor):
564 | '''must call setup() to bind scroll in setup()'''
565 | frame = c.named("scrolld", c.callNew(TkGUI.ScrollableFrame, p, orient))
566 | c.setAttr(frame, "item", mayGive1(frame, e_ctor))
567 | return frame
568 |
569 | hor = HORIZONTAL
570 | vert = VERTICAL
571 | both = BOTH
572 | left,top,right,bottom = LEFT,TOP,RIGHT,BOTTOM
573 | raised,flat=RAISED,FLAT
574 | at_cursor,at_end=INSERT,END
575 | choose_single,choose_multi = SINGLE,MULTIPLE
576 | class Anchors:
577 | LT="NW"; TOP="N"; RT="NE"
578 | L="W"; CENTER="CENTER"; R="E"
579 | LD="SW"; BOTTOM="S"; RD="SE"
580 |
581 | class Cursors:
582 | arrow="arrow"; deny="circle"
583 | wait="watch"
584 | cross="cross"; move="fleur"; kill="pirate"
585 |
586 | class Events:
587 | click = EventName("")
588 | doubleClick = EventName("")
589 | mouseM = EventName("")
590 | mouseR = EventName("")
591 | key = EventName("")
592 | enter = EventName(""); leave = EventName("")
593 |
594 | def alert(self, msg, title=None, kind="info"):
595 | tie = title or kind.capitalize()
596 | if kind == "info": tkMsgBox.showinfo(msg, tie)
597 | elif kind == "warn": tkMsgBox.showwarning(msg, tie)
598 | elif kind == "error": tkMsgBox.showerror(msg, tie)
599 | else: raise ValueError("unknown kind: "+kind)
600 |
601 | def ask(self, msg, title="Question") -> bool: return tkMsgBox.askquestion(title, msg)
602 | def askCancel(self, msg, title="Proceed?") -> bool: return not tkMsgBox.askokcancel(title, msg)
603 | def askOrNull(self, msg, title="Question") -> Optional[bool]: return tkMsgBox.askyesnocancel(title, msg)
604 |
605 | def askOpen(self, file_types, title=None, initial_dir=None, mode=SINGLE) -> str:
606 | '''ask path(s) to open, with file types and (optional)title, init dir'''
607 | kws = kwargsNotNull(filetypes=file_types, title=title, initialdir=initial_dir)
608 | return tkFileMsgBox.askopenfilename(**kws) if mode == SINGLE else tkFileMsgBox.askopenfilenames(**kws)
609 | def askSave(self, default_extension, file_types, title=None, initial=None, initial_dir=None) -> str:
610 | '''ask path (initial) to save to, with choosed file type'''
611 | kws = kwargsNotNull(title=title, initialfile=initial, initialdir=initial_dir)
612 | return tkFileMsgBox.asksaveasfilename(defaultextension=default_extension, filetypes=file_types, **kws)
613 | def askSaveDir(self, title=None) -> str: return tkFileMsgBox.askdirectory(**kwargsNotNull(title=title))
614 |
615 | @staticmethod
616 | def connect(sender, signal, receiver, slot): #cg:todo?
617 | ''' connects a command from [sender] to notify [receiver].[slot], or call slot(sender, receiver, *signal_args) '''
618 | def runProc(*arg, **kwargs):
619 | return slot(sender, receiver, *arg, **kwargs)
620 | listen = receiver.__getattribute__(slot) if not callable(slot) else runProc
621 | sender[signal+"command"] = listen
622 | @staticmethod
623 | def _bindScrollBarY(a, b, evt, v, *args): b.yview_moveto(v)
624 | @staticmethod
625 | def _bindScrollBarX(a, b, evt, v, *args): b.xview_moveto(v)
626 |
627 | @staticmethod
628 | def bindYScrollBar(box, bar):
629 | TkGUI.connect(box, "yscroll", bar, "set")
630 | TkGUI.connect(bar, "", box, TkGUI._bindScrollBarY)
631 | @staticmethod
632 | def bindXScrollBar(box, bar):
633 | TkGUI.connect(box, "xscroll", bar, "set")
634 | TkGUI.connect(bar, "", box, TkGUI._bindScrollBarX)
635 |
636 | class TkGUI(BaseTkGUI, EventPoller):
637 | root:"TkGUI" = None
638 | def __init__(self):
639 | if TkGUI.root != None: raise RuntimeError("TkGUI is singleton, should not created twice")
640 | super().__init__(Tk())
641 | EventPoller.__init__(self)
642 | self.on_quit = EventCallback()
643 | TkGUI.root = self
644 | c.named("tkgui", self, is_extern=True)
645 | c.named("root", self.tk, is_extern=True)
646 | self.tk.bind("", lambda _: self.on_quit.run())
647 | def quit(self):
648 | self.tk.destroy()
649 |
650 | class TkWin(BaseTkGUI):
651 | def __init__(self):
652 | super().__init__(Toplevel(TkGUI.root.tk))
653 |
654 | def callThreadSafe(op, args=(), kwargs={}):
655 | return TkGUI.root.callThreadSafe(op, args, kwargs)
656 |
657 | def makeThreadSafe(op):
658 | '''
659 | A decorator that makes a function safe to be called from any thread, (and it runs in the main thread).
660 | If you have a function runs a lot of Tk update and will be called asynchronous, better decorate with this (also it will be faster)
661 | [op] should not block the main event loop.
662 | '''
663 | @wraps(op)
664 | def safe(*args, **kwargs):
665 | return callThreadSafe(op, args, kwargs).getValue()
666 | return safe
667 |
668 | class Timeout:
669 | def __init__(self, after_what, op):
670 | assert TkGUI.root != None, "TkGUI not initialized"
671 | self.op = op
672 | self._id = TkGUI.root.tk.after(after_what, op)
673 |
674 | def cancel(self):
675 | """Prevent this timeout from running as scheduled."""
676 | TkGUI.root.tk.after_cancel(self._id) # race condition?
677 |
678 |
679 | def runAsync(thunk, op, **kwargs):
680 | '''launch the [thunk], then call [op] safely with args, return thunk() result'''
681 | future = lambda res: callThreadSafe(op, (res,), kwargs)
682 | return thunk(future)
683 |
684 | def thunkify(op, kw_callback="callback", *args, **kwargs):
685 | '''make a function with named callback param as thunk'''
686 | def addCb(cb, kws):
687 | kws[kw_callback] = cb
688 | return kws
689 | return lambda cb: op(*args, **addCb(kwargs, cb))
690 |
691 | from threading import Thread
692 | def thunkifySync(op, *args, **kwargs):
693 | def callAsync(cb):
694 | Thread(target=lambda args1, kwargs1: cb(op(*args1, **kwargs1)), args=(args, kwargs) ).start()
695 | return callAsync
696 |
697 | from time import sleep
698 | def delay(msec):
699 | return lambda cb: Thread(target=lambda cb1: cb1(sleep(msec/1000)), args=(cb,)).start()
700 |
--------------------------------------------------------------------------------