diff --git a/.gitignore b/.gitignore index fc98cbec7..f4c1a659c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ .*~ __pycache__ zynlibs/*/build -.vscode/launch.json +.vscode .idea \ No newline at end of file diff --git a/zynconf/zynthian_config.py b/zynconf/zynthian_config.py index f0efd23cd..3f9b8c4ae 100755 --- a/zynconf/zynthian_config.py +++ b/zynconf/zynthian_config.py @@ -30,7 +30,7 @@ from time import sleep from stat import S_IWUSR from shutil import copyfile -from subprocess import check_output +from subprocess import check_output, DEVNULL # ------------------------------------------------------------------------------- # Configure logging @@ -170,11 +170,160 @@ sys_dir = os.environ.get('ZYNTHIAN_SYS_DIR', "/zynthian/zynthian-sys") config_dir = os.environ.get('ZYNTHIAN_CONFIG_DIR', '/zynthian/config') config_fpath = config_dir + "/zynthian_envars.sh" +zynthian_repositories = ["zynthian-sys", "zynthian-ui", "zyncoder", "zynthian-data", "zynthian-webconf"] # ------------------------------------------------------------------------------- -# Config management +# Version configuration # ------------------------------------------------------------------------------- +def is_git_behind(path): + # True if the git repository is behind the upstream version + check_output(f"git -C {path} remote update; git -C {path} status --porcelain -bs | grep behind | wc -l", + encoding="utf-8", shell=True) != '0\n' + +def get_git_branch(path): + # Get the current branch for a git repository or None if detached or invalid repo name + try: + return check_output(f"git -C {path} symbolic-ref -q --short HEAD", + encoding="utf-8", shell=True).strip() + except: + return None + +def get_git_tag(path): + # Get the current tag for a git repository or None if invalid repo name + try: + status = check_output(f"git -C {path} status", + encoding="utf-8", shell=True, stderr=DEVNULL).split('\n') + for line in status: + if line.strip().startswith("HEAD detached at "): + return line.strip()[17:] + except: + return None + +def get_git_local_hash(path): + # Get the hash of the current commit for a git branch or None if invalid + try: + return check_output(f"git -C {path} rev-parse HEAD", + encoding="utf-8", shell=True).strip() + except: + return None + +def get_git_remote_hash(path, branch=None): + # Get the hash of the latest commit for a git branch or for a tag or None if invalid + if branch is None: + branch = get_git_tag(path) + if branch is None: + return None + try: + return check_output(f"git -C {path} ls-remote origin {branch}", + encoding="utf-8", shell=True).strip().split("\t")[0] + except: + return None + +def get_git_version_info(path): + # Get version information about a git repository + local_hash = get_git_local_hash(path) + branch = get_git_branch(path) + tag = get_git_tag(path) + release_name = None + version = None + major_version = 0 + minor_version = 0 + patch_version = 0 + frozen = False + if tag is not None: + # Check if it is a major release channel + parts = tag.split("-", 1) + if len(parts) == 2: + release_name = parts[0] + version = parts[1] + if version: + parts = version.split(".", 3) + major_version = parts[0] + if len(parts) > 2: + patch_version = parts[2] + if len(parts) > 1: + minor_version = parts[1] + frozen = True + else: + # On stable release channel. Check which point release we are on. + tags = check_output(f"git -C {path} tag --points-at {tag}", encoding="utf-8", shell=True).split() + for t in tags: + parts = t.split("-", 1) + if len(parts) != 2 or parts[0] != release_name: + continue + v_parts = parts[1].split(".", 3) + try: + major_version = int(major_version) + x = int(v_parts[0]) + y = z = 0 + if len(v_parts) > 1: + y = int(v_parts[1]) + if len(v_parts) > 2: + z = int(v_parts[2]) + if x > major_version: + major_version = x + minor_version = y + patch_version = z + elif y > minor_version: + minor_version = y + patch_version = z + elif z > patch_version: + patch_version = z + except: + pass + result = { + "branch": branch, + "tag": tag, + "name": release_name, + "major": major_version, + "minor": minor_version, + "patch": patch_version, + "frozen": frozen, + "local_hash": local_hash + } + return result + +def update_git(path): + check_output(f"git -C {path} remote update origin --prune", shell=True) + +def get_git_tags(path, refresh=False): + # Get list of tags in a git repository + try: + if refresh: + update_git(path) + return sorted(check_output(f"git -C {path} tag", encoding="utf-8", shell=True).split(), key=str.casefold) + except: + return [] + +def get_git_branches(path, refresh=False): + # Get list of branches in a git repository + result = [] + if refresh: + update_git(path) + for branch in check_output(f"git -C {path} branch -a", encoding="utf-8", shell=True).splitlines(): + branch = branch.strip() + if branch.startswith("*"): + branch = branch[2:] + if branch.startswith("remotes/origin/"): + branch = branch[15:] + if "->" in branch or branch.startswith("(HEAD detached at "): + continue + if branch not in result: + result.append(branch) + return sorted(result, key=str.casefold) + +def get_system_version(): + # Get the overall release version or None if inconsistent repository states + tag = get_git_version_info("/zynthian/zynthian-sys")["tag"] + for repo in zynthian_repositories: + if get_git_version_info(f"/zynthian/{repo}")["tag"] != tag: + return None + return tag + +# ------------------------------------------------------------------------------- +# Config management +# ------------------------------------------------------------------------------- def get_midi_config_fpath(fpath=None): if not fpath: diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py index df63cf5b1..13250e991 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py @@ -188,8 +188,7 @@ def __init__(self, state_manager, idev_in, idev_out=None): self._device_handler = DeviceHandler(state_manager, self._leds) self._mixer_handler = MixerHandler(state_manager, self._leds) self._padmatrix_handler = PadMatrixHandler(state_manager, self._leds) - self._stepseq_handler = StepSeqHandler( - state_manager, self._leds, idev_in) + self._stepseq_handler = StepSeqHandler(state_manager, self._leds, idev_in) self._current_handler = self._mixer_handler self._is_shifted = False @@ -712,19 +711,19 @@ def refresh(self): return query = { - FN_MUTE: self._zynmixer.get_mute, - FN_SOLO: self._zynmixer.get_solo, - FN_SELECT: self._is_active_chain, + FN_MUTE: lambda c: self._zynmixer.get_mute(c.mixer_chan), + FN_SOLO: lambda c: self._zynmixer.get_solo(c.mixer_chan), + FN_SELECT: lambda c: c.chain_id == self._active_chain, }[self._track_buttons_function] for i in range(8): - index = i + (8 if self._chains_bank == 1 else 0) - chain = self._chain_manager.get_chain_by_index(index) + pos = i + (8 if self._chains_bank == 1 else 0) + chain = self._chain_manager.get_chain_by_position(pos) if not chain: break # Main channel ignored if chain.chain_id == 0: continue - self._leds.led_state(BTN_TRACK_1 + i, query(index)) + self._leds.led_state(BTN_TRACK_1 + i, query(chain)) def on_shift_changed(self, state): retval = super().on_shift_changed(state) @@ -786,10 +785,18 @@ def cc_change(self, ccnum, ccval): def update_strip(self, chan, symbol, value): if {"mute": FN_MUTE, "solo": FN_SOLO}.get(symbol) != self._track_buttons_function: return - chan -= self._chains_bank * 8 - if 0 > chan > 8: + + # Mixer 'chan' may not be equal to its position (if re-arranged or a + # chain was deleted). Search the actual displayed position. + chain_id = self._chain_manager.get_chain_id_by_mixer_chan(chan) + for pos in range(self._chain_manager.get_chain_count()): + if self._chain_manager.get_chain_id_by_index(pos) == chain_id: + break + + pos -= self._chains_bank * 8 + if 0 > pos > 8: return - self._leds.led_state(BTN_TRACK_1 + chan, value) + self._leds.led_state(BTN_TRACK_1 + pos, value) return True def set_active_chain(self, chain, refresh): @@ -801,12 +808,6 @@ def set_active_chain(self, chain, refresh): if refresh: self.refresh() - def _is_active_chain(self, position): - chain = self._chain_manager.get_chain_by_position(position) - if chain is None: - return False - return chain.chain_id == self._active_chain - def _update_volume(self, ccnum, ccval): return self._update_control("level", ccnum, ccval, 0, 100) @@ -934,8 +935,11 @@ def __init__(self, state_manager, leds: FeedbackLEDs): def on_record_changed(self, state): self._is_record_pressed = state - if state and self._recording_seq: - self._stop_pattern_record() + + # Only STOP recording allowed, as START conflicts with RECORD + PAD + if state and self._recording_seq is not None: + if self._libseq.isMidiRecord(): + self._stop_pattern_record() def on_toggle_play(self): self._state_manager.send_cuia("TOGGLE_PLAY") @@ -1185,25 +1189,28 @@ def _refresh_tool_buttons(self): return # If seqman is disabled, show playing status in row launchers - playing_rows = {seq % - self._zynseq.col_in_bank for seq in self._playing_seqs} + playing_rows = { + seq % self._zynseq.col_in_bank for seq in self._playing_seqs} for row in range(5): state = row in playing_rows self._leds.led_state(BTN_SOFT_KEY_START + row, state) def _start_pattern_record(self, seq): + # Set pad's chain as active channel = self._libseq.getChannel(self._zynseq.bank, seq, 0) chain_id = self._chain_manager.get_chain_id_by_mixer_chan(channel) if chain_id is None: return - if self._libseq.isMidiRecord(): self._state_manager.send_cuia("TOGGLE_RECORD") self._chain_manager.set_active_chain_by_id(chain_id) - self._show_pattern_editor(seq) + # Open Pattern Editor + self._show_pattern_editor(seq, skip_arranger=True) + + # Start playing & recording if self._libseq.getPlayState(self._zynseq.bank, seq) == zynseq.SEQ_STOPPED: - self._libseq.togglePlayState(self._zynseq.bank, seq) + self._state_manager.send_cuia("TOGGLE_PLAY") if not self._libseq.isMidiRecord(): self._state_manager.send_cuia("TOGGLE_RECORD") @@ -1289,7 +1296,7 @@ def _copy_sequence(self, src_scene, src_seq, dst_scene, dst_seq): # Also copy StepSeq instrument pages self._request_action("stepseq", "sync-sequences", - src_scene, src_seq, dst_scene, dst_seq) + src_scene, src_seq, dst_scene, dst_seq) # -------------------------------------------------------------------------- @@ -1655,7 +1662,7 @@ def __init__(self, state_manager, leds: FeedbackLEDs, dev_idx): self._is_arranger_mode = False # We need to receive clock though MIDI - # TODO: Changing clock source from user preference seems wrong! + # FIXME: Changing clock source from user preference seems wrong! self._state_manager.set_transport_clock_source(1) # Pads ordered for cursor sliding + note pads @@ -1849,8 +1856,7 @@ def note_on(self, note, velocity, shifted_override=None): return True if note == BTN_PLAY: - self._libseq.togglePlayState( - self._zynseq.bank, self._selected_seq) + self._libseq.togglePlayState(self._zynseq.bank, self._selected_seq) elif BTN_PAD_START <= note <= BTN_PAD_END: self._pressed_pads[note] = time.time() @@ -2059,8 +2065,7 @@ def _update_step_velocity(self, step, delta): velocity = self._libseq.getNoteVelocity(step, note) + delta velocity = min(127, max(10, velocity)) self._libseq.setNoteVelocity(step, note, velocity) - self._leds.led_on(self._pads[step], COLOR_RED, - int((velocity * 6) / 127)) + self._leds.led_on(self._pads[step], COLOR_RED, int((velocity * 6) / 127)) self._play_step(step) def _update_step_stutter_count(self, step, delta): diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py b/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py index c5ae48bd8..0fddacad0 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py @@ -30,9 +30,7 @@ from zyncoder.zyncore import lib_zyncore from zyngine.zynthian_signal_manager import zynsigman -from .zynthian_ctrldev_base import ( - zynthian_ctrldev_zynmixer -) +from .zynthian_ctrldev_base import zynthian_ctrldev_zynmixer from .zynthian_ctrldev_base_extended import ( CONST, KnobSpeedControl, IntervalTimer, ButtonTimer ) diff --git a/zyngine/ctrldev/zynthian_ctrldev_base_extended.py b/zyngine/ctrldev/zynthian_ctrldev_base_extended.py index eb7de28ce..153cfde11 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_base_extended.py +++ b/zyngine/ctrldev/zynthian_ctrldev_base_extended.py @@ -217,7 +217,7 @@ def _run_callback(self, note, elapsed): # -------------------------------------------------------------------------- -# Helper class to handle knobs' speed +# Helper class to handle knobs' speed # -------------------------------------------------------------------------- class KnobSpeedControl: def __init__(self, steps_normal=3, steps_shifted=8): diff --git a/zyngine/ctrldev/zynthian_ctrldev_base_ui.py b/zyngine/ctrldev/zynthian_ctrldev_base_ui.py index 9cfb5bae0..bf8aa2449 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_base_ui.py +++ b/zyngine/ctrldev/zynthian_ctrldev_base_ui.py @@ -44,6 +44,7 @@ def _show_pattern_editor(self, seq=None, skip_arranger=False): self._state_manager.send_cuia("SCREEN_ZYNPAD") if seq is not None: self._select_pad(seq) + self._refresh_pattern_editor() if not skip_arranger: zynthian_gui_config.zyngui.screens["zynpad"].show_pattern_editor() else: @@ -56,9 +57,15 @@ def _select_pad(self, pad): # This SHOULD not be coupled to UI! This is needed because when the pattern is changed in # zynseq, it is not reflected in pattern editor. def _refresh_pattern_editor(self): - index = self._zynseq.libseq.getPatternIndex() - zynthian_gui_config.zyngui.screens["pattern_editor"].load_pattern( - index) + zynpad = zynthian_gui_config.zyngui.screens["zynpad"] + patted = zynthian_gui_config.zyngui.screens['pattern_editor'] + pattern = self._zynseq.libseq.getPattern(zynpad.bank, zynpad.selected_pad, 0, 0) + + self._state_manager.start_busy("load_pattern", f"loading pattern {pattern}") + patted.bank = zynpad.bank + patted.sequence = zynpad.selected_pad + patted.load_pattern(pattern) + self._state_manager.end_busy("load_pattern") # FIXME: This SHOULD not be coupled to UI! def _get_selected_sequence(self): diff --git a/zyngine/zynthian_state_manager.py b/zyngine/zynthian_state_manager.py index ed16c7f26..a94009cdb 100644 --- a/zyngine/zynthian_state_manager.py +++ b/zyngine/zynthian_state_manager.py @@ -2728,52 +2728,22 @@ def check_for_updates(self): self.checking_for_updates = True def update_thread(): - logging.debug("************ CHECKING FOR UPDATES ... ************") + logging.debug("************ CHECKING FOR UPDATES... ************") try: - repos = ["zynthian-ui", "zynthian-sys", "zynthian-webconf", "zynthian-data", "zyncoder"] # If attached to last stable => Detect if new tag relase available - if os.environ.get('ZYNTHIAN_STABLE_TAG', "") == "last": - stable_branch = os.environ.get('ZYNTHIAN_STABLE_BRANCH', "oram") - for repo in repos: - path = f"/zynthian/{repo}" - branch = get_repo_branch(path) - # Get last tag release - check_output(["git", "-C", path, "remote", "update", "origin", "--prune"], - encoding="utf-8", stderr=STDOUT) - stags = check_output(["git", "-C", path, "tag", "-l", f"{stable_branch}-*"], - encoding="utf-8", stderr=STDOUT).strip().split("\n") - last_stag = stags[-1].strip() - #logging.debug(f"STABLE TAG RELEASES => {stags}") - if branch != last_stag: - #logging.info(f"For reposiroty '{repo}', current branch ({branch}) != last tag release ({last_stag})!") - self.update_available = True - break - # else => Check for commits to pull - else: - for repo in repos: - path = f"/zynthian/{repo}" - branch = get_repo_branch(path) - local_hash = check_output(["git", "-C", path, "rev-parse", "HEAD"], - encoding="utf-8", stderr=STDOUT).strip() - remote_hash = check_output(["git", "-C", path, "ls-remote", "origin", branch], - encoding="utf-8", stderr=STDOUT).strip().split("\t")[0] - #logging.debug(f"*********** BRANCH {branch} => local hash {local_hash}, remote hash {remote_hash} ****************") - if local_hash != remote_hash: - self.update_available = True - break + for repo in zynconf.zynthian_repositories: + path = f"/zynthian/{repo}" + zynconf.update_git(path) + local_hash = zynconf.get_git_local_hash(path) + remote_hash = zynconf.get_git_remote_hash(path, "HEAD") + #logging.debug(f"*********** BRANCH {branch} => local hash {local_hash}, remote hash {remote_hash} ****************") + if local_hash != remote_hash: + self.update_available = True + break except Exception as e: logging.warning(e) self.checking_for_updates = False - def get_repo_branch(path): - res = check_output(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], - encoding="utf-8", stderr=STDOUT).strip() - parts = res.split("/", 1) - if len(parts) > 1 and parts[0] == 'heads': - return parts[1] - else: - return res - thread = Thread(target=update_thread, args=()) thread.name = "Check update" thread.daemon = True # thread dies with the program diff --git a/zyngui/zynthian_gui_admin.py b/zyngui/zynthian_gui_admin.py index a8f15ae19..cef86bc6c 100644 --- a/zyngui/zynthian_gui_admin.py +++ b/zyngui/zynthian_gui_admin.py @@ -210,6 +210,7 @@ def fill_list(self): self.list_data.append((self.workflow_capture_stop, 0, "\u2612 Capture Workflow", ["End workflow capture session", None])) else: self.list_data.append((self.workflow_capture_start, 0, "\u2610 Capture Workflow", ["Start workflow capture session.\n\nZynthian display, encoder and button actions are saved to file until this option is deselected.", None])) + self.list_data.append((self.about, 0, "About...", ["Show information about zynthian version, etc.", None])) if self.state_manager.update_available: self.list_data.append((self.update_software, 0, "Update Software", ["Updates zynthian firmware and software from Internet.\n\nThis option is only shown when there are updates availale, as indicated by the \u21bb icon in the topbar.\nUpdates may take several minutes. Do not poweroff during an update.", None])) # self.list_data.append((self.update_system, 0, "Update Operating System")) @@ -627,6 +628,25 @@ def workflow_capture_stop(self): self.zyngui.stop_capture_log() self.update_list() + def about(self): + self.zyngui.show_info("System Info\n") + version = zynconf.get_system_version() + if version: + self.zyngui.add_info(f"System software version {version}.\n\n") + else: + self.zyngui.add_info("System software not on consistent version.\n\n") + self.zyngui.add_info("Software Repositories:\n") + for repo in zynconf.zynthian_repositories: + path = f"/zynthian/{repo}" + info = zynconf.get_git_version_info(path) + if info['tag']: + if info['name']: + self.zyngui.add_info(f"{repo}: {info['name']} {info['major']}.{info['minor']}.{info['patch']}\n") + else: + self.zyngui.add_info(f"{repo}: {info['tag']}\n") + else: + self.zyngui.add_info(f"{repo}: {info['branch']}\n") + def update_software(self): logging.info("UPDATE SOFTWARE") self.last_state_action() diff --git a/zynlibs/zynseq/zynseq.py b/zynlibs/zynseq/zynseq.py index 4af115b2e..891a53658 100644 --- a/zynlibs/zynseq/zynseq.py +++ b/zynlibs/zynseq/zynseq.py @@ -164,7 +164,7 @@ def update_progress(self): # Function to select a bank for edit / control # bank: Index of bank - # force: True to fore bank selection even if same as current bank + # force: True to force bank selection even if same as current bank def select_bank(self, bank=None, force=False): if self.changing_bank: return