├── 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 [![PyPI](https://img.shields.io/pypi/v/hachiko-bapu?style=flat-square)](https://pypi.org/project/hachiko-bapu/) [![introduction](https://img.shields.io/badge/introduction-wiki-blue?style=flat-square)](https://github.com/duangsuse-valid-projects/Hachiko/wiki) 2 | 3 |
4 | Hachiko 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 | --------------------------------------------------------------------------------