StemRoller: desktop stem separator, Android stems player, and server daemons
- stemroller/: PySide6/QML desktop app and player for separating music into stems - android/: Plex music player that plays per-stem mixes, with Android Auto support, a graphic + parametric (Equalizer APO-style) EQ, and offline downloads - server/: stemserve (serves stems to the app) and stemd (separates the library on the server's GPU, with "Stem Now" priority requests) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
716c5499ff
commit
1845f03c41
184 files changed
+24648
No files matched your search
@@ -0,0 +1,149 @@
|
||||
"""The workbook: every view is a sheet (columns + rows) shown in the Excel grid."""
|
||||
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
from PySide6.QtCore import QAbstractTableModel, QByteArray, QModelIndex, Qt
|
||||
|
||||
# Column formats: how a value is shown, aligned and summed in the status bar.
|
||||
# text, int, year, time (ms -> m:ss), stems (count of stems, shown TRUE/FALSE-ish), date (unix s)
|
||||
COLUMNS = {
|
||||
"songs": [
|
||||
("title", "Title", 300, "text"), ("artist", "Artist", 190, "text"), ("album", "Album", 230, "text"),
|
||||
("year", "Year", 64, "year"), ("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"),
|
||||
("plays", "Plays", 64, "int"),
|
||||
],
|
||||
"albumSongs": [
|
||||
("index", "#", 44, "int"), ("title", "Title", 320, "text"), ("artist", "Artist", 200, "text"),
|
||||
("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"), ("plays", "Plays", 64, "int"),
|
||||
],
|
||||
"artists": [
|
||||
("title", "Artist", 280, "text"), ("albums", "Albums", 70, "int"), ("songs", "Songs", 70, "int"),
|
||||
("stemSongs", "With stems", 90, "int"), ("genre", "Genre", 200, "text"),
|
||||
],
|
||||
"albums": [
|
||||
("title", "Album", 300, "text"), ("artist", "Artist", 200, "text"), ("year", "Year", 64, "year"),
|
||||
("tracks", "Songs", 64, "int"), ("stemSongs", "With stems", 90, "int"), ("genre", "Genre", 160, "text"),
|
||||
("addedAt", "Added", 96, "date"),
|
||||
],
|
||||
"playlists": [
|
||||
("title", "Playlist", 300, "text"), ("tracks", "Songs", 70, "int"), ("durationMs", "Length", 80, "time"),
|
||||
("kind", "Type", 80, "text"), ("updatedAt", "Updated", 96, "date"),
|
||||
],
|
||||
"queue": [
|
||||
("pos", "#", 44, "int"), ("title", "Title", 300, "text"), ("artist", "Artist", 190, "text"),
|
||||
("album", "Album", 230, "text"), ("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"),
|
||||
],
|
||||
"search": [
|
||||
("type", "Type", 70, "text"), ("title", "Name", 300, "text"), ("artist", "Artist", 190, "text"),
|
||||
("album", "Album", 230, "text"), ("year", "Year", 64, "year"), ("durationMs", "Time", 64, "time"),
|
||||
("stems", "Stems", 70, "stems"),
|
||||
],
|
||||
}
|
||||
|
||||
# Plex row kind -> what a double-click does
|
||||
SONG, ARTIST, ALBUM, PLAYLIST = "song", "artist", "album", "playlist"
|
||||
|
||||
|
||||
def fold(s: str) -> str:
|
||||
"""Case- and accent-insensitive form for search and sorting."""
|
||||
s = unicodedata.normalize("NFKD", s or "")
|
||||
return "".join(c for c in s if not unicodedata.combining(c)).casefold()
|
||||
|
||||
|
||||
def fmt_time(ms) -> str:
|
||||
s = int(round((ms or 0) / 1000))
|
||||
h, m = divmod(s // 60, 60)
|
||||
return f"{h}:{m:02d}:{s % 60:02d}" if h else f"{m}:{s % 60:02d}"
|
||||
|
||||
|
||||
def fmt(value, kind) -> str:
|
||||
if kind == "time":
|
||||
return fmt_time(value) if value else ""
|
||||
if kind == "year":
|
||||
return str(value) if value else ""
|
||||
if kind == "int":
|
||||
return "" if value in (None, "") else str(value)
|
||||
if kind == "stems":
|
||||
return f"{value}" if value else ""
|
||||
if kind == "date":
|
||||
return time.strftime("%Y-%m-%d", time.localtime(value)) if value else ""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
class Sheet:
|
||||
"""A tab: its rows, columns and what the rows are."""
|
||||
|
||||
def __init__(self, title, columns, rows, kind=SONG, sid=None, loading=False):
|
||||
self.title = title
|
||||
self.columns = COLUMNS[columns]
|
||||
self.layout = columns
|
||||
self.rows = rows
|
||||
self.kind = kind
|
||||
self.sid = sid or title
|
||||
self.loading = loading
|
||||
self.sort = None # (column, descending)
|
||||
|
||||
def row_kind(self, r):
|
||||
return self.rows[r].get("_kind", self.kind)
|
||||
|
||||
|
||||
class SheetModel(QAbstractTableModel):
|
||||
"""One sheet for the QML TableView. Roles: display, align, style, playing."""
|
||||
|
||||
ROLES = {Qt.ItemDataRole.DisplayRole: b"display", Qt.ItemDataRole.UserRole + 1: b"align",
|
||||
Qt.ItemDataRole.UserRole + 2: b"cellStyle", Qt.ItemDataRole.UserRole + 3: b"playing",
|
||||
Qt.ItemDataRole.UserRole + 4: b"bold"}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.sheet: Sheet | None = None
|
||||
self.playing_key = ""
|
||||
self.playing_pos = -1 # for the queue sheet, where the same song can appear twice
|
||||
|
||||
def set_sheet(self, sheet):
|
||||
self.beginResetModel()
|
||||
self.sheet = sheet
|
||||
self.endResetModel()
|
||||
|
||||
def refresh(self):
|
||||
self.beginResetModel()
|
||||
self.endResetModel()
|
||||
|
||||
def set_playing(self, key, pos):
|
||||
self.playing_key, self.playing_pos = key, pos
|
||||
if self.sheet and self.sheet.rows:
|
||||
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sheet.rows) - 1, len(self.sheet.columns) - 1),
|
||||
[Qt.ItemDataRole.UserRole + 3, Qt.ItemDataRole.UserRole + 4])
|
||||
|
||||
def roleNames(self):
|
||||
return {k: QByteArray(v) for k, v in self.ROLES.items()}
|
||||
|
||||
def rowCount(self, parent=QModelIndex()):
|
||||
return len(self.sheet.rows) if self.sheet else 0
|
||||
|
||||
def columnCount(self, parent=QModelIndex()):
|
||||
return len(self.sheet.columns) if self.sheet else 0
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
s = self.sheet
|
||||
if s is None or not index.isValid():
|
||||
return None
|
||||
row = s.rows[index.row()]
|
||||
key, _, _, kind = s.columns[index.column()]
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
return fmt(row.get(key), kind)
|
||||
if role == Qt.ItemDataRole.UserRole + 1:
|
||||
return "left" if kind in ("text", "stems") else "right"
|
||||
if role == Qt.ItemDataRole.UserRole + 2:
|
||||
# Conditional formatting: "Good" for songs with stems, a data bar for stem counts.
|
||||
if kind == "stems" and row.get(key):
|
||||
return "good"
|
||||
if key == "stemSongs" and row.get(key):
|
||||
return "bar:%.3f" % (row[key] / max(1, row.get("tracks") or row.get("songs") or row[key]))
|
||||
return ""
|
||||
if role in (Qt.ItemDataRole.UserRole + 3, Qt.ItemDataRole.UserRole + 4):
|
||||
if s.layout == "queue":
|
||||
return row.get("pos") == self.playing_pos + 1
|
||||
return s.row_kind(index.row()) == SONG and row.get("key") == self.playing_key and bool(self.playing_key)
|
||||
return None
|
||||
Reference in new issue
Block a user