diff options
| -rw-r--r-- | assets/buttons/clear.png | bin | 0 -> 1288 bytes | |||
| -rw-r--r-- | assets/buttons/export.png | bin | 0 -> 942 bytes | |||
| -rw-r--r-- | assets/buttons/load.png | bin | 0 -> 1458 bytes | |||
| -rw-r--r-- | assets/buttons/loop.png | bin | 0 -> 1895 bytes | |||
| -rw-r--r-- | assets/buttons/lyrics.png | bin | 0 -> 2494 bytes | |||
| -rw-r--r-- | assets/buttons/pause.png | bin | 0 -> 425 bytes | |||
| -rw-r--r-- | assets/buttons/play.png | bin | 0 -> 1084 bytes | |||
| -rw-r--r-- | assets/buttons/save.png | bin | 0 -> 1392 bytes | |||
| -rw-r--r-- | assets/buttons/stop.png | bin | 0 -> 389 bytes | |||
| -rw-r--r-- | assets/buttons/symbols.png | bin | 0 -> 1553 bytes | |||
| -rw-r--r-- | assets/buttons/zoom_in.png | bin | 0 -> 3552 bytes | |||
| -rw-r--r-- | assets/buttons/zoom_out.png | bin | 0 -> 3261 bytes | |||
| -rw-r--r-- | assets/icon.ico | bin | 0 -> 34208 bytes | |||
| -rw-r--r-- | assets/icon.png | bin | 0 -> 11660 bytes | |||
| -rw-r--r-- | assets/title.png | bin | 0 -> 1480 bytes | |||
| -rw-r--r-- | install_menu_entry.py | 270 | ||||
| -rw-r--r-- | interface.py | 1805 | ||||
| -rw-r--r-- | synth.py | 542 |
18 files changed, 2617 insertions, 0 deletions
diff --git a/assets/buttons/clear.png b/assets/buttons/clear.png Binary files differnew file mode 100644 index 0000000..a774504 --- /dev/null +++ b/assets/buttons/clear.png diff --git a/assets/buttons/export.png b/assets/buttons/export.png Binary files differnew file mode 100644 index 0000000..d98664f --- /dev/null +++ b/assets/buttons/export.png diff --git a/assets/buttons/load.png b/assets/buttons/load.png Binary files differnew file mode 100644 index 0000000..6c5eadb --- /dev/null +++ b/assets/buttons/load.png diff --git a/assets/buttons/loop.png b/assets/buttons/loop.png Binary files differnew file mode 100644 index 0000000..308f2b9 --- /dev/null +++ b/assets/buttons/loop.png diff --git a/assets/buttons/lyrics.png b/assets/buttons/lyrics.png Binary files differnew file mode 100644 index 0000000..d20f7ca --- /dev/null +++ b/assets/buttons/lyrics.png diff --git a/assets/buttons/pause.png b/assets/buttons/pause.png Binary files differnew file mode 100644 index 0000000..5acd110 --- /dev/null +++ b/assets/buttons/pause.png diff --git a/assets/buttons/play.png b/assets/buttons/play.png Binary files differnew file mode 100644 index 0000000..79859b9 --- /dev/null +++ b/assets/buttons/play.png diff --git a/assets/buttons/save.png b/assets/buttons/save.png Binary files differnew file mode 100644 index 0000000..6fa26fd --- /dev/null +++ b/assets/buttons/save.png diff --git a/assets/buttons/stop.png b/assets/buttons/stop.png Binary files differnew file mode 100644 index 0000000..9c09dac --- /dev/null +++ b/assets/buttons/stop.png diff --git a/assets/buttons/symbols.png b/assets/buttons/symbols.png Binary files differnew file mode 100644 index 0000000..7a0a86d --- /dev/null +++ b/assets/buttons/symbols.png diff --git a/assets/buttons/zoom_in.png b/assets/buttons/zoom_in.png Binary files differnew file mode 100644 index 0000000..8ae3bb5 --- /dev/null +++ b/assets/buttons/zoom_in.png diff --git a/assets/buttons/zoom_out.png b/assets/buttons/zoom_out.png Binary files differnew file mode 100644 index 0000000..206fc16 --- /dev/null +++ b/assets/buttons/zoom_out.png diff --git a/assets/icon.ico b/assets/icon.ico Binary files differnew file mode 100644 index 0000000..3602008 --- /dev/null +++ b/assets/icon.ico diff --git a/assets/icon.png b/assets/icon.png Binary files differnew file mode 100644 index 0000000..52a609b --- /dev/null +++ b/assets/icon.png diff --git a/assets/title.png b/assets/title.png Binary files differnew file mode 100644 index 0000000..ecaf558 --- /dev/null +++ b/assets/title.png diff --git a/install_menu_entry.py b/install_menu_entry.py new file mode 100644 index 0000000..60914c5 --- /dev/null +++ b/install_menu_entry.py @@ -0,0 +1,270 @@ +import argparse, os, plistlib, shlex, shutil, struct, subprocess, sys, tempfile, zlib + +NAME = "Pythia" +COMMENT = "UTAU-style piano roll for the Ascended Ancient energeme synthesizer" +HERE = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(HERE, 'interface.py') +ASSETS = os.path.join(HERE, 'assets') +WIN, MAC = sys.platform == 'win32', sys.platform == 'darwin' + +def os_release(): + ids = set() + try: + with open('/etc/os-release', encoding='utf-8') as f: + for line in f: + key, _, val = line.partition('=') + if key in ('ID', 'ID_LIKE'): + ids |= set(val.strip().strip('"\'').lower().split()) + except OSError: + pass + return ids + +def dependency_hint(): + if WIN: + return "install Python from python.org (it includes tkinter), then: pip install numpy scipy pillow" + if MAC: + return "brew install python-tk (or use the python.org installer), then: pip3 install numpy scipy pillow" + ids = os_release() + if ids & {'debian', 'ubuntu', 'linuxmint', 'pop', 'raspbian'}: + return "sudo apt install python3-tk python3-numpy python3-scipy python3-pil" + if ids & {'fedora', 'rhel', 'centos', 'rocky', 'almalinux'}: + return "sudo dnf install python3-tkinter python3-numpy python3-scipy python3-pillow" + if ids & {'arch', 'manjaro', 'endeavouros'}: + return "sudo pacman -S tk python-numpy python-scipy python-pillow" + if ids & {'suse', 'opensuse', 'opensuse-leap', 'opensuse-tumbleweed'}: + return "sudo zypper install python3-tk python3-numpy python3-scipy python3-Pillow" + return "install tkinter, numpy and scipy for Python 3 with your package manager (or pip)" + +def check_dependencies(python): + probe = subprocess.run([python, '-c', 'import tkinter, numpy, scipy'], capture_output=True, text=True) + if probe.returncode: + last = (probe.stderr.strip().splitlines() or ['import failed'])[-1] + print(f"warning: {python} cannot import tkinter/numpy/scipy yet:\n {last}\n fix: {dependency_hint()}") + +def get_icon(ext, dest): + path = os.path.join(ASSETS, 'icon.' + ext) + if os.path.isfile(path): + return path + other = os.path.join(ASSETS, 'icon.' + ('ico' if ext == 'png' else 'png')) + if os.path.isfile(other): + try: + from PIL import Image + im = Image.open(other) + if im.info.get('sizes'): + im.size = max(im.info['sizes']) + im = im.convert('RGBA') + if ext == 'ico': + im.save(dest, sizes=[(n, n) for n in (16, 32, 48, 64, 128, 256)]) + else: + im.save(dest) + return dest + except Exception: + pass + return None + +def make_png(path, size=256): + s = size / 64 + BG, BORDER, GRID, NOTE, HI, CURSOR = ((0x15, 0x16, 0x1a), (0x3b, 0x82, 0xf6), (0x2f, 0x32, 0x39), + (0x2f, 0x6f, 0xe0), (0x9c, 0xc4, 0xff), (0xff, 0xcf, 0x5c)) + px = [[BG] * size for _ in range(size)] + def rect(x0, y0, x1, y1, c): + a, b, e = int(x0 * s), int(y0 * s), min(size, max(int(x1 * s), int(x0 * s) + 1)) + for y in range(b, min(size, max(int(y1 * s), b + 1))): + px[y][a:e] = [c] * (e - a) + rect(0, 0, 64, 64, BORDER); rect(2, 2, 62, 62, BG) + for y in (18, 30, 42, 54): + rect(4, y, 60, y + 1, GRID) + for x0, y0, x1 in ((7, 38, 25), (27, 26, 45), (47, 14, 59)): + rect(x0, y0, x1, y0 + 8, NOTE); rect(x0, y0, x1, y0 + 2, HI) + rect(25, 4, 27, 60, CURSOR) + raw = b''.join(b'\x00' + bytes(v for p in row for v in p) for row in px) + def chunk(tag, data): + body = tag + data + return struct.pack('>I', len(data)) + body + struct.pack('>I', zlib.crc32(body) & 0xffffffff) + with open(path, 'wb') as f: + f.write(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(raw, 9)) + chunk(b'IEND', b'')) + +def remove(*paths): + gone = [] + for p in paths: + try: + if os.path.islink(p) or os.path.isfile(p): + os.remove(p) + elif os.path.isdir(p): + shutil.rmtree(p) + else: + continue + gone.append(p) + except OSError as e: + print(f"could not remove {p}: {e}") + return gone + +def _xdg_data(): + return os.environ.get('XDG_DATA_HOME') or os.path.expanduser('~/.local/share') + +def _linux_paths(): + apps = os.path.join(_xdg_data(), 'applications') + icons = os.path.join(_xdg_data(), 'icons', 'hicolor', '256x256', 'apps') + return apps, icons, os.path.join(apps, 'pythia.desktop'), os.path.join(icons, 'pythia.png') + +def _linux_desktop_dir(): + try: + out = subprocess.run(['xdg-user-dir', 'DESKTOP'], capture_output=True, text=True).stdout.strip() + if out: + return out + except OSError: + pass + return os.path.expanduser('~/Desktop') + +def _desktop_quote(path): + esc = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$') + return f'"{esc}"' + +def _refresh_linux_caches(apps): + for cmd in (['update-desktop-database', apps], + ['gtk-update-icon-cache', '-f', '-t', os.path.join(_xdg_data(), 'icons', 'hicolor')]): + if shutil.which(cmd[0]): + subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def linux_install(python, desktop): + apps, icons, entry_path, icon_path = _linux_paths() + os.makedirs(apps, exist_ok=True); os.makedirs(icons, exist_ok=True) + src = get_icon('png', icon_path) + if src is None: + make_png(icon_path) + elif src != icon_path: + shutil.copyfile(src, icon_path) + entry = "\n".join([ + "[Desktop Entry]", "Type=Application", "Version=1.0", f"Name={NAME}", + "GenericName=Energeme piano roll", f"Comment={COMMENT}", + f"Exec={_desktop_quote(python)} {_desktop_quote(TARGET)}", f"Path={HERE}", f"Icon={icon_path}", + "Terminal=false", "Categories=AudioVideo;Audio;Music;", + "Keywords=utau;vocaloid;conlang;energeme;synth;piano roll;", + f"StartupWMClass={NAME}", "StartupNotify=true", ""]) + def write(path): + with open(path, 'w', encoding='utf-8') as f: + f.write(entry) + os.chmod(path, 0o755) + write(entry_path) + _refresh_linux_caches(apps) + print(f"menu entry: {entry_path}\nicon: {icon_path}\nPress the Super key and type '{NAME}'. " + "(If it doesn't show up at once, log out and back in.)") + if desktop: + folder = _linux_desktop_dir() + if not os.path.isdir(folder): + print(f"no desktop folder at {folder}; skipped the desktop shortcut") + return + path = os.path.join(folder, 'Pythia.desktop') + write(path) + if shutil.which('gio'): + subprocess.run(['gio', 'set', path, 'metadata::trusted', 'true'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + print(f"desktop: {path}\n(If it shows a warning icon, right-click it > Allow Launching / Trust.)") + +def linux_uninstall(): + apps, _icons, entry_path, icon_path = _linux_paths() + gone = remove(entry_path, icon_path, os.path.join(_linux_desktop_dir(), 'Pythia.desktop')) + _refresh_linux_caches(apps) + return gone + +def _powershell(script, **env): + exe = shutil.which('powershell') or shutil.which('pwsh') or 'powershell' + res = subprocess.run([exe, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + capture_output=True, text=True, env={**os.environ, **env}) + if res.returncode: + raise RuntimeError(res.stderr.strip() or 'PowerShell failed') + return res.stdout.strip() + +def _win_folder(kind): + return _powershell(f"[Environment]::GetFolderPath('{kind}')") + +def _windowless(python): + head, tail = os.path.split(python) + if tail.lower() == 'python.exe' and os.path.isfile(os.path.join(head, 'pythonw.exe')): + return os.path.join(head, 'pythonw.exe') + return python + +def win_install(python, desktop): + icon = get_icon('ico', os.path.join(ASSETS, 'icon.ico')) or '' + make = ("$s=(New-Object -ComObject WScript.Shell).CreateShortcut($env:PY_LNK);" + "$s.TargetPath=$env:PY_TARGET;$s.Arguments=$env:PY_ARGS;$s.WorkingDirectory=$env:PY_CWD;" + "$s.Description=$env:PY_DESC;if($env:PY_ICON){$s.IconLocation=$env:PY_ICON};$s.Save()") + folders = [('Programs', 'Start menu')] + ([('Desktop', 'desktop')] if desktop else []) + for kind, label in folders: + lnk = os.path.join(_win_folder(kind), NAME + '.lnk') + _powershell(make, PY_LNK=lnk, PY_TARGET=_windowless(python), PY_ARGS=f'"{TARGET}"', + PY_CWD=HERE, PY_DESC=COMMENT, PY_ICON=icon) + print(f"{label}: {lnk}") + if not icon: + print("note: assets/icon.ico not found, so the shortcut uses the default Python icon") + print(f"Press the Windows key and type '{NAME}'.") + +def win_uninstall(): + return remove(*(os.path.join(_win_folder(k), NAME + '.lnk') for k in ('Programs', 'Desktop'))) + +def _mac_app(): + return os.path.expanduser(f'~/Applications/{NAME}.app') + +def _make_icns(png, out): + if not (shutil.which('sips') and shutil.which('iconutil')): + return False + with tempfile.TemporaryDirectory() as tmp: + iconset = os.path.join(tmp, 'Pythia.iconset') + os.mkdir(iconset) + for name, px in (('16x16', 16), ('16x16@2x', 32), ('32x32', 32), ('32x32@2x', 64), + ('128x128', 128), ('128x128@2x', 256), ('256x256', 256)): + r = subprocess.run(['sips', '-z', str(px), str(px), png, '--out', os.path.join(iconset, f'icon_{name}.png')], + capture_output=True) + if r.returncode: + return False + return subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', out], capture_output=True).returncode == 0 + +def mac_install(python, desktop): + app = _mac_app() + remove(app) + macos, res = os.path.join(app, 'Contents', 'MacOS'), os.path.join(app, 'Contents', 'Resources') + os.makedirs(macos); os.makedirs(res) + launcher = os.path.join(macos, NAME) + with open(launcher, 'w', encoding='utf-8') as f: + f.write(f"#!/bin/bash\ncd {shlex.quote(HERE)}\nexec {shlex.quote(python)} {shlex.quote(TARGET)}\n") + os.chmod(launcher, 0o755) + with tempfile.TemporaryDirectory() as tmp: + png = get_icon('png', os.path.join(tmp, 'icon.png')) + has_icon = bool(png) and _make_icns(png, os.path.join(res, f'{NAME}.icns')) + info = {'CFBundleName': NAME, 'CFBundleDisplayName': NAME, 'CFBundleExecutable': NAME, + 'CFBundleIdentifier': 'local.pythia', 'CFBundlePackageType': 'APPL', 'CFBundleVersion': '1.0', + 'CFBundleShortVersionString': '1.0', 'NSHighResolutionCapable': True} + if has_icon: + info['CFBundleIconFile'] = NAME + with open(os.path.join(app, 'Contents', 'Info.plist'), 'wb') as f: + plistlib.dump(info, f) + print(f"app: {app}\nOpen Launchpad or Spotlight and type '{NAME}'.") + if not has_icon: + print("note: no icon (needs assets/icon.png; sips/iconutil come with macOS)") + if desktop: + link = os.path.expanduser(f'~/Desktop/{NAME}') + remove(link) + os.symlink(app, link) + print(f"desktop: {link}") + +def mac_uninstall(): + return remove(_mac_app(), os.path.expanduser(f'~/Desktop/{NAME}')) + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--desktop', action='store_true', help="also put a shortcut on the desktop") + ap.add_argument('--uninstall', action='store_true', help="remove the menu entry and desktop shortcut") + ap.add_argument('--python', default=sys.executable, help="Python to launch Pythia with (default: this one)") + a = ap.parse_args() + if a.uninstall: + gone = win_uninstall() if WIN else mac_uninstall() if MAC else linux_uninstall() + print("removed:", ", ".join(gone) if gone else "nothing to remove") + return + if not os.path.isfile(TARGET): + sys.exit(f"interface.py not found next to this script ({HERE}).") + check_dependencies(a.python) + (win_install if WIN else mac_install if MAC else linux_install)(a.python, a.desktop) + +if __name__ == '__main__': + main() 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 diff --git a/synth.py b/synth.py new file mode 100644 index 0000000..54f5dea --- /dev/null +++ b/synth.py @@ -0,0 +1,542 @@ +import re +from contextlib import contextmanager +from functools import lru_cache +from typing import NamedTuple + +import numpy as np +from scipy.io import wavfile +from scipy.signal import butter, sosfilt + +SR = 44100 +VIOLET, INDIGO, BLUE, CYAN, GREEN, YELLOW, ORANGE, RED = 2200, 1850, 1500, 1150, 800, 550, 380, 250 + +def env(sig, attack=0.1, release=0.3): + n = len(sig) + e = np.ones(n) + a, r = int(n * attack), int(n * release) + if a > 0: + e[:a] = np.linspace(0, 1, a) + if r > 0: + e[-r:] *= np.linspace(1, 0, r) + return sig * e + +@lru_cache(maxsize=512) +def _sos(btype, wn, order): + return butter(order, wn, btype=btype, output='sos') + +def bandpass(sig, low, high, order=2): + low, high = max(20, low), min(SR / 2 - 100, high) + return sosfilt(_sos('band', (low / (SR / 2), high / (SR / 2)), order), sig) + +def lowpass(sig, cutoff, order=2): + return sosfilt(_sos('low', cutoff / (SR / 2), order), sig) + +def highpass(sig, cutoff, order=2): + return sosfilt(_sos('high', cutoff / (SR / 2), order), sig) + +def silence(dur): + return np.zeros(int(SR * dur)) + +def _noise(n): + return np.random.uniform(-1, 1, n) + +def _tone(freq, n): + return np.sin(2 * np.pi * freq * np.arange(n) / SR) + +_quiet = False + +def _warn(msg): + if not _quiet: + print(f"[warning] {msg}") + +_VOICE = {'pitch': 1.0, 'formant': 1.0} + +VOICE_PRESETS = { + 'neu': (1.00, 1.00), + 'masc': (0.78, 0.90), + 'fem': (1.28, 1.10), +} + +def set_voice(voice=None, pitch=None, formant=None): + if voice is not None: + _VOICE['pitch'], _VOICE['formant'] = VOICE_PRESETS[voice] if isinstance(voice, str) else voice + if pitch is not None: + _VOICE['pitch'] = pitch + if formant is not None: + _VOICE['formant'] = formant + +def _fs(freq): + return freq * _VOICE['formant'] + +def pulse(freq, coherent=True, dur=0.09): + freq = _fs(freq) + noise = _noise(int(SR * dur)) + return env(bandpass(noise, freq * 0.9, freq * 1.1, order=4) if coherent else noise, 0.02, 0.5) + +def _stop(freq, coherent=True, dm=1.0): + base = 0.09 + closure = silence(base * (dm - 1)) if dm > 1.0 else np.zeros(0) + return np.concatenate([closure, pulse(freq, coherent, base * min(dm, 1.0))]) + +def turbulence(freq, coherent=True, dur=0.16): + freq = _fs(freq) + n = int(SR * dur) + shaped = lowpass(_noise(n), freq * 1.3) + sig = shaped * 0.8 + _tone(freq, n) * 0.3 if coherent else shaped + return env(sig, 0.1, 0.25) + +def sibilant(freq, coherent=True, dur=0.14): + freq = _fs(freq) + n = int(SR * dur) + sig = highpass(_noise(n), 1800 + freq * 0.3) + if coherent: + sig = sig * 0.7 + bandpass(_noise(n), freq * 1.5, freq * 2.0, order=4) * 0.5 + return env(sig, 0.03, 0.2) + +def hum(freq, coherent=True, dur=0.18): + freq = _fs(freq) + n = int(SR * dur) + tone = (_tone(freq, n) * 0.7 + _tone(freq * 2, n) * 0.25 + + _tone(freq * 0.5, n) * 0.2 + _tone(freq * 1.005, n) * 0.4) + sig = tone if coherent else lowpass(_noise(n), freq * 1.4) * 0.85 + tone * 0.15 + return env(sig, 0.1, 0.35) + +def flutter(freq, coherent=True, dur=0.22, rate=26): + freq = _fs(freq) + n = int(SR * dur) + phase = (np.arange(n) / SR * rate) % 1.0 + gate = np.clip(1 - 10 * np.abs(phase - 0.2), 0, 1) + carrier = _tone(freq, n) if coherent else lowpass(_noise(n), freq * 1.4) + return env(carrier * gate, 0.05, 0.15) + +def wave(freq, coherent=True, dur=0.15): + freq = _fs(freq) + n = int(SR * dur) + sig = _tone(freq, n) if coherent else lowpass(_noise(n), freq * 1.2) * 0.85 + _tone(freq, n) * 0.15 + return env(sig, 0.35, 0.4) + +def splitwave(freq, coherent=True, dur=0.16): + freq = _fs(freq) + n = int(SR * dur) + if coherent: + sig = 0.5 * _tone(freq * 0.85, n) + 0.5 * _tone(freq * 1.18, n) + else: + ch1 = lowpass(_noise(n), freq * 0.85 * 1.3) + ch2 = lowpass(_noise(n), freq * 1.18 * 1.3) + sig = 0.5 * ch1 + 0.5 * ch2 + return env(sig, 0.2, 0.35) + +def splitturbulence(freq, coherent=True, dur=0.16): + freq = _fs(freq) + n = int(SR * dur) + ch1 = bandpass(_noise(n), freq * 0.45, freq * 0.75, order=6) + ch2 = bandpass(_noise(n), freq * 1.25, freq * 1.65, order=6) + shaped = 0.5 * ch1 + 0.5 * ch2 + if coherent: + shaped = shaped * 0.8 + _tone(freq, n) * 0.3 + return env(shaped, 0.1, 0.25) + +def affricate(freq, coherent=True, dm=1.0): + return np.concatenate([pulse(freq, coherent, 0.05 * dm), turbulence(freq, coherent, 0.1 * dm)]) + +def aspiration(dur=0.05): + return turbulence(GREEN, False, dur) * 0.6 + +def labialization(dur=0.045): + return wave(VIOLET, dur=dur) * 0.6 + +def palatalization(dur=0.045): + return wave(BLUE, dur=dur) * 0.6 + +def _build_f0_signal(f0, n): + if f0 is None: + return None + if isinstance(f0, (list, tuple)) and len(f0) > 1: + bounds = np.linspace(0, n, len(f0)).astype(int) + curve = np.zeros(n) + for i in range(len(f0) - 1): + s, e = bounds[i], bounds[i + 1] + curve[s:e] = np.linspace(f0[i], f0[i + 1], e - s, endpoint=False) + curve[-1] = f0[-1] + return np.sin(np.cumsum(2 * np.pi * curve / SR)) + return _tone(f0[0] if isinstance(f0, (list, tuple)) else f0, n) + +def _finish_vowel(sig, n, f0): + f0_sig = _build_f0_signal(f0, n) + if f0_sig is not None: + sig = sig * 0.7 + f0_sig * 0.35 + return env(sig, 0.15, 0.3) + +def vowel_glide(freq_pairs, total_dur, f0=None): + n = int(SR * total_dur) + if n <= 0: + return np.zeros(0) + if len(freq_pairs) == 1: + f1, f2 = freq_pairs[0] + return _finish_vowel(0.5 * _tone(f1, n) + 0.5 * _tone(f2, n), n, f0) + bounds = np.linspace(0, n, len(freq_pairs)).astype(int) + curves = np.zeros((2, n)) + for i in range(len(freq_pairs) - 1): + s, e = bounds[i], bounds[i + 1] + for k in (0, 1): + curves[k, s:e] = np.linspace(freq_pairs[i][k], freq_pairs[i + 1][k], e - s, endpoint=False) + curves[:, -1] = freq_pairs[-1] + phases = np.cumsum(2 * np.pi * curves / SR, axis=1) + return _finish_vowel(0.5 * np.sin(phases[0]) + 0.5 * np.sin(phases[1]), n, f0) + +def _overlap_add(parts, overlap_sec=0.02): + if not parts: + return np.zeros(0) + k0 = int(SR * overlap_sec) + out = parts[0] + for p in parts[1:]: + k = min(k0, len(out), len(p)) + if k > 0: + out = np.concatenate([out[:-k], out[-k:] + p[:k], p[k:]]) + else: + out = np.concatenate([out, p]) + return out + +TONES = {'`': 260, "'": 220, '-': 185, ',': 150, '.': 115} + +_SHAPES = { + 'stop': lambda f, c, dm: _stop(f, c, dm), + 'glstop': lambda f, c, dm: pulse(f, c, 0.09 * dm), + 'fric': lambda f, c, dm: turbulence(f, c, 0.16 * dm), + 'appr': lambda f, c, dm: wave(f, c, 0.15 * dm), + 'lat': lambda f, c, dm: splitwave(f, c, 0.16 * dm), + 'tap': lambda f, c, dm: pulse(f, c, 0.04 * dm), + 'trill': lambda f, c, dm: flutter(f, c, 0.22 * dm), + 'nasal': lambda f, c, dm: hum(f, c, 0.18 * dm), + 'affr': lambda f, c, dm: affricate(f, c, dm), + 'sib': lambda f, c, dm: sibilant(f, c, 0.14 * dm), + 'latfric': lambda f, c, dm: splitturbulence(f, c, 0.16 * dm), +} + +_COLUMNS = [('stop', False), ('stop', True), ('fric', False), ('fric', True), ('appr', True), ('lat', True), + ('tap', False), ('tap', True), ('trill', True), ('nasal', True), + ('affr', False), ('affr', True), ('sib', False), ('sib', True), ('latfric', False), ('latfric', True)] + +_PLACES = [ + (VIOLET, 'p b ɸ β w ⱳ ᴘ ⱱ ʙ m'), + (INDIGO, 't d θ ð ɹ l ɍ ɾ r n ʦ ʣ s z ɬ ɮ'), + (BLUE, 'c ɟ ç ʝ j ʎ ꞔ ƴ ɏ ɲ ʨ ʥ ɕ ʑ ꜿ ỿ'), + (CYAN, 'q ɢ χ ʁ ɰ ʟ ꝗ ꞡ ʀ ɴ'), + (GREEN, 'ʔ 7 h ɦ ʕ ꝇ ƕ ʮ ʢ ƞ'), + (YELLOW, 'ფ ბ ს ზ ჯ ლ ტ წ რ ნ'), + (ORANGE, 'փ բ ֆ վ ր լ չ ջ ռ մ'), + (RED, 'Ⱂ Ⰱ Ⱄ Ⰸ Ⰻ Ⰾ Ⱌ Ⰷ Ⱃ Ⰿ'), +] + +def _shape(manner, freq, coherent): + fn = _SHAPES['glstop' if manner == 'stop' and freq == GREEN else manner] + return lambda dm=1.0: fn(freq, coherent, dm) + +CONSONANTS = {} +SONORANT_VOICELESS = {} +for _freq, _row in _PLACES: + for _sym, (_manner, _voiced) in zip(_row.split(), _COLUMNS): + CONSONANTS[_sym] = _shape(_manner, _freq, _voiced) + if _manner in ('appr', 'lat', 'trill', 'nasal'): + SONORANT_VOICELESS[_sym] = _shape(_manner, _freq, False) + +VOWELS = { + 'i': (280, 2250), 'y': (280, 1900), 'ɯ': (280, 1200), 'u': (280, 650), + 'ɪ': (350, 2000), 'ɵ': (420, 1400), 'ʊ': (380, 850), + 'e': (400, 2100), 'ø': (400, 1700), 'ɤ': (400, 1300), 'o': (400, 700), + 'ə': (500, 1500), + 'ɛ': (570, 1900), 'œ': (570, 1600), 'ʌ': (600, 1100), 'ɔ': (550, 850), + 'æ': (700, 1750), 'ɐ': (700, 1400), 'ɑ': (750, 1050), + 'a': (850, 1600), +} + +ASCII = { + 'p': 'p', 'b': 'b', 't': 't', 'd': 'd', 'k': 'c', 'g': 'ɟ', 'q': 'q', 'gq': 'ɢ', '?': 'ʔ', '7': '7', 'uvt': 'ფ', 'uvd': 'ბ', 'xrt': 'փ', 'xrd': 'բ', 'gmt': 'Ⱂ', 'gmd': 'Ⰱ', + 'f': 'ɸ', 'v': 'β', 'th': 'θ', 'dh': 'ð', 'kh': 'ç', 'gh': 'ʝ', 'x': 'χ', 'xg': 'ʁ', 'h': 'h', 'hg': 'ɦ', 'uvs': 'ს', 'uvz': 'ზ', 'xrs': 'ֆ', 'xrz': 'վ', 'gms': 'Ⱄ', 'gmz': 'Ⰸ', + 'w': 'w', 'wl': 'ⱳ', 'rz': 'ɹ', 'l': 'l', 'j': 'j', 'lj': 'ʎ', 'jq': 'ɰ', 'lq': 'ʟ', 'jh': 'ʕ', 'lh': 'ꝇ', 'uvj': 'ჯ', 'uvl': 'ლ', 'xrj': 'ր', 'xrl': 'լ', 'gmj': 'Ⰻ', 'gml': 'Ⰾ', + 'rp': 'ᴘ', 'rb': 'ⱱ', 'rh': 'ɍ', 'r': 'ɾ', 'rk': 'ꞔ', 'rj': 'ƴ', 'rq': 'ꝗ', 'rg': 'ꞡ', 'hr': 'ƕ', 'hgr': 'ʮ', 'uvrh': 'ტ', 'uvr': 'წ', 'xrrh': 'չ', 'xrr': 'ջ', 'gmrh': 'Ⱌ', 'gmr': 'Ⰷ', + 'bb': 'ʙ', 'rr': 'r', 'jj': 'ɏ', 'gg': 'ʀ', 'hh': 'ʢ', 'uvrr': 'რ', 'xrrr': 'ռ', 'gmrr': 'Ⱃ', + 'm': 'm', 'n': 'n', 'nj': 'ɲ', 'ng': 'ɴ', 'nh': 'ƞ', 'uvn': 'ნ', 'xrn': 'մ', 'gmn': 'Ⰿ', + 'ts': 'ʦ', 'dz': 'ʣ', 'tsh': 'ʨ', 'dzh': 'ʥ', + 's': 's', 'z': 'z', 'sh': 'ɕ', 'zh': 'ʑ', + 'ls': 'ɬ', 'lz': 'ɮ', 'lsh': 'ꜿ', 'lzh': 'ỿ', + + 'i': 'i', 'y': 'y', 'eu': 'ɯ', 'u': 'u', + 'ie': 'ɪ', 'yo': 'ɵ', 'uo': 'ʊ', + 'e': 'e', 'oe': 'ø', 'eo': 'ɤ', 'o': 'o', + 'ea': 'ə', + 'ee': 'ɛ', 'oee': 'œ', 'eoo': 'ʌ', 'oo': 'ɔ', + 'ae': 'æ', 'eaa': 'ɐ', 'aa': 'ɑ', 'a': 'a', +} + +SECONDARY = {'>h': aspiration, '>w': labialization, '>j': palatalization} + +PLACE_TRANSITION = { + VIOLET: ('locus', 700), + INDIGO: ('locus', 1800), + BLUE: ('locus', 2400), + CYAN: ('perturb', (120, -350)), +} +TRANSITION_PLACE = { + 'p': VIOLET, 'b': VIOLET, 't': INDIGO, 'd': INDIGO, + 'c': BLUE, 'ɟ': BLUE, + 'q': CYAN, 'ɢ': CYAN, 'χ': CYAN, 'ʁ': CYAN, +} + +def _place_vowel_transition(place_color, vowel_freqs, dur=0.055): + entry = PLACE_TRANSITION.get(place_color) + if entry is None or entry[1] is None: + return None + kind, param = entry + f1, f2 = vowel_freqs + if kind == 'perturb': + n = int(SR * 0.07) + d1, d2 = _fs(param[0]), _fs(param[1]) + f1_curve = np.linspace(f1 + d1, f1, n) + f2_curve = np.linspace(max(200, f2 + d2), f2, n) + else: + n = int(SR * dur) + if n <= 0: + return None + f1_curve = np.full(n, f1) + f2_curve = np.linspace(_fs(param), f2, n) + sig = 0.5 * np.sin(np.cumsum(2 * np.pi * f1_curve / SR)) + 0.5 * np.sin(np.cumsum(2 * np.pi * f2_curve / SR)) + return env(sig, 0.08, 0.08) * 1.6 + +LONG_STEP = 1.8 +BRIEF_STEP = 1.8 + +class Phone(NamedTuple): + core: str + secondary: list + tone: object + dur: float + devoiced: bool + +def _strip_marks(phon): + core, secondary = phon, [] + while core[-2:] in SECONDARY: + secondary.insert(0, core[-2:]) + core = core[:-2] + devoiced = core.endswith('*') + if devoiced: + core = core[:-1] + stripped = core.rstrip(''.join(TONES)) + tones = tuple(TONES[c] for c in core[len(stripped):]) + core = stripped + tone_f0 = tones[0] if len(tones) == 1 else (tones or None) + stripped = core.rstrip(':') + if len(stripped) != len(core): + dur_mult, core = LONG_STEP ** (len(core) - len(stripped)), stripped + else: + stripped = core.rstrip('+') + dur_mult = BRIEF_STEP ** -(len(core) - len(stripped)) + core = stripped + return core, secondary, tone_f0, dur_mult, devoiced + +def _parse(phones, translate=None): + parsed = [] + for p in phones: + core, secondary, tone, dur, devoiced = _strip_marks(p) + if translate: + core = translate.get(core, core) + parsed.append(Phone(core, secondary, tone, dur, devoiced)) + return parsed + +def _group_phones(parsed): + groups, i = [], 0 + while i < len(parsed): + if parsed[i].core in VOWELS: + j = i + 1 + while j < len(parsed) and parsed[j].core in VOWELS: + j += 1 + groups.append(('v', list(range(i, j)))) + i = j + else: + groups.append(('c', i)) + i += 1 + return groups + +def _consonant_audio(ph, text, unknown_dur): + if ph.core not in CONSONANTS: + _warn(f"unknown phoneme '{text}', inserting silence") + return silence(unknown_dur) + if ph.tone is not None: + _warn(f"tone mark ignored on consonant '{text}'") + if ph.devoiced and ph.core in SONORANT_VOICELESS: + audio = SONORANT_VOICELESS[ph.core](ph.dur) + else: + if ph.devoiced: + _warn(f"no voiceless form for consonant '{text}', ignoring '*'") + audio = CONSONANTS[ph.core](ph.dur) + if ph.secondary: + audio = np.concatenate([audio] + [SECONDARY[m]() for m in ph.secondary]) + return audio + +def _formants(parsed, run): + return [(_fs(VOWELS[parsed[i].core][0]), _fs(VOWELS[parsed[i].core][1])) for i in run] + +def _onset(parsed, groups, gi, first_pair): + if gi > 0 and groups[gi - 1][0] == 'c': + place = TRANSITION_PLACE.get(parsed[groups[gi - 1][1]].core) + if place is not None: + return _place_vowel_transition(place, first_pair) + return None + +def _tone_contour(tones): + if all(t is None for t in tones): + return None + filled, last = [], None + for t in tones: + if t is None: + filled.append(185 if last is None else last) + else: + filled.extend(t if isinstance(t, tuple) else (t,)) + last = t[-1] if isinstance(t, tuple) else t + filled = [_VOICE['pitch'] * f for f in filled] + return filled if len(filled) > 1 else filled[0] + +def _render_phones(phones, seg_gap=0.02, vowel_gap=0.004, translate=None): + parsed = _parse(phones, translate) + groups = _group_phones(parsed) + chunks, is_vowel = [], [] + for gi, (kind, ref) in enumerate(groups): + if kind == 'c': + chunks.append(_consonant_audio(parsed[ref], phones[ref], 0.01)) + else: + pairs = _formants(parsed, ref) + audio = vowel_glide(pairs, sum(0.22 * parsed[i].dur for i in ref), + f0=_tone_contour([parsed[i].tone for i in ref])) + trans = _onset(parsed, groups, gi, pairs[0]) + if trans is not None: + audio = _overlap_add([trans, audio], 0.015) + chunks.append(audio) + is_vowel.append(kind == 'v') + out = [] + for gi, chunk in enumerate(chunks): + out.append(chunk) + if gi < len(chunks) - 1: + out.append(silence(vowel_gap if is_vowel[gi + 1] else seg_gap)) + return out + +NOTE_SEMITONE = {'C': -9, 'C#': -8, 'Db': -8, 'D': -7, 'D#': -6, 'Eb': -6, + 'E': -5, 'F': -4, 'F#': -3, 'Gb': -3, 'G': -2, 'G#': -1, + 'Ab': -1, 'A': 0, 'A#': 1, 'Bb': 1, 'B': 2} + +def note_to_hz(note): + if isinstance(note, (int, float)): + return float(note) + m = re.match(r'([A-Ga-g][#b]?)(-?\d+)', note) + if not m: + raise ValueError(f"unrecognized note name: {note!r}") + n = NOTE_SEMITONE[m.group(1).capitalize()] + (int(m.group(2)) - 4) * 12 + return 440.0 * 2 ** (n / 12) + +def sleep(beats): + return (None, None, beats) + +def render_note_syllable(phoneme_str, note, dur_sec, min_vowel_dur=0.05, xfade=0.02, translate=ASCII): + hz = note_to_hz(note) + phones = phoneme_str.split() + parsed = _parse(phones, translate) + groups = _group_phones(parsed) + cons = {ref: _consonant_audio(parsed[ref], phones[ref], 0.03) for kind, ref in groups if kind == 'c'} + slots = sum(len(ref) for kind, ref in groups if kind == 'v') + per_vowel = 0.0 + if slots: + free = dur_sec - sum(len(a) for a in cons.values()) / SR + per_vowel = max(min_vowel_dur * slots, free) / slots + chunks = [] + for gi, (kind, ref) in enumerate(groups): + if kind == 'c': + chunks.append(cons[ref]) + else: + pairs = _formants(parsed, ref) + trans = _onset(parsed, groups, gi, pairs[0]) + if trans is not None: + chunks.append(trans) + chunks.append(vowel_glide(pairs, per_vowel * len(ref), f0=hz)) + return _overlap_add(chunks, xfade) + +@lru_cache(maxsize=256) +def _note_audio(phoneme_str, note, n, pitch, formant): + audio = render_note_syllable(phoneme_str, note, n / SR) + audio.flags.writeable = False + return audio + +def _fit_length(audio, n): + if len(audio) < n: + return np.concatenate([audio, np.zeros(n - len(audio))]) + if len(audio) > n: + out = audio[:n].copy() + k = min(int(0.01 * SR), n) + out[-k:] *= np.linspace(1, 0, k) + return out + return audio + +STRESS_BOOST = 1.35 + +def _words(seq, translate, seg_gap, word_gap): + words = seq.split('|') + out = [] + for wi, group in enumerate(words): + phones = group.split() + stressed = bool(phones) and phones[-1] == '!' + if stressed: + phones.pop() + parts = _render_phones(phones, seg_gap, translate=translate) + audio = np.concatenate(parts) if parts else silence(0) + out.append(audio * STRESS_BOOST if stressed else audio) + if wi < len(words) - 1: + out.append(silence(word_gap)) + return out + +def _notes(seq, bpm, note_gap): + out = [] + for phoneme_str, note, beats in seq: + slot = max(1, int(round(beats * 60.0 / bpm * SR))) + if phoneme_str is None: + out.append(np.zeros(slot)) + continue + stressed = phoneme_str.endswith('!') + ps = phoneme_str[:-1] if stressed else phoneme_str + gap = min(int(note_gap * SR), slot // 2) + audio = _note_audio(ps, note, slot - gap, _VOICE['pitch'], _VOICE['formant']) + if stressed: + audio = audio * STRESS_BOOST + out += [_fit_length(audio, slot - gap), np.zeros(gap)] + return out + +@contextmanager +def _settings(voice, quiet): + global _quiet + prev_voice, prev_quiet = dict(_VOICE), _quiet + try: + if voice is not None: + set_voice(voice) + _quiet = quiet + yield + finally: + _VOICE.update(prev_voice) + _quiet = prev_quiet + +def synthesize(seq, filename='output.wav', mode='ipa', seg_gap=0.02, word_gap=0.15, + bpm=100, note_gap=0.01, voice=None, quiet=False): + if mode not in ('ipa', 'ascii', 'vocaloid'): + raise ValueError("mode must be 'ipa', 'ascii', or 'vocaloid'") + with _settings(voice, quiet): + if mode == 'vocaloid': + segments = _notes(seq, bpm, note_gap) + else: + segments = _words(seq, ASCII if mode == 'ascii' else None, seg_gap, word_gap) + audio = np.concatenate(segments) if segments else np.zeros(1) + peak = np.max(np.abs(audio)) + if peak > 0: + audio = audio / peak * 0.85 + wavfile.write(filename, SR, (audio * 32767).astype(np.int16)) + if not quiet: + print(f"wrote {filename}") + return len(audio) / SR + +if __name__ == '__main__': + synthesize("o uo | n o uo", "demo.wav", mode='ascii') |
