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('', self._enter, add='+') widget.bind('', self._leave, add='+') widget.bind('', 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('', lambda e: self._set(hover=True)) self.bind('', lambda e: self._set(hover=False)) self.bind('', lambda e: self.itemconfig(self.shape, fill=self.press_c)) self.bind('', 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('', 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('', 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('', ok) dlg.bind('', 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("", self._apply_length) len_entry.bind("", 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('', lambda e: show_palette_window(self)) self.bind('', self._toggle_fullscreen) self.bind('', 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("", self.on_press) self.canvas.bind("", self.on_drag) self.canvas.bind("", self.on_release) self.canvas.bind("", self.on_double) self.canvas.bind("", self.on_right) self.canvas.bind("", self._hover_note) self.canvas.bind("", self._on_canvas_resize) self._hover_msg = None if sys.platform == 'darwin': self.canvas.bind("", self.on_right) self.canvas.bind("", self.on_right) self.canvas.bind("", self._wheel) self.canvas.bind("", self._hwheel) self.canvas.bind("", lambda e: self.zoom_at(1.15 if e.delta > 0 else 1 / 1.15, e.x)) self.canvas.bind("", lambda e: self._yview('scroll', -2, 'units')) self.canvas.bind("", lambda e: self._yview('scroll', 2, 'units')) self.canvas.bind("", lambda e: self.canvas.xview('scroll', -1, 'units')) self.canvas.bind("", lambda e: self.canvas.xview('scroll', 1, 'units')) self.canvas.bind("", lambda e: self.zoom_at(1.15, e.x)) self.canvas.bind("", lambda e: self.zoom_at(1 / 1.15, e.x)) self.ruler.bind("", self.ruler_press) self.ruler.bind("", self.ruler_drag) self.ruler.bind("", self.ruler_release) self.bind("", self._on_key) self.bind("", 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