diff options
Diffstat (limited to 'interface.py')
| -rw-r--r-- | interface.py | 1805 |
1 files changed, 1805 insertions, 0 deletions
diff --git a/interface.py b/interface.py new file mode 100644 index 0000000..c32af3f --- /dev/null +++ b/interface.py @@ -0,0 +1,1805 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import tkinter.font as tkfont +import difflib +import subprocess, sys, os, shutil, tempfile, atexit, json, math, time, wave, traceback +from collections import namedtuple +from functools import lru_cache +from synth import synthesize, sleep, ASCII, CONSONANTS, VOWELS, SONORANT_VOICELESS, _strip_marks + +NOTE_LETTERS = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] + +def semitone_to_name(n): + letter = NOTE_LETTERS[n % 12] + octave = 4 + (n // 12) + return f"{letter}{octave}" + +PITCH_RANGE = list(range(14, -22, -1)) + +BEATS = 100 +CELL_W = 60 +SNAP = 0.1 +CELL_H = 18 +MAX_CH = 40 +LABEL_W = 50 +EDGE = 8 +VIEW_W = 1100 +VIEW_H = 600 +PREVIEW_SECS = 0.45 +HERE = os.path.dirname(os.path.abspath(__file__)) +LOG = os.path.join(HERE, 'piano_roll_error.log') +ASSET_DIRS = [os.environ.get('PYTHIA_ASSETS'), os.path.join(HERE, 'assets')] +BTN_ICON = 18 + +def asset(*parts): + for d in ASSET_DIRS: + if d: + path = os.path.join(d, *parts) + if os.path.isfile(path): + return path + return None +USE_ICONS = True +RULER_H = 32 +MIN_BEATS = 4 +MAX_BEATS = 2000 +MIN_CW = 12 +MAX_CW = 240 + +VOICES = ['masc', 'neu', 'fem'] + +HELP = "drag > new / move / resize | right-click > delete | double-click > edit | shift+drag > box select | F1 > all shortcuts | F2 > Symbol Palette" +HELP_FULL = ("Mouse\n" + " drag empty > new note\n" + " drag note > move\n" + " drag right edge > resize\n" + " right-click > delete\n" + " double-click > edit lyric\n" + " shift+drag > box select\n" + " Ctrl+wheel > zoom\n" + " click ruler > set start time\n" + " drag the ruler > loop range\n" + "\nEdit\n" + " Ctrl+Z / +Shift+Z / +Y > undo / redo\n" + " Ctrl+C / +X / +V > copy / cut / paste\n" + " Ctrl+D > duplicate\n" + " Ctrl+A > select all\n" + " Ctrl+L > lyric entry\n" + " Esc > deselect\n" + " Del > delete\n" + " Enter > edit\n" + " arrows > nudge (Shift = bigger)\n" + "\nView and playback\n" + " Space > play / stop\n" + " Ctrl+ +/- > zoom\n" + " Ctrl+0 > reset zoom\n" + " Home > go to start\n" + " End > go to end\n" + " F1 > help\n" + " F2 > symbol palette\n" + " F11 > fullscreen\n" + "\nFile\n" + " Ctrl+N > new\n" + " Ctrl+O > open\n" + " Ctrl+S > save\n" + " Ctrl+Shift+S > save as\n" + " Ctrl+E > export WAV\n" + " Ctrl+Q > quit\n") + +C = { + 'bg': '#15161a', 'panel': '#202228', 'fg': '#e4e6eb', 'dim': '#8a8f98', + 'grid': '#2f3239', 'bar': '#454953', 'row_c': '#24272d', 'row_w': '#1b1d22', 'row_b': '#141519', + 'field': '#2e3138', 'field_hi': '#3d4149', + 'btn': '#FFD43B', 'btn_hover': '#FFE27D', 'btn_press': '#E0B01F', 'btn_fg': '#1b1d22', + 'btn_on': '#E6A800', 'btn_on_hi': '#F0B400', + 'accent': '#4b8fc7', + 'note': '#306998', 'note_edge': '#8fb8dc', 'note_text': '#ffffff', 'ghost': '#5a97c8', + 'sel': '#4b8fc7', 'sel_edge': '#ffffff', 'stress': '#FFD43B', + 'bad': '#ff5c5c', 'warn': '#ffb347', 'danger': '#e5484d', 'danger_press': '#b42318', +} + +GLYPHS = {'play', 'pause', 'stop', 'loop', 'save', 'load', 'export', 'clear', 'lyrics', 'symbols', 'zoom_in', 'zoom_out'} +_ICON_CACHE = {} + +def load_icon(name): + if name not in _ICON_CACHE: + path = asset('buttons', name + '.png') + _ICON_CACHE[name] = load_scaled(path, BTN_ICON) if path else None + return _ICON_CACHE[name] + +def draw_glyph(cv, name, cx, cy, fg): + s = 7 + ln = dict(fill=fg, width=2, capstyle='round') + if name == 'play': + cv.create_polygon(cx - s + 2, cy - s, cx - s + 2, cy + s, cx + s, cy, fill=fg, outline=fg) + elif name == 'pause': + cv.create_rectangle(cx - s + 1, cy - s, cx - 1, cy + s, fill=fg, outline=fg) + cv.create_rectangle(cx + 2, cy - s, cx + s - 1, cy + s, fill=fg, outline=fg) + elif name == 'stop': + cv.create_rectangle(cx - s + 1, cy - s + 1, cx + s - 1, cy + s - 1, fill=fg, outline=fg) + elif name == 'loop': + cv.create_line(cx - s, cy + 1, cx - s, cy - s + 2, cx + s - 3, cy - s + 2, arrow='last', **ln) + cv.create_line(cx + s, cy - 1, cx + s, cy + s - 2, cx - s + 3, cy + s - 2, arrow='last', **ln) + elif name in ('save', 'load'): + a, b = (cy - s, cy + 2) if name == 'save' else (cy + 2, cy - s) + cv.create_line(cx, a, cx, b, arrow='last', **ln) + cv.create_line(cx - s, cy + 1, cx - s, cy + s, cx + s, cy + s, cx + s, cy + 1, **ln) + elif name == 'export': + for dx, hh in ((-6, 3), (-3, 7), (0, 10), (3, 6), (6, 4)): + cv.create_line(cx + dx, cy - hh, cx + dx, cy + hh, **ln) + elif name == 'clear': + cv.create_line(cx - s + 1, cy - s + 1, cx + s - 1, cy + s - 1, **ln) + cv.create_line(cx - s + 1, cy + s - 1, cx + s - 1, cy - s + 1, **ln) + elif name == 'symbols': + cv.create_text(cx, cy, text='ə', fill=fg, font=('TkDefaultFont', 12, 'bold')) + elif name == 'lyrics': + cv.create_text(cx, cy, text='Aa', fill=fg, font=('TkDefaultFont', 10, 'bold')) + elif name in ('zoom_in', 'zoom_out'): + ox, oy = cx - 2, cy - 2 + cv.create_oval(ox - 6, oy - 6, ox + 6, oy + 6, outline=fg, width=2) + cv.create_line(ox + 5, oy + 5, cx + s, cy + s, **ln) + cv.create_line(ox - 2, oy, ox + 2, oy, fill=fg, width=2) + if name == 'zoom_in': + cv.create_line(ox, oy - 2, ox, oy + 2, fill=fg, width=2) + +def load_scaled(path, h): + if not path or not os.path.isfile(path): + return None + try: + from PIL import Image, ImageTk + im = Image.open(path).convert('RGBA') + return ImageTk.PhotoImage(im.resize((max(1, round(im.width * h / im.height)), h), Image.LANCZOS)) + except ImportError: + pass + except Exception: + return None + try: + img = tk.PhotoImage(file=path) + except tk.TclError: + return None + if img.height() > h: + img = img.subsample(max(1, round(img.height() / h))) + elif 0 < img.height() < h: + img = img.zoom(max(1, round(h / img.height()))) + return img + +class Tip: + def __init__(self, widget, text): + self.widget, self.text, self.tw, self._id = widget, text, None, None + widget.bind('<Enter>', self._enter, add='+') + widget.bind('<Leave>', self._leave, add='+') + widget.bind('<ButtonPress-1>', self._leave, add='+') + + def _enter(self, e=None): + self._id = self.widget.after(450, self._show) + + def _show(self): + x = self.widget.winfo_rootx() + 4 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 + self.tw = tk.Toplevel(self.widget) + self.tw.wm_overrideredirect(True) + self.tw.wm_geometry(f"+{x}+{y}") + tk.Label(self.tw, text=self.text, bg=C['field_hi'], fg=C['fg'], padx=6, pady=2, bd=0, + font=('TkDefaultFont', 8)).pack() + + def _leave(self, e=None): + if self._id: + self.widget.after_cancel(self._id) + self._id = None + if self.tw: + self.tw.destroy() + self.tw = None + +class RoundButton(tk.Canvas): + def __init__(self, parent, text, command, bg=None, hover=None, fg=None, radius=9, + padx=13, pady=5, font=('TkDefaultFont', 9), min_w=0, icon=None, tip=None, press=None): + self.bg_c, self.hover_c = bg or C['btn'], hover or C['btn_hover'] + self.press_c, self.fg_c, self.cmd = press or C['btn_press'], fg or C['btn_fg'], command + self._hover = False + img, glyph = self._resolve(icon) + f = tkfont.Font(font=font) + self.h = f.metrics('linespace') + 2 * pady + self.w = max(self.h + 14, min_w) if (img or glyph) else max(f.measure(text) + 2 * padx, min_w) + super().__init__(parent, width=self.w, height=self.h, bg=parent['bg'], + highlightthickness=0, bd=0, cursor='hand2') + r, w, h = radius, self.w, self.h + pts = [r, 0, w - r, 0, w, 0, w, r, w, h - r, w, h, w - r, h, r, h, 0, h, 0, h - r, 0, r, 0, 0] + self.shape = self.create_polygon(pts, smooth=True, fill=self._color(), outline='') + self.label = None + self._font, self._tip = font, None + self._draw_content(icon, text) + self.bind('<Enter>', lambda e: self._set(hover=True)) + self.bind('<Leave>', lambda e: self._set(hover=False)) + self.bind('<ButtonPress-1>', lambda e: self.itemconfig(self.shape, fill=self.press_c)) + self.bind('<ButtonRelease-1>', self._release) + if img or glyph: + self._tip = Tip(self, tip or text) + + @staticmethod + def _resolve(icon): + if not (USE_ICONS and icon): + return None, None + return load_icon(icon), (icon if icon in GLYPHS else None) + + def _draw_content(self, icon, text): + self.delete('content') + self.icon_name, self.label, self._img = icon, None, None + img, glyph = self._resolve(icon) + w, h = self.w, self.h + if img: + self._img = img + self.create_image(w / 2, h / 2, image=img) + elif glyph: + draw_glyph(self, glyph, w / 2, h / 2, self.fg_c) + else: + self.label = self.create_text(w / 2, h / 2, text=text, fill=self.fg_c, font=self._font) + for item in self.find_all(): + if item != self.shape: + self.addtag_withtag('content', item) + + def set_icon(self, icon, text=None, tip=None): + self._draw_content(icon, text or "") + if self._tip and tip: + self._tip.text = tip + + def _color(self): + return self.hover_c if self._hover else self.bg_c + + def _set(self, hover): + self._hover = hover + self.itemconfig(self.shape, fill=self._color()) + + def _release(self, e): + self.itemconfig(self.shape, fill=self._color()) + if 0 <= e.x <= self.w and 0 <= e.y <= self.h: + self.cmd() + +class ToggleButton(RoundButton): + def __init__(self, parent, text, variable, **kw): + self.var = variable + super().__init__(parent, text, self._flip, **kw) + self.var.trace_add('write', lambda *a: self._set(self._hover)) + + def _flip(self): + self.var.set(not self.var.get()) + + def _color(self): + if self.var.get(): + return C['btn_on_hi'] if self._hover else C['btn_on'] + return super()._color() + + def _set(self, hover): + self._hover = hover + on = self.var.get() + self.itemconfig(self.shape, fill=self._color(), outline=C['note'] if on else '', width=2 if on else 1) + +def snap(x): + return round(round(x / SNAP) * SNAP, 1) + +Note = namedtuple('Note', 'start row lyric length stress') + +def parse_lyric(text, stress=False): + t = text.strip() + if t.endswith('!'): + stress = True + t = t.rstrip('!').rstrip() + return t, stress + +def score_lyric(n): + return n.lyric + (' !' if n.stress else '') + +class NoteModel: + def __init__(self, beats=BEATS): + self.beats = beats + self.notes = {} + self.next_id = 1 + + def snapshot(self): + return (dict(self.notes), self.next_id, self.beats) + + def restore(self, snap_): + self.notes = dict(snap_[0]) + self.next_id = snap_[1] + self.beats = snap_[2] + + def note_at(self, beat, row=None): + for nid, n in self.notes.items(): + if n.start <= beat < n.start + n.length and (row is None or n.row == row): + return nid + return None + + def free(self, start, length, ignore=()): + if start < -1e-9 or start + length > self.beats + 1e-9: + return False + for nid, n in self.notes.items(): + if nid in ignore: + continue + if start < n.start + n.length - 1e-9 and n.start < start + length - 1e-9: + return False + return True + + def room(self, start, ignore=()): + limit = self.beats - start + for nid, n in self.notes.items(): + if nid in ignore: + continue + if n.start > start + 1e-9: + limit = min(limit, n.start - start) + return round(limit, 1) + + def add(self, start, row, lyric, length, stress=False): + nid = self.next_id + self.next_id += 1 + self.notes[nid] = Note(round(start, 1), row, lyric, round(length, 1), stress) + return nid + + def move(self, ids, db, dr, orig=None): + orig = orig or {i: self.notes[i] for i in ids} + new = {} + for i in ids: + n = orig[i] + s = round(n.start + db, 1) + r = n.row + dr + if not (0 <= r < len(PITCH_RANGE)) or not self.free(s, n.length, ignore=ids): + return False + new[i] = n._replace(start=s, row=r) + self.notes.update(new) + return True + + def remove(self, ids): + for i in ids: + self.notes.pop(i, None) + + def clear(self): + self.notes = {} + + def build_score(self, a=0.0, b=None, pad=False): + b = self.beats if b is None else b + score = [] + cursor = a + for n in sorted(self.notes.values(), key=lambda n: n.start): + if n.start < a - 1e-9 or n.start >= b - 1e-9: + continue + length = round(min(n.length, b - n.start), 1) + if length <= 0: + continue + if n.start > cursor + 1e-9: + score.append(sleep(round(n.start - cursor, 1))) + score.append((score_lyric(n), semitone_to_name(PITCH_RANGE[n.row]), length)) + cursor = round(n.start + length, 1) + if pad and cursor < b - 1e-9: + score.append(sleep(round(b - cursor, 1))) + return score + + def to_dict(self, bpm, voice): + notes = sorted(self.notes.values(), key=lambda n: n.start) + return {'version': 1, 'bpm': bpm, 'voice': voice, 'length': self.beats, + 'notes': [{'start': n.start, 'note': semitone_to_name(PITCH_RANGE[n.row]), 'pitch': PITCH_RANGE[n.row], + 'lyric': n.lyric, 'length': n.length, 'stress': n.stress} for n in notes]} + + def load_dict(self, data): + beats = max(MIN_BEATS, min(MAX_BEATS, int(data.get('length', BEATS)))) + tmp = NoteModel(beats) + for d in data['notes']: + n = Note(round(float(d['start']), 1), PITCH_RANGE.index(int(d['pitch'])), str(d['lyric']), + round(float(d['length']), 1), bool(d.get('stress', False))) + if n.length <= 0 or not tmp.free(n.start, n.length): + raise ValueError("overlapping or out-of-range note") + tmp.add(n.start, n.row, n.lyric, n.length, n.stress) + self.notes, self.next_id, self.beats = tmp.notes, tmp.next_id, beats + bpm = float(data.get('bpm', 100)) + voice = data.get('voice', 'neu') + return (bpm if bpm > 0 else 100.0), (voice if voice in VOICES else 'neu') + +class Player: + def __init__(self): + self.proc = None + self.cmd = None + if sys.platform == 'darwin': + self.cmd = ['afplay'] + elif sys.platform != 'win32': + for c in (['paplay'], ['aplay', '-q'], ['play', '-q'], ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet']): + if shutil.which(c[0]): + self.cmd = c + break + + def play(self, path, loop=False): + self.stop() + try: + if sys.platform == 'win32': + import winsound + flags = winsound.SND_FILENAME | winsound.SND_ASYNC | (winsound.SND_LOOP if loop else 0) + winsound.PlaySound(path, flags) + elif self.cmd: + self.proc = subprocess.Popen(self.cmd + [path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + pass + + def stop(self): + try: + if sys.platform == 'win32': + import winsound + winsound.PlaySound(None, winsound.SND_PURGE) + elif self.proc is not None: + self.proc.terminate() + self.proc.wait(timeout=1) + except Exception: + pass + self.proc = None + +def make_icon(size): + s = size / 64 + img = tk.PhotoImage(width=size, height=size) + def rect(x0, y0, x1, y1, color): + a, b = int(x0 * s), int(y0 * s) + img.put(color, to=(a, b, max(int(x1 * s), a + 1), max(int(y1 * s), b + 1))) + rect(0, 0, 64, 64, C['accent']) + rect(2, 2, 62, 62, C['bg']) + for y in (18, 30, 42, 54): + rect(4, y, 60, y + 1, C['grid']) + rect(7, 38, 25, 46, C['note']) + rect(27, 26, 45, 34, C['note']) + rect(47, 14, 59, 22, C['note']) + rect(7, 38, 25, 40, C['note_edge']) + rect(27, 26, 45, 28, C['note_edge']) + rect(47, 14, 59, 16, C['note_edge']) + rect(25, 4, 27, 60, C['stress']) + return img + +_ASCII_KEYS = sorted(ASCII, key=len, reverse=True) + +def _segment(core): + out, i = [], 0 + while i < len(core): + for k in _ASCII_KEYS: + if core.startswith(k, i): + out.append(k); i += len(k); break + else: + return None + return out + +@lru_cache(maxsize=4096) +def check_phonemes(lyric): + issues = [] + for tok in lyric.split(): + core, _sec, tone, _dm, dev = _strip_marks(tok) + base = ASCII.get(core, core) + if base in VOWELS: + continue + if base in CONSONANTS: + if tone is not None: + issues.append(('warn', tok, f"'{tok}': tone marks only work on vowels (ignored)")) + if dev and base not in SONORANT_VOICELESS: + issues.append(('warn', tok, f"'{tok}': no voiceless form for this consonant ('*' ignored)")) + continue + seg = _segment(core) + if seg and len(seg) > 1: + hint = f" - phonemes need spaces, try '{' '.join(seg)}'" + else: + close = difflib.get_close_matches(core, ASCII.keys(), n=2, cutoff=0.6) + hint = f" - did you mean {' or '.join(repr(c) for c in close)}?" if close else "" + issues.append(('error', tok, f"unknown phoneme '{tok}'{hint}")) + return tuple(issues) + +def describe(lyric): + return '; '.join(m for _s, _t, m in check_phonemes(lyric)) + +def severity(lyric): + sev = [s for s, _t, _m in check_phonemes(lyric)] + return 'error' if 'error' in sev else ('warn' if sev else None) + +def describe_many(text): + msgs = [] + for i, seg in enumerate(text.split('/'), 1): + seg = seg.strip().rstrip('!').strip() + if seg and seg != '_': + d = describe(seg) + if d: + msgs.append(f"note {i}: {d}") + return ' | '.join(msgs) + +def quiet_synth(*a, **k): + return synthesize(*a, quiet=True, **k) + +PLACES = ['labial', 'coronal', 'palatal', 'uvular', 'glottal', 'ultraviolet', 'x-ray', 'gamma'] +CONS_ROWS = [ + ('stop', [('p','b'), ('t','d'), ('k','g'), ('q','gq'), ('?','7'), ('uvt','uvd'), ('xrt','xrd'), ('gmt','gmd')]), + ('fricative', [('f','v'), ('th','dh'), ('kh','gh'), ('x','xg'), ('h','hg'), ('uvs','uvz'), ('xrs','xrz'), ('gms','gmz')]), + ('approximant', [('w','wl'), ('rz','l'), ('j','lj'), ('jq','lq'), ('jh','lh'), ('uvj','uvl'), ('xrj','xrl'), ('gmj','gml')]), + ('tap', [('rp','rb'), ('rh','r'), ('rk','rj'), ('rq','rg'), ('hr','hgr'), ('uvrh','uvr'), ('xrrh','xrr'), ('gmrh','gmr')]), + ('trill', [('bb',), ('rr',), ('jj',), ('gg',), ('hh',), ('uvrr',), ('xrrr',), ('gmrr',)]), + ('nasal', [('m',), ('n',), ('nj',), ('ng',), ('nh',), ('uvn',), ('xrn',), ('gmn',)]), + ('affricate', [(), ('ts','dz'), ('tsh','dzh'), (), (), (), (), ()]), + ('sibilant', [(), ('s','z'), ('sh','zh'), (), (), (), (), ()]), + ('lat. fric.', [(), ('ls','lz'), ('lsh','lzh'), (), (), (), (), ()]), +] +VOWEL_LAYOUT = [ + ('i', 0, 0), ('y', 2, 0), ('eu', 6, 0), ('u', 8, 0), + ('ie', 1, 1), ('yo', 4, 1), ('uo', 7, 1), + ('e', 1, 2), ('oe', 3, 2), ('eo', 5, 2), ('o', 7, 2), + ('ea', 4, 3), + ('ee', 2, 4), ('oee', 3, 4), ('eoo', 5, 4), ('oo', 6, 4), + ('ae', 3, 5), ('eaa', 4, 5), ('aa', 5, 5), + ('a', 4, 6), +] +MARKS = [(':', 'long (stack ::)'), ('+', 'short (stack ++)'), ('*', 'voiceless sonorant'), + ('>h', 'aspirated'), ('>w', 'labialized'), ('>j', 'palatalized'), + ('`', 'tone 1 (highest)'), ("'", 'tone 2'), ('-', 'tone 3'), (',', 'tone 4'), ('.', 'tone 5 (lowest)'), + ('!', 'stress')] + +class PalettePanel(tk.Frame): + def __init__(self, parent, on_pick, bg=None): + super().__init__(parent, bg=bg or C['panel']) + self.on_pick = on_pick + tabs = tk.Frame(self, bg=self['bg']); tabs.pack(fill='x', pady=(0, 4)) + self.pages = {} + for name in ('Consonants', 'Vowels', 'Marks'): + RoundButton(tabs, name, lambda n=name: self.show(n), padx=10, pady=3, font=('TkDefaultFont', 8)).pack(side='left', padx=2) + self.pages[name] = tk.Frame(self, bg=self['bg']) + self.info = tk.Label(self, text="click a symbol to insert its typed spelling", bg=self['bg'], fg=C['dim'], + anchor='w', font=('TkDefaultFont', 8)) + self.info.pack(side='bottom', fill='x', pady=(4, 0)) + self._cons(self.pages['Consonants']); self._vowels(self.pages['Vowels']); self._marks(self.pages['Marks']) + self.show('Consonants') + + def show(self, name): + for n, f in self.pages.items(): + f.pack_forget() + self.pages[name].pack(fill='both', expand=True) + + def _chip(self, parent, key): + sym = ASCII[key] + b = RoundButton(parent, f"{sym} | {key}", lambda k=key: self.on_pick(k, False), padx=5, pady=2, + radius=6, font=('TkDefaultFont', 8)) + extra = f" F1 {VOWELS[sym][0]} Hz, F2 {VOWELS[sym][1]} Hz" if sym in VOWELS else "" + b.bind('<Enter>', lambda e, k=key, s=sym: self.info.config(text=f"{s} | {k}{extra}"), add='+') + return b + + def _cons(self, f): + for c, name in enumerate(PLACES): + tk.Label(f, text=name, bg=self['bg'], fg=C['dim'], font=('TkDefaultFont', 8)).grid(row=0, column=c + 1, pady=(0, 2)) + for r, (manner, cells) in enumerate(CONS_ROWS, start=1): + tk.Label(f, text=manner, bg=self['bg'], fg=C['dim'], font=('TkDefaultFont', 8), anchor='e').grid(row=r, column=0, sticky='e', padx=(0, 4)) + for c, keys in enumerate(cells): + if not keys: + continue + cell = tk.Frame(f, bg=self['bg']); cell.grid(row=r, column=c + 1, padx=2, pady=1) + for k in keys: + self._chip(cell, k).pack(side='left', padx=1) + + def _vowels(self, f): + STEP_X, STEP_Y, LEFT, TOP = 62, 32, 78, 40 + W, H = LEFT + 8 * STEP_X + 40, TOP + 6 * STEP_Y + 48 + cv = tk.Canvas(f, width=W, height=H, bg=self['bg'], highlightthickness=0) + cv.pack() + px = lambda col: LEFT + col * STEP_X + py = lambda row: TOP + row * STEP_Y + cv.create_polygon(px(0) - 24, py(0) - 15, px(8) + 24, py(0) - 15, px(5) + 24, py(6) + 15, px(3) - 24, py(6) + 15, + fill='', outline=C['grid'], width=1) + f2s = [VOWELS[ASCII[k]][1] for k, _, _ in VOWEL_LAYOUT] + cv.create_text(px(0), 8, text=f"<- F2 high ({max(f2s)} Hz, front)", anchor='w', fill=C['dim'], font=('TkDefaultFont', 8)) + cv.create_text(px(8), 8, text=f"F2 low ({min(f2s)} Hz, back) ->", anchor='e', fill=C['dim'], font=('TkDefaultFont', 8)) + rows = {} + for k, c, r in VOWEL_LAYOUT: + rows.setdefault(r, []).append(VOWELS[ASCII[k]][0]) + cv.create_window(px(c), py(r), window=self._chip(cv, k), anchor='center') + for r, f1s in rows.items(): + lo, hi = min(f1s), max(f1s) + cv.create_text(6, py(r), text=f"F1 {lo}" if lo == hi else f"F1 {lo}-{hi}", anchor='w', + fill=C['dim'], font=('TkDefaultFont', 8)) + cv.create_text(6, H - 6, text="F1 low -> high going down | two vowels in a row = diphthong glide", + anchor='sw', fill=C['dim'], font=('TkDefaultFont', 8)) + + def _marks(self, f): + for i, (m, desc) in enumerate(MARKS): + b = RoundButton(f, m, lambda m=m: self.on_pick(m, True), padx=10, pady=3, radius=6) + b.grid(row=i // 6, column=i % 6, padx=3, pady=3) + b.bind('<Enter>', lambda e, m=m, d=desc: self.info.config(text=f"{m} = {d} (attach right after the phoneme, no space)"), add='+') + +def show_palette_window(app): + if getattr(app, '_pal_win', None) and app._pal_win.winfo_exists(): + app._pal_win.lift(); return + w = tk.Toplevel(app, bg=C['panel']); w.title("Symbol palette"); w.transient(app) + app._pal_win = w + def pick(text, mark): + if mark: + app.status.config(text=f"'{text}' is a mark - it attaches to a symbol inside a lyric") + else: + app.play_symbol(text) + panel = PalettePanel(w, pick) + panel.info.config(text="click a symbol to hear it | consonants play C + schwa, then schwa + C + schwa") + panel.pack(padx=10, pady=10) + +def ask(parent, title, prompt, initial="", check=None, checked=False, width=34, palette=False, validate=None): + dlg = tk.Toplevel(parent, bg=C['panel']) + dlg.title(title) + dlg.transient(parent) + dlg.resizable(False, False) + tk.Label(dlg, text=prompt, bg=C['panel'], fg=C['fg']).pack(padx=14, pady=(14, 6)) + var = tk.StringVar(value=initial) + entry = tk.Entry(dlg, textvariable=var, width=width, bg=C['field'], fg=C['fg'], insertbackground=C['fg'], + relief='flat', highlightthickness=1, highlightbackground=C['grid'], highlightcolor=C['accent']) + entry.pack(padx=14, pady=4) + warn_lbl = tk.Label(dlg, text='', bg=C['panel'], fg=C['bad'], font=('TkDefaultFont', 8), wraplength=520, justify='left') + warn_lbl.pack(padx=14, anchor='w') + if validate: + def _check(*_): + warn_lbl.config(text=validate(var.get())) + var.trace_add('write', _check); _check() + check_var = tk.BooleanVar(value=checked) + if check: + tk.Checkbutton(dlg, text=check, variable=check_var, bg=C['panel'], fg=C['fg'], selectcolor=C['field'], + activebackground=C['panel'], activeforeground=C['fg'], highlightthickness=0, + bd=0).pack(anchor='w', padx=14, pady=(2, 0)) + result = {'value': None} + def ok(_=None): + result['value'] = (var.get(), check_var.get()) + dlg.destroy() + def cancel(_=None): + dlg.destroy() + if palette: + def insert(text, mark): + entry.focus_set(); entry.selection_clear() + s, i = var.get(), entry.index('insert') + if i == 0 and s: + i = len(s); entry.icursor(i) + if not mark and i > 0 and s[i - 1] not in ' /': + text = ' ' + text + entry.insert(i, text) + entry.icursor(i + len(text)) + panel = PalettePanel(dlg, insert) + state = {'open': False} + def toggle(): + state['open'] = not state['open'] + if state['open']: + panel.pack(padx=12, pady=(4, 0), before=row) + else: + panel.pack_forget() + dlg.update_idletasks() + row = tk.Frame(dlg, bg=C['panel']) + row.pack(pady=(8, 14)) + if palette: + RoundButton(row, "Palette", toggle, min_w=84).pack(side='left', padx=4) + for text, cmd in (("OK", ok), ("Cancel", cancel)): + RoundButton(row, text, cmd, min_w=84).pack(side='left', padx=4) + dlg.bind('<Return>', ok) + dlg.bind('<Escape>', cancel) + dlg.update_idletasks() + x = parent.winfo_rootx() + (parent.winfo_width() - dlg.winfo_width()) // 2 + y = parent.winfo_rooty() + (parent.winfo_height() - dlg.winfo_height()) // 3 + dlg.geometry(f"+{max(x, 0)}+{max(y, 0)}") + entry.focus_set() + entry.select_range(0, 'end') + dlg.wait_visibility() + dlg.grab_set() + parent.wait_window(dlg) + return result['value'] + +def ask_text(parent, title, prompt, initial=""): + res = ask(parent, title, prompt, initial) + return res[0] if res else None + +class PianoRoll(tk.Tk): + def __init__(self): + super().__init__(className='Pythia') + self.title("Pythia") + self.configure(bg=C['bg']) + self._set_icon() + self.report_callback_exception = self._on_error + self.model = NoteModel(BEATS) + self.cw = CELL_W + self.ch = CELL_H + self.selection = set() + self.path = None + self.dirty = False + self.clip = [] + self.drag = None + self.undo_stack = [] + self.redo_stack = [] + self.player = Player() + self.tmpdir = tempfile.mkdtemp(prefix='energeme_') + atexit.register(shutil.rmtree, self.tmpdir, True) + self.preview_cache = {} + self.loop_range = None + self.playing = False + self.paused = False + self.pause_el = 0.0 + self.play_loop_range = False + self._slice_active = False + self._tick_id = None + self._play_n = 0 + self._temps = [] + self._grid_key = None + self._r0 = 0 + self.cursor = 0.0 + self._r_dragged = False + self.protocol("WM_DELETE_WINDOW", self._close) + + style = ttk.Style(self) + style.theme_use('clam') + for orient in ('Vertical', 'Horizontal'): + style.configure(f'Dark.{orient}.TScrollbar', background=C['field'], troughcolor=C['bg'], + bordercolor=C['bg'], lightcolor=C['field'], darkcolor=C['field'], arrowcolor=C['fg']) + style.map(f'Dark.{orient}.TScrollbar', background=[('active', C['field_hi'])]) + + lbl_kw = dict(bg=C['panel'], fg=C['fg']) + + top = tk.Frame(self, bg=C['panel']) + top.pack(fill='x') + logo = tk.Frame(top, bg=C['panel']) + logo.pack(side='left', fill='y', padx=(8, 6)) + bars = tk.Frame(top, bg=C['panel']) + bars.pack(side='left', fill='x', expand=True) + bar1 = tk.Frame(bars, bg=C['panel']) + bar1.pack(fill='x') + bar2 = tk.Frame(bars, bg=C['panel']) + bar2.pack(fill='x') + entry_kw = dict(bg=C['field'], fg=C['fg'], insertbackground=C['fg'], relief='flat', highlightthickness=1, + highlightbackground=C['grid'], highlightcolor=C['accent']) + + tk.Label(bar1, text="BPM:", **lbl_kw).pack(side='left', padx=(8, 2), pady=6) + self.bpm_var = tk.StringVar(value="100") + self.bpm_var.trace_add('write', lambda *a: hasattr(self, 'ruler') and self._redraw_ruler()) + tk.Entry(bar1, textvariable=self.bpm_var, width=5, **entry_kw).pack(side='left') + + tk.Label(bar1, text="Length:", **lbl_kw).pack(side='left', padx=(14, 2)) + self.len_var = tk.StringVar(value=str(BEATS)) + len_entry = tk.Entry(bar1, textvariable=self.len_var, width=5, **entry_kw) + len_entry.pack(side='left') + len_entry.bind("<Return>", self._apply_length) + len_entry.bind("<FocusOut>", self._apply_length) + tk.Label(bar1, text="beats", bg=C['panel'], fg=C['dim']).pack(side='left', padx=(3, 0)) + + tk.Label(bar1, text="Gender:", **lbl_kw).pack(side='left', padx=(14, 4)) + tk.Label(bar1, text="masc", bg=C['panel'], fg=C['dim']).pack(side='left') + self.gender = tk.IntVar(value=1) + tk.Scale(bar1, from_=0, to=2, resolution=1, orient='horizontal', length=90, showvalue=False, + variable=self.gender, bg=C['panel'], fg=C['fg'], troughcolor=C['bg'], activebackground=C['accent'], + highlightthickness=0, bd=0, sliderrelief='flat', sliderlength=18, width=12).pack(side='left', padx=4) + tk.Label(bar1, text="fem", bg=C['panel'], fg=C['dim']).pack(side='left') + + tk.Label(bar1, text="Zoom:", **lbl_kw).pack(side='left', padx=(14, 4)) + RoundButton(bar1, "-", lambda: self.zoom_at(1 / 1.25), padx=9, icon='zoom_out', + tip="Zoom out (Ctrl -)").pack(side='left', padx=2) + RoundButton(bar1, "+", lambda: self.zoom_at(1.25), padx=9, icon='zoom_in', + tip="Zoom in (Ctrl +)").pack(side='left', padx=2) + + self.play_btn = RoundButton(bar2, "Play", self.toggle_play, icon='play', tip="Play (Space)") + self.play_btn.pack(side='left', padx=(8, 4), pady=4) + RoundButton(bar2, "Stop", self.stop_playback, icon='stop', tip="Stop").pack(side='left', padx=4) + self.loop_on = tk.BooleanVar(value=False) + ToggleButton(bar2, "Loop", self.loop_on, icon='loop', tip="Loop").pack(side='left', padx=(6, 14)) + RoundButton(bar2, "Save", self.save_project, icon='save', tip="Save project (Ctrl+S)").pack(side='left', padx=4) + RoundButton(bar2, "Load", self.load_project, icon='load', tip="Open project (Ctrl+O)").pack(side='left', padx=4) + RoundButton(bar2, "Export WAV", self.export, icon='export', tip="Export WAV (Ctrl+E)").pack(side='left', padx=4) + RoundButton(bar2, "Clear", self.clear, icon='clear', tip="Clear all notes", + hover=C['danger'], press=C['danger_press']).pack(side='left', padx=4) + RoundButton(bar2, "Lyrics", self.lyric_entry, icon='lyrics', tip="Lyric entry (Ctrl+L)").pack(side='left', padx=4) + RoundButton(bar2, "Symbols", lambda: show_palette_window(self), icon='symbols', tip="Symbol palette (F2)").pack(side='left', padx=4) + self.status = tk.Label(bar2, text="", bg=C['panel'], fg=C['dim']) + self.status.pack(side='left', padx=8) + + tk.Label(self, text=HELP, bg=C['panel'], fg=C['dim'], anchor='w', font=('TkDefaultFont', 8) + ).pack(side='bottom', fill='x') + self.bind('<F2>', lambda e: show_palette_window(self)) + self.bind('<F11>', self._toggle_fullscreen) + self.bind('<F1>', lambda e: self._show_help()) + self._build_logo(logo, bar1, bar2) + self._build_menu() + + body = tk.Frame(self, bg=C['bg']) + body.pack(fill='both', expand=True) + body.rowconfigure(1, weight=1) + body.columnconfigure(1, weight=1) + + total_h = len(PITCH_RANGE) * self.ch + tk.Frame(body, width=LABEL_W, height=RULER_H, bg=C['panel']).grid(row=0, column=0) + self.ruler = tk.Canvas(body, width=VIEW_W, height=RULER_H, bg=C['panel'], highlightthickness=0) + self.labels = tk.Canvas(body, width=LABEL_W, height=VIEW_H, bg=C['bg'], highlightthickness=0, + scrollregion=(0, 0, LABEL_W, total_h)) + self.canvas = tk.Canvas(body, width=VIEW_W, height=VIEW_H, bg=C['bg'], highlightthickness=0) + self.vbar = ttk.Scrollbar(body, orient='vertical', style='Dark.Vertical.TScrollbar', command=self._yview) + self.hbar = ttk.Scrollbar(body, orient='horizontal', style='Dark.Horizontal.TScrollbar', command=self._xview) + self.canvas.configure(yscrollcommand=self.vbar.set, xscrollcommand=self._on_xscroll) + self.ruler.grid(row=0, column=1, sticky='ew') + self.labels.grid(row=1, column=0, sticky='ns') + self.canvas.grid(row=1, column=1, sticky='nsew') + self.vbar.grid(row=1, column=2, sticky='ns') + self.hbar.grid(row=2, column=1, sticky='ew') + + self.canvas.bind("<ButtonPress-1>", self.on_press) + self.canvas.bind("<B1-Motion>", self.on_drag) + self.canvas.bind("<ButtonRelease-1>", self.on_release) + self.canvas.bind("<Double-Button-1>", self.on_double) + self.canvas.bind("<Button-3>", self.on_right) + self.canvas.bind("<Motion>", self._hover_note) + self.canvas.bind("<Configure>", self._on_canvas_resize) + self._hover_msg = None + if sys.platform == 'darwin': + self.canvas.bind("<Button-2>", self.on_right) + self.canvas.bind("<Control-Button-1>", self.on_right) + self.canvas.bind("<MouseWheel>", self._wheel) + self.canvas.bind("<Shift-MouseWheel>", self._hwheel) + self.canvas.bind("<Control-MouseWheel>", lambda e: self.zoom_at(1.15 if e.delta > 0 else 1 / 1.15, e.x)) + self.canvas.bind("<Button-4>", lambda e: self._yview('scroll', -2, 'units')) + self.canvas.bind("<Button-5>", lambda e: self._yview('scroll', 2, 'units')) + self.canvas.bind("<Shift-Button-4>", lambda e: self.canvas.xview('scroll', -1, 'units')) + self.canvas.bind("<Shift-Button-5>", lambda e: self.canvas.xview('scroll', 1, 'units')) + self.canvas.bind("<Control-Button-4>", lambda e: self.zoom_at(1.15, e.x)) + self.canvas.bind("<Control-Button-5>", lambda e: self.zoom_at(1 / 1.15, e.x)) + self.ruler.bind("<ButtonPress-1>", self.ruler_press) + self.ruler.bind("<B1-Motion>", self.ruler_drag) + self.ruler.bind("<ButtonRelease-1>", self.ruler_release) + + self.bind("<Key>", self._on_key) + self.bind("<Control-Key>", self._on_ctrl) + + self._sync() + self._maximize() + self._update_title() + + def _build_logo(self, logo, bar1, bar2): + self.update_idletasks() + h1, h2 = max(bar1.winfo_reqheight(), 16), max(bar2.winfo_reqheight(), 16) + icon = load_scaled(asset('icon.png') or '', h1) or make_icon(h1) + title = load_scaled(asset('title.png') or '', h2) + self._logo_imgs = [i for i in (icon, title) if i] + if title is None: + font = tkfont.Font(family='TkDefaultFont', size=max(8, int(h2 * 0.32)), weight='bold') + w2 = font.measure("PYTHIA") + 4 + else: + w2 = title.width() + col_w = max(icon.width(), w2) + icon_slot = tk.Frame(logo, bg=C['panel'], width=col_w, height=h1) + title_slot = tk.Frame(logo, bg=C['panel'], width=col_w, height=h2) + for slot in (icon_slot, title_slot): + slot.pack() + slot.pack_propagate(False) + tk.Label(icon_slot, image=icon, bg=C['panel'], bd=0).pack(side='left') + if title is not None: + tk.Label(title_slot, image=title, bg=C['panel'], bd=0).pack(side='left') + else: + tk.Label(title_slot, text="PYTHIA", font=font, bg=C['panel'], fg=C['accent']).pack(side='left') + + def _maximize(self): + for apply in (lambda: self.state('zoomed'), + lambda: self.attributes('-zoomed', True)): + try: + apply() + return + except tk.TclError: + pass + self.geometry(f"{self.winfo_screenwidth()}x{self.winfo_screenheight()}+0+0") + + def _toggle_fullscreen(self, event=None): + self.attributes('-fullscreen', not bool(self.attributes('-fullscreen'))) + return 'break' + + def _note_fs(self): + return 7 if self.ch < 22 else 9 if self.ch < 30 else 10 + + def _on_canvas_resize(self, event): + new = max(CELL_H, min(MAX_CH, event.height // len(PITCH_RANGE))) + if new != self.ch: + self.ch = new + self._sync() + + def delete_selected(self): + if self.selection: + before = self.model.snapshot() + self.model.remove(list(self.selection)) + self.selection = set() + self._commit(before) + self.draw_notes() + + def select_all(self): + self.selection = set(self.model.notes) + self.draw_notes() + + def _build_menu(self): + kw = dict(bg=C['panel'], fg=C['fg'], activebackground=C['accent'], activeforeground='#ffffff', + bd=0, relief='flat', tearoff=0) + bar = tk.Menu(self, **kw) + def add(label, items): + m = tk.Menu(bar, **kw) + for it in items: + if it is None: + m.add_separator() + else: + m.add_command(label=it[0], accelerator=it[1], command=it[2]) + bar.add_cascade(label=label, menu=m) + add("File", [("New", "Ctrl+N", self.new_project), ("Open...", "Ctrl+O", self.load_project), + ("Save", "Ctrl+S", self.save_project), ("Save As...", "Ctrl+Shift+S", lambda: self.save_project(True)), + None, ("Export WAV...", "Ctrl+E", self.export), None, ("Quit", "Ctrl+Q", self._close)]) + add("Edit", [("Undo", "Ctrl+Z", self.undo), ("Redo", "Ctrl+Shift+Z", self.redo), None, + ("Cut", "Ctrl+X", lambda: self.copy_notes(cut=True)), ("Copy", "Ctrl+C", self.copy_notes), + ("Paste", "Ctrl+V", self.paste_notes), ("Duplicate", "Ctrl+D", self.duplicate_notes), + ("Delete", "Del", self.delete_selected), None, ("Select All", "Ctrl+A", self.select_all), None, + ("Lyric Entry...", "Ctrl+L", self.lyric_entry), ("Symbol Palette", "F2", lambda: show_palette_window(self)), + None, ("Clear All Notes", "", self.clear)]) + add("View", [("Zoom In", "Ctrl++", lambda: self.zoom_at(1.25)), ("Zoom Out", "Ctrl+-", lambda: self.zoom_at(1 / 1.25)), + ("Reset Zoom", "Ctrl+0", lambda: self.zoom_at(CELL_W / self.cw)), None, + ("Jump to Start", "Home", lambda: (self.seek(0.0), self.canvas.xview_moveto(0))), + ("Jump to End", "End", lambda: self.canvas.xview_moveto(1)), None, + ("Fullscreen", "F11", self._toggle_fullscreen)]) + add("Help", [("Keyboard Shortcuts", "F1", self._show_help), + ("About Pythia", "", lambda: messagebox.showinfo( + "About Pythia", "Pythia - piano roll for the energeme synthesizer\n(Ascended Ancient / Alteran)"))]) + self.config(menu=bar) + + def _show_help(self): + messagebox.showinfo("Shortcuts", HELP_FULL, parent=self) + + def _on_error(self, exc, val, tb): + with open(LOG, 'a', encoding='utf-8') as f: + f.write(''.join(traceback.format_exception(exc, val, tb)) + '\n') + try: + self.status.config(text=f"error: {val} (see piano_roll_error.log)") + except Exception: + pass + + def _set_icon(self): + ico, png = asset('icon.ico'), asset('icon.png') + if sys.platform == 'win32' and ico: + try: + self.iconbitmap(ico) + return + except Exception: + pass + try: + self._icons = [tk.PhotoImage(file=png)] if png else [make_icon(64), make_icon(32)] + self.iconphoto(True, *self._icons) + except Exception: + pass + + def _xview(self, *args): + self.canvas.xview(*args) + + def _on_xscroll(self, first, last): + self.hbar.set(first, last) + self.ruler.xview_moveto(first) + + def _yview(self, *args): + self.canvas.yview(*args) + self.labels.yview(*args) + + def _wheel(self, event): + self._yview('scroll', -1 if event.delta > 0 else 1, 'units') + + def _hwheel(self, event): + self.canvas.xview('scroll', -1 if event.delta > 0 else 1, 'units') + + def draw_grid(self): + self.canvas.delete("grid") + self.labels.delete("all") + total_w = self.model.beats * self.cw + total_h = len(PITCH_RANGE) * self.ch + self.canvas.configure(scrollregion=(0, 0, total_w, total_h)) + self.labels.configure(scrollregion=(0, 0, LABEL_W, total_h)) + self._grid_key = (self.model.beats, self.cw, self.ch) + for row, semitone in enumerate(PITCH_RANGE): + y = row * self.ch + name = semitone_to_name(semitone) + if '#' in name: + bg = C['row_b'] + elif name.startswith('C'): + bg = C['row_c'] + else: + bg = C['row_w'] + self.canvas.create_rectangle(0, y, total_w, y + self.ch, fill=bg, outline=C['grid'], tags="grid") + self.labels.create_rectangle(0, y, LABEL_W, y + self.ch, fill=bg, outline=C['grid']) + self.labels.create_text(LABEL_W - 5, y + self.ch / 2, text=name, anchor='e', fill=C['dim'], + font=('TkDefaultFont', 8)) + for b in range(self.model.beats + 1): + x = b * self.cw + self.canvas.create_line(x, 0, x, total_h, fill=C['bar'] if b % 4 == 0 else C['grid'], tags="grid") + self.canvas.tag_lower("grid") + + def _clock(self, beat, tenths=False): + sec = beat * 60.0 / self._bpm() + if tenths: + m, s = divmod(round(sec, 1), 60) + return f"{int(m)}:{s:04.1f}" + s = int(round(sec)) + return f"{s // 60}:{s % 60:02d}" + + def draw_ruler(self): + self.ruler.delete("all") + total_w = self.model.beats * self.cw + self.ruler.configure(scrollregion=(0, 0, total_w, RULER_H)) + every = 1 if self.cw >= 50 else (4 if self.cw >= 20 else 8) + tenths = every * 60.0 / self._bpm() < 5 + for b in range(self.model.beats + 1): + x = b * self.cw + labeled = b % every == 0 + self.ruler.create_line(x, RULER_H - (14 if labeled else 6), x, RULER_H, fill=C['bar']) + if labeled: + self.ruler.create_text(x + 3, 2, text=str(b), anchor='nw', fill=C['dim'], font=('TkDefaultFont', 7)) + self.ruler.create_text(x + 3, 13, text=self._clock(b, tenths), anchor='nw', fill=C['bar'], font=('TkDefaultFont', 7)) + + def _redraw_ruler(self): + self.draw_ruler() + self.draw_range() + self.draw_cursor() + + def draw_cursor(self): + self.canvas.delete("cursor") + self.ruler.delete("cursor") + x = self.cursor * self.cw + self.ruler.create_polygon(x - 6, RULER_H - 11, x + 6, RULER_H - 11, x, RULER_H - 1, + fill=C['note_edge'], outline='', tags="cursor") + self.ruler.create_line(x, 0, x, RULER_H, fill=C['note_edge'], tags="cursor") + self.canvas.create_line(x, 0, x, len(PITCH_RANGE) * self.ch, fill=C['note_edge'], dash=(4, 3), tags="cursor") + self.canvas.tag_lower("cursor") + self.canvas.tag_lower("grid") + + def draw_range(self): + self.canvas.delete("range") + self.ruler.delete("range") + if not self.loop_range: + return + a, b = self.loop_range + total_h = len(PITCH_RANGE) * self.ch + self.canvas.create_rectangle(a * self.cw, 0, b * self.cw, total_h, fill=C['accent'], stipple='gray12', + outline=C['accent'], tags="range") + self.ruler.create_rectangle(a * self.cw, 0, b * self.cw, RULER_H, fill=C['accent'], stipple='gray50', + outline='', tags="range") + self.canvas.tag_lower("range") + self.canvas.tag_lower("grid") + self.ruler.tag_lower("range") + + def _sync(self): + if (self.model.beats, self.cw, self.ch) != self._grid_key: + self.draw_grid() + self.draw_ruler() + self.loop_range = None if not self.loop_range or self.loop_range[1] > self.model.beats else self.loop_range + self.draw_range() + self.draw_cursor() + self.len_var.set(str(self.model.beats)) + self.draw_notes() + + def zoom_at(self, factor, sx=None): + if sx is None: + sx = self.canvas.winfo_width() / 2 + new = min(MAX_CW, max(MIN_CW, round(self.cw * factor))) + if new == self.cw: + return + beat = self.canvas.canvasx(sx) / self.cw + self.cw = new + self._sync() + self.canvas.xview_moveto(max(0, beat * self.cw - sx) / (self.model.beats * self.cw)) + + def _apply_length(self, event=None): + try: + v = int(float(self.len_var.get())) + except ValueError: + self.len_var.set(str(self.model.beats)) + return + need = max([math.ceil(n.start + n.length - 1e-9) for n in self.model.notes.values()] + [MIN_BEATS]) + v = max(need, min(MAX_BEATS, v)) + if v != self.model.beats: + before = self.model.snapshot() + self.model.beats = v + self._commit(before) + self._sync() + + def _ruler_beat(self, event): + b = round(self.ruler.canvasx(event.x) / self.cw) + return min(max(b, 0), self.model.beats) + + def ruler_press(self, event): + self._r0 = self._ruler_beat(event) + self._r_press_x, self._r_dragged = event.x, False + self._r_click = min(max(snap(self.ruler.canvasx(event.x) / self.cw), 0.0), float(self.model.beats)) + + def ruler_drag(self, event): + if not self._r_dragged and abs(event.x - self._r_press_x) < 4: + return + self._r_dragged = True + b = self._ruler_beat(event) + self.loop_range = (min(self._r0, b), max(self._r0, b)) if b != self._r0 else None + self.draw_range() + + def ruler_release(self, event): + if self._r_dragged: + return + self.loop_range = None + self.draw_range() + self.seek(self._r_click) + + def seek(self, beat): + beat = min(max(beat, 0.0), float(self.model.beats)) + self.cursor = beat + self.draw_cursor() + if self.playing or self.paused: + end = self.play_a + self.play_dur * self.play_bpm / 60 + if not self.play_loop_range and self.play_a - 1e-9 <= beat < end - 0.05: + el = (beat - self.play_a) * 60 / self.play_bpm + was_playing = self.playing + if was_playing: + if self._tick_id is not None: + self.after_cancel(self._tick_id) + self._tick_id = None + self.player.stop() + self.pause_el = el + self._draw_playhead(el) + if was_playing: + self.resume_playback() + elif self.playing: + self.play() + else: + self.stop_playback() + + def draw_notes(self): + self.canvas.delete("notes") + for nid, n in self.model.notes.items(): + self._draw_note(n, nid in self.selection) + d = self.drag + if d and d['mode'] == 'new': + x = d['start'] * self.cw + y = d['row'] * self.ch + self.canvas.create_rectangle(x + 1, y + 1, x + d['len'] * self.cw - 1, y + self.ch - 1, + fill=C['ghost'], outline=C['note_edge'], dash=(3, 2), stipple='gray50', + tags="notes") + elif d and d['mode'] == 'box': + self.canvas.create_rectangle(d['x0'], d['y0'], d['x1'], d['y1'], outline=C['accent'], dash=(4, 3), + tags="notes") + + def _draw_note(self, n, selected): + x = n.start * self.cw + y = n.row * self.ch + w = n.length * self.cw + sev = severity(n.lyric) + outline = C['bad'] if sev == 'error' else C['warn'] if sev == 'warn' else (C['sel_edge'] if selected else C['note_edge']) + self.canvas.create_rectangle(x + 1, y + 1, x + w - 1, y + self.ch - 1, + fill=C['sel'] if selected else C['note'], + outline=outline, width=2 if (selected or sev) else 1, + dash=(3, 2) if sev == 'error' else None, tags="notes") + edge = min(EDGE, w / 2) + self.canvas.create_rectangle(x + w - edge, y + 3, x + w - 2, y + self.ch - 3, fill=C['note_edge'], + outline='', stipple='gray25', tags="notes") + self.canvas.create_text(x + 4, y + self.ch / 2, text=n.lyric, anchor='w', fill=C['note_text'], + font=('TkDefaultFont', self._note_fs()), tags="notes") + if sev and w > 28: + off = 12 if (n.stress and w > 40) else 0 + self.canvas.create_text(x + w - edge - 3 - off, y + self.ch / 2, text="?" if sev == 'error' else "~", anchor='e', + fill=C['bad'] if sev == 'error' else C['warn'], font=('TkDefaultFont', 9, 'bold'), tags="notes") + if n.stress and w > 40: + self.canvas.create_text(x + w - edge - 3, y + self.ch / 2, text="!", anchor='e', fill=C['stress'], + font=('TkDefaultFont', 9, 'bold'), tags="notes") + + def _cell(self, event): + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + return cx, cy, cx / self.cw, int(cy // self.ch) + + def _mark_dirty(self, dirty=True): + if dirty and self.paused: + self.stop_playback() + self.dirty = dirty + self._update_title() + + def _update_title(self): + name = os.path.basename(self.path) if self.path else "untitled" + self.title(f"{'* ' if self.dirty else ''}{name} - Pythia") + + def _commit(self, before): + if self.model.snapshot() != before: + self._mark_dirty() + self.undo_stack.append(before) + self.redo_stack = [] + if len(self.undo_stack) > 200: + self.undo_stack.pop(0) + + def undo(self, event=None): + if not self.undo_stack: + return 'break' + self.redo_stack.append(self.model.snapshot()) + self.model.restore(self.undo_stack.pop()) + self.selection &= set(self.model.notes) + self._sync() + self._mark_dirty() + return 'break' + + def redo(self, event=None): + if not self.redo_stack: + return 'break' + self.undo_stack.append(self.model.snapshot()) + self.model.restore(self.redo_stack.pop()) + self.selection &= set(self.model.notes) + self._sync() + self._mark_dirty() + return 'break' + + def _typing(self, event): + return isinstance(event.widget, (tk.Entry, tk.Scale)) + + def _on_ctrl(self, event): + if self._typing(event): + return + k, shift = event.keysym.lower(), bool(event.state & 0x1) + actions = { + 'z': self.redo if shift else self.undo, 'y': self.redo, + 'equal': lambda: self.zoom_at(1.25), 'plus': lambda: self.zoom_at(1.25), + 'minus': lambda: self.zoom_at(1 / 1.25), '0': lambda: self.zoom_at(CELL_W / self.cw), + 's': lambda: self.save_project(save_as=shift), 'o': self.load_project, 'n': self.new_project, + 'e': self.export, 'q': self._close, 'l': self.lyric_entry, 'a': self.select_all, + 'c': self.copy_notes, 'x': lambda: self.copy_notes(cut=True), 'v': self.paste_notes, + 'd': self.duplicate_notes, + } + if k in actions: + actions[k]() + return 'break' + + def _on_key(self, event): + if self._typing(event) or event.state & 0x4: + return + k = event.keysym + shift = bool(event.state & 0x1) + if k in ('Delete', 'BackSpace'): + self.delete_selected() + elif k == 'space': + self.toggle_play() + elif k == 'Escape': + self.selection = set() + self.draw_notes() + elif k in ('Return', 'KP_Enter'): + if len(self.selection) == 1: + self._edit_note(next(iter(self.selection))) + elif k == 'Home': + self.seek(0.0) + self.canvas.xview_moveto(0) + elif k == 'End': + self.canvas.xview_moveto(1) + elif k in ('Left', 'Right'): + step = 1.0 if shift else SNAP + self._nudge(-step if k == 'Left' else step, 0) + elif k in ('Up', 'Down'): + step = 12 if shift else 1 + self._nudge(0, -step if k == 'Up' else step) + + def _nudge(self, db, dr): + if not self.selection: + return + before = self.model.snapshot() + ids = sorted(self.selection, key=lambda i: self.model.notes[i].start) + if self.model.move(ids, db, dr): + self._commit(before) + self.draw_notes() + if dr: + self.preview(self.model.notes[ids[0]]) + + def on_press(self, event): + self.canvas.focus_set() + self.drag = None + cx, cy, raw, row = self._cell(event) + if not (0 <= raw < self.model.beats and 0 <= row < len(PITCH_RANGE)): + return + shift = bool(event.state & 0x1) + hit = self.model.note_at(raw, row) + before = self.model.snapshot() + if hit is None: + if shift: + self.drag = {'mode': 'box', 'x0': cx, 'y0': cy, 'x1': cx, 'y1': cy} + elif self.model.note_at(raw) is None: + start = min(max(snap(raw - SNAP / 2), 0.0), round(self.model.beats - SNAP, 1)) + if self.model.free(start, SNAP): + self.selection = set() + self.drag = {'mode': 'new', 'start': start, 'row': row, + 'len': min(1.0, self.model.room(start)), 'snap': before} + else: + n = self.model.notes[hit] + x_end = (n.start + n.length) * self.cw + if shift: + self.selection ^= {hit} + elif cx >= x_end - min(EDGE, n.length * self.cw / 2): + if hit not in self.selection: + self.selection = {hit} + self.drag = {'mode': 'resize', 'id': hit, 'snap': before} + else: + narrow = len(self.selection) > 1 and hit in self.selection + if hit not in self.selection: + self.selection = {hit} + ids = list(self.selection) + self.drag = {'mode': 'move', 'ids': ids, 'orig': {i: self.model.notes[i] for i in ids}, + 'press_raw': raw, 'press_row': row, 'lead': hit, 'cur': (0.0, 0), + 'narrow': narrow, 'snap': before} + self.draw_notes() + + def on_drag(self, event): + d = self.drag + if not d: + return + cx, cy, raw, row = self._cell(event) + m = d['mode'] + if m == 'new': + d['len'] = max(SNAP, min(snap(raw - d['start']), self.model.room(d['start']))) + elif m == 'resize': + n = self.model.notes[d['id']] + new_len = max(SNAP, min(snap(raw - n.start), self.model.room(n.start, ignore=(d['id'],)))) + self.model.notes[d['id']] = n._replace(length=new_len) + elif m == 'move': + db = snap(raw - d['press_raw']) + dr = row - d['press_row'] + if (db, dr) != d['cur'] and self.model.move(d['ids'], db, dr, d['orig']): + row_changed = dr != d['cur'][1] + d['cur'] = (db, dr) + if row_changed: + self.preview(self.model.notes[d['lead']]) + elif m == 'box': + d['x1'], d['y1'] = cx, cy + self.draw_notes() + + def on_release(self, event): + d = self.drag + self.drag = None + if not d: + return + m = d['mode'] + if m == 'new': + res = self._ask_lyric("Lyric") + if res: + lyric, stress = res + if lyric: + nid = self.model.add(d['start'], d['row'], lyric, d['len'], stress) + self.selection = {nid} + self._commit(d['snap']) + self.preview(self.model.notes[nid]) + self._report(lyric) + elif m == 'move': + if d['cur'] == (0.0, 0) and d['narrow']: + self.selection = {d['lead']} + self._commit(d['snap']) + elif m == 'resize': + self._commit(d['snap']) + elif m == 'box': + x0, x1 = sorted((d['x0'], d['x1'])) + y0, y1 = sorted((d['y0'], d['y1'])) + for nid, n in self.model.notes.items(): + ny0 = n.row * self.ch + if (n.start * self.cw < x1 and (n.start + n.length) * self.cw > x0 + and ny0 < y1 and ny0 + self.ch > y0): + self.selection.add(nid) + self.draw_notes() + + def on_double(self, event): + self.drag = None + cx, cy, raw, row = self._cell(event) + nid = self.model.note_at(raw, row) + if nid is not None: + self._edit_note(nid) + + def _ask_lyric(self, title, initial="", stress=False): + res = ask(self, title, "Phoneme(s) for this note (Alteran spelling):", initial=initial, + check="Stressed (!)", checked=stress, palette=True, + validate=lambda t: describe(parse_lyric(t)[0])) + return parse_lyric(res[0], res[1]) if res else None + + def _edit_note(self, nid): + n = self.model.notes[nid] + res = self._ask_lyric("Edit note", n.lyric, n.stress) + if not res or not res[0]: + return + lyric, stress = res + before = self.model.snapshot() + self.model.notes[nid] = n._replace(lyric=lyric, stress=stress) + self.selection = {nid} + self._commit(before) + self.draw_notes() + self.preview(self.model.notes[nid]) + self._report(lyric) + + def _group(self, ids): + notes = sorted((self.model.notes[i] for i in ids), key=lambda n: n.start) + origin = notes[0].start + return [(round(n.start - origin, 1), n.row, n.lyric, n.length, n.stress) for n in notes] + + def _place_group(self, items, base): + before = self.model.snapshot() + span = max(s + l for s, _r, _ly, l, _st in items) + b = round(base, 1) + while b + span <= self.model.beats + 1e-9: + if all(self.model.free(round(b + s, 1), l) for s, _r, _ly, l, _st in items): + self.selection = {self.model.add(b + s, r, ly, l, st) for s, r, ly, l, st in items} + self._commit(before) + self.draw_notes() + return True + b = round(b + 1.0, 1) + self.status.config(text="no room to paste here") + return False + + def copy_notes(self, cut=False): + if not self.selection: + self.status.config(text="select some notes first") + return + self.clip = self._group(self.selection) + self.status.config(text=f"{'cut' if cut else 'copied'} {len(self.clip)} note(s)") + if cut: + before = self.model.snapshot() + self.model.remove(list(self.selection)) + self.selection = set() + self._commit(before) + self.draw_notes() + + def paste_notes(self): + if not self.clip: + self.status.config(text="nothing to paste") + return + base = max(0, round(self.canvas.canvasx(0) / self.cw)) + if self._place_group(self.clip, base): + self.status.config(text=f"pasted {len(self.clip)} note(s)") + + def duplicate_notes(self): + if not self.selection: + self.status.config(text="select some notes first") + return + end = max(self.model.notes[i].start + self.model.notes[i].length for i in self.selection) + items = self._group(self.selection) + if self._place_group(items, end): + self.status.config(text=f"duplicated {len(items)} note(s)") + + def _lyric_targets(self): + ids = self.selection or set(self.model.notes) + return sorted(ids, key=lambda i: (self.model.notes[i].start, self.model.notes[i].row)) + + def apply_lyrics(self, text, ids): + sylls = [s.strip() for s in text.split('/')] + if sylls and not sylls[-1]: + sylls.pop() + before = self.model.snapshot() + changed = 0 + for nid, s in zip(ids, sylls): + if s in ('_', ''): + continue + lyric, stress = parse_lyric(s) + if not lyric: + continue + self.model.notes[nid] = self.model.notes[nid]._replace(lyric=lyric, stress=stress) + changed += 1 + if changed: + self._commit(before) + self.draw_notes() + return changed, max(0, len(sylls) - len(ids)), max(0, len(ids) - len(sylls)) + + def lyric_entry(self): + ids = self._lyric_targets() + if not ids: + self.status.config(text="place some notes first") + return + scope = "selected" if self.selection else "all" + initial = ' / '.join(score_lyric(self.model.notes[i]) for i in ids) + res = ask(self, "Lyric entry", + f"Syllables for {'all ' + str(len(ids)) if scope == 'all' else str(len(ids)) + ' selected'} notes, in time order.\n" + "Separate notes with / (use _ to keep a note as is, ! at the end = stress):", + initial=initial, width=70, palette=True, validate=describe_many) + if not res: + return + changed, extra, untouched = self.apply_lyrics(res[0], ids) + msg = f"lyrics set on {changed} note(s)" + bad = self._bad_summary() + if extra: + msg += f"; {extra} extra syllable(s) ignored" + if untouched: + msg += f"; {untouched} note(s) left unchanged" + if bad: + msg += f" | {bad}" + self.status.config(text=msg) + + def _hover_note(self, event): + if self.drag: + return + _cx, _cy, raw, row = self._cell(event) + nid = self.model.note_at(raw, row) + msg = describe(self.model.notes[nid].lyric) if nid is not None else '' + if msg: + self._hover_msg = msg + self.status.config(text=msg) + elif self._hover_msg is not None: + if self.status.cget('text') == self._hover_msg: + self.status.config(text="") + self._hover_msg = None + + def _bad_summary(self): + errs = sum(1 for n in self.model.notes.values() if severity(n.lyric) == 'error') + return f"{errs} note(s) with unknown phonemes (red) play as silence" if errs else "" + + def _report(self, lyric): + msg = describe(lyric) + if msg: + self.status.config(text=msg) + self._hover_msg = None + + def on_right(self, event): + cx, cy, raw, row = self._cell(event) + nid = self.model.note_at(raw, row) + if nid is None: + return + before = self.model.snapshot() + self.model.remove([nid]) + self.selection.discard(nid) + self._commit(before) + self.draw_notes() + + def _bpm(self): + try: + b = float(self.bpm_var.get()) + except ValueError: + return 100.0 + return b if b > 0 else 100.0 + + def _cached_wav(self, key, score, bpm, voice): + path = self.preview_cache.get(key) + if path is None: + path = os.path.join(self.tmpdir, f"pv{len(self.preview_cache)}.wav") + quiet_synth(score, path, mode='vocaloid', bpm=bpm, voice=voice) + self.preview_cache[key] = path + return path + + def preview(self, note): + if self.playing: + self.stop_playback() + name = semitone_to_name(PITCH_RANGE[note.row]) + voice, bpm = VOICES[self.gender.get()], self._bpm() + text = score_lyric(note) + try: + path = self._cached_wav((text, name, voice, bpm), [(text, name, round(PREVIEW_SECS * bpm / 60, 3))], bpm, voice) + except Exception as e: + self.status.config(text=f"preview failed: {e}") + return + self.player.play(path) + + def play_symbol(self, key): + sym = ASCII.get(key) + if sym in VOWELS: + parts = [(key, 0.7)] + elif sym in CONSONANTS: + parts = [(f"{key} ea", 0.6), None, (f"ea {key} ea", 0.9)] + else: + return + if self.playing: + self.stop_playback() + voice = VOICES[self.gender.get()] + score = [sleep(0.25) if p is None else (p[0], 'C4', p[1]) for p in parts] + try: + path = self._cached_wav(('symbol', key, voice), score, 60, voice) + except Exception as e: + self.status.config(text=f"could not play '{key}': {e}") + return + self.player.play(path) + self.status.config(text=f"{sym} ({key})") + + def clear(self): + n = len(self.model.notes) + if not n: + self.status.config(text="nothing to clear") + return + if not messagebox.askyesno("Clear all notes", f"Remove all {n} note{'s' if n != 1 else ''}?\n\n(You can undo this with Ctrl+Z.)", + icon='warning', default='no', parent=self): + return + before = self.model.snapshot() + self.model.clear() + self.selection = set() + self.status.config(text="") + self._commit(before) + self.draw_notes() + + def _render(self, path): + score = self.model.build_score() + if not score: + self.status.config(text="no notes placed yet") + return None + self.status.config(text="") + quiet_synth(score, path, mode='vocaloid', bpm=self._bpm(), voice=VOICES[self.gender.get()]) + self.status.config(text=self._bad_summary()) + return path + + def play(self): + self.stop_playback() + a, b = self.loop_range if self.loop_range else (self.cursor, float(self.model.beats)) + score = self.model.build_score(a, b, pad=bool(self.loop_range)) + if not any(item[0] is not None for item in score): + self.status.config(text="no notes in range" if self.loop_range else ( + "no notes after the start position" if self.cursor > 0 and self.model.notes else "no notes placed yet")) + return + self.status.config(text="rendering...") + self.update_idletasks() + self._drop_temps() + self._play_n += 1 + path = os.path.join(self.tmpdir, f"play{self._play_n}.wav") + bpm = self._bpm() + try: + dur = quiet_synth(score, path, mode='vocaloid', bpm=bpm, voice=VOICES[self.gender.get()]) + self._temps.append(path) + except Exception as e: + self.status.config(text=f"render failed: {e}") + return + self.status.config(text=self._bad_summary()) + self.play_a, self.play_bpm, self.play_dur = a, bpm, dur + self.play_loop_range = bool(self.loop_range) + self.play_path, self.play_loop = path, self.loop_on.get() + self.playing, self.paused, self._slice_active = True, False, False + self.player.play(path, loop=self.play_loop and sys.platform == 'win32') + self.t0 = time.perf_counter() + self._set_play_button(True) + self._tick() + + def _drop_temps(self): + for p in self._temps: + try: + os.remove(p) + except OSError: + pass + self._temps = [] + + def _set_play_button(self, playing): + btn = getattr(self, 'play_btn', None) + if btn is not None: + btn.set_icon('pause' if playing else 'play', "Pause" if playing else "Play", + "Pause (Space)" if playing else "Play (Space)") + + def toggle_play(self): + if self.playing: + self.pause_playback() + elif self.paused: + self.resume_playback() + else: + self.play() + + def pause_playback(self): + self.pause_el = min(time.perf_counter() - self.t0, self.play_dur) + self.playing, self.paused = False, True + if self._tick_id is not None: + self.after_cancel(self._tick_id) + self._tick_id = None + self.player.stop() + self._set_play_button(False) + + def resume_playback(self): + el = self.pause_el + try: + with wave.open(self.play_path, 'rb') as w: + start = int(el * w.getframerate()) + if w.getnframes() - start < 800: + self.stop_playback() + return + w.setpos(start) + params, frames = w.getparams(), w.readframes(w.getnframes() - start) + self._play_n += 1 + path = os.path.join(self.tmpdir, f"resume{self._play_n}.wav") + with wave.open(path, 'wb') as o: + o.setparams(params) + o.writeframes(frames) + self._temps.append(path) + except Exception as e: + self.status.config(text=f"resume failed: {e}") + return + self.paused, self.playing, self._slice_active = False, True, True + self.player.play(path, loop=False) + self.t0 = time.perf_counter() - el + self._set_play_button(True) + self._tick() + + def stop_playback(self): + self.playing, self.paused, self._slice_active = False, False, False + if self._tick_id is not None: + self.after_cancel(self._tick_id) + self._tick_id = None + self.player.stop() + self.canvas.delete("playhead") + self._set_play_button(False) + + def _draw_playhead(self, el): + x = (self.play_a + el * self.play_bpm / 60) * self.cw + self.canvas.delete("playhead") + self.canvas.create_line(x, 0, x, len(PITCH_RANGE) * self.ch, fill=C['stress'], width=2, tags="playhead") + return x + + def _tick(self): + if not self.playing: + return + el = time.perf_counter() - self.t0 + if el >= self.play_dur: + if not self.play_loop: + self.stop_playback() + return + if sys.platform != 'win32' or self._slice_active: + self.player.play(self.play_path, loop=(sys.platform == 'win32')) + self._slice_active = False + self.t0 += self.play_dur + el -= self.play_dur + x = self._draw_playhead(el) + left = self.canvas.canvasx(0) + right = self.canvas.canvasx(self.canvas.winfo_width()) + if x < left or x > right - 30: + self.canvas.xview_moveto(max(0, x - 40) / (self.model.beats * self.cw)) + self._tick_id = self.after(30, self._tick) + + def save_project(self, save_as=False): + path = self.path + if save_as or not path: + path = filedialog.asksaveasfilename(defaultextension='.json', filetypes=[('Energeme project', '*.json')], + initialfile=os.path.basename(self.path) if self.path else 'song.json') + if not path: + return False + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(self.model.to_dict(self._bpm(), VOICES[self.gender.get()]), f, indent=1, ensure_ascii=False) + except OSError as e: + self.status.config(text=f"save failed: {e}") + return False + self.path = path + self._mark_dirty(False) + self.status.config(text=f"saved {os.path.basename(path)}") + return True + + def _confirm_discard(self): + if not self.dirty: + return True + ans = messagebox.askyesnocancel("Unsaved changes", "Save changes to this project first?", parent=self) + if ans is None: + return False + return self.save_project() if ans else True + + def new_project(self): + if not self._confirm_discard(): + return + self.stop_playback() + self.model.notes, self.model.next_id, self.model.beats = {}, 1, BEATS + self.selection = set() + self.undo_stack, self.redo_stack = [], [] + self.loop_range = None + self.path = None + self._grid_key = None + self._sync() + self._mark_dirty(False) + self.status.config(text="new project") + + def load_project(self): + if not self._confirm_discard(): + return + path = filedialog.askopenfilename(filetypes=[('Energeme project', '*.json')]) + if not path: + return + try: + with open(path, encoding='utf-8') as f: + data = json.load(f) + bpm, voice = self.model.load_dict(data) + except (OSError, ValueError, KeyError, TypeError) as e: + self.status.config(text=f"load failed: {e}") + return + self.stop_playback() + self.bpm_var.set(f"{bpm:g}") + self.gender.set(VOICES.index(voice)) + self.selection = set() + self.undo_stack, self.redo_stack = [], [] + self.loop_range = None + self._grid_key = None + self._sync() + self.path = path + self._mark_dirty(False) + self.status.config(text=f"loaded {os.path.basename(path)}") + + def export(self): + path = ask_text(self, "Export", "Filename to save as:", initial="song.wav") + if not path: + return + if not path.lower().endswith('.wav'): + path += '.wav' + try: + if self._render(path): + self.status.config(text=f"exported {os.path.basename(path)}") + except Exception as e: + self.status.config(text=f"export failed: {e}") + + def _close(self): + if not self._confirm_discard(): + return + self.stop_playback() + self.destroy() + +if __name__ == '__main__': + try: + PianoRoll().mainloop() + except Exception: + with open(LOG, 'a', encoding='utf-8') as f: + f.write(traceback.format_exc() + '\n') + raise |
