summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--assets/buttons/clear.pngbin0 -> 1288 bytes
-rw-r--r--assets/buttons/export.pngbin0 -> 942 bytes
-rw-r--r--assets/buttons/load.pngbin0 -> 1458 bytes
-rw-r--r--assets/buttons/loop.pngbin0 -> 1895 bytes
-rw-r--r--assets/buttons/lyrics.pngbin0 -> 2494 bytes
-rw-r--r--assets/buttons/pause.pngbin0 -> 425 bytes
-rw-r--r--assets/buttons/play.pngbin0 -> 1084 bytes
-rw-r--r--assets/buttons/save.pngbin0 -> 1392 bytes
-rw-r--r--assets/buttons/stop.pngbin0 -> 389 bytes
-rw-r--r--assets/buttons/symbols.pngbin0 -> 1553 bytes
-rw-r--r--assets/buttons/zoom_in.pngbin0 -> 3552 bytes
-rw-r--r--assets/buttons/zoom_out.pngbin0 -> 3261 bytes
-rw-r--r--assets/icon.icobin0 -> 34208 bytes
-rw-r--r--assets/icon.pngbin0 -> 11660 bytes
-rw-r--r--assets/title.pngbin0 -> 1480 bytes
-rw-r--r--install_menu_entry.py270
-rw-r--r--interface.py1805
-rw-r--r--synth.py542
18 files changed, 2617 insertions, 0 deletions
diff --git a/assets/buttons/clear.png b/assets/buttons/clear.png
new file mode 100644
index 0000000..a774504
--- /dev/null
+++ b/assets/buttons/clear.png
Binary files differ
diff --git a/assets/buttons/export.png b/assets/buttons/export.png
new file mode 100644
index 0000000..d98664f
--- /dev/null
+++ b/assets/buttons/export.png
Binary files differ
diff --git a/assets/buttons/load.png b/assets/buttons/load.png
new file mode 100644
index 0000000..6c5eadb
--- /dev/null
+++ b/assets/buttons/load.png
Binary files differ
diff --git a/assets/buttons/loop.png b/assets/buttons/loop.png
new file mode 100644
index 0000000..308f2b9
--- /dev/null
+++ b/assets/buttons/loop.png
Binary files differ
diff --git a/assets/buttons/lyrics.png b/assets/buttons/lyrics.png
new file mode 100644
index 0000000..d20f7ca
--- /dev/null
+++ b/assets/buttons/lyrics.png
Binary files differ
diff --git a/assets/buttons/pause.png b/assets/buttons/pause.png
new file mode 100644
index 0000000..5acd110
--- /dev/null
+++ b/assets/buttons/pause.png
Binary files differ
diff --git a/assets/buttons/play.png b/assets/buttons/play.png
new file mode 100644
index 0000000..79859b9
--- /dev/null
+++ b/assets/buttons/play.png
Binary files differ
diff --git a/assets/buttons/save.png b/assets/buttons/save.png
new file mode 100644
index 0000000..6fa26fd
--- /dev/null
+++ b/assets/buttons/save.png
Binary files differ
diff --git a/assets/buttons/stop.png b/assets/buttons/stop.png
new file mode 100644
index 0000000..9c09dac
--- /dev/null
+++ b/assets/buttons/stop.png
Binary files differ
diff --git a/assets/buttons/symbols.png b/assets/buttons/symbols.png
new file mode 100644
index 0000000..7a0a86d
--- /dev/null
+++ b/assets/buttons/symbols.png
Binary files differ
diff --git a/assets/buttons/zoom_in.png b/assets/buttons/zoom_in.png
new file mode 100644
index 0000000..8ae3bb5
--- /dev/null
+++ b/assets/buttons/zoom_in.png
Binary files differ
diff --git a/assets/buttons/zoom_out.png b/assets/buttons/zoom_out.png
new file mode 100644
index 0000000..206fc16
--- /dev/null
+++ b/assets/buttons/zoom_out.png
Binary files differ
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..3602008
--- /dev/null
+++ b/assets/icon.ico
Binary files differ
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..52a609b
--- /dev/null
+++ b/assets/icon.png
Binary files differ
diff --git a/assets/title.png b/assets/title.png
new file mode 100644
index 0000000..ecaf558
--- /dev/null
+++ b/assets/title.png
Binary files differ
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')