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:
ClericalErrorandClaude Opus 5.5 committed 2026-10-14 21:17:17 -07:00
1 parent 716c5499ff
commit 1845f03c41
184 files changed
+24648

No files matched your search

+344
View File
@@ -0,0 +1,344 @@
import QtQuick
import QtQuick.Controls.Basic
// Main pane for the selected track: progress, error, or stem lanes.
Item {
id: tv
property var win: Window.window
readonly property var sel: app.selected
readonly property string status: sel.valid ? sel.status : ""
// Lane model only changes when the track or its stems change, so per-lane
// UI state survives the frequent selectedChanged notifications.
property string stemsKey: ""
property var stems: []
property var soloState: []
property var muteState: []
property var gainState: []
property real viewStart: 0
property real viewEnd: 1
property int waveW: 100
function syncStems() {
var key = sel.valid && sel.status === "done" ? sel.id + ":" + sel.stems.map(s => s.name).join(",") : ""
if (key === stemsKey) return
stemsKey = key
stems = key ? sel.stems : []
soloState = stems.map(() => false)
muteState = stems.map(() => false)
gainState = stems.map(() => 0)
viewStart = 0
viewEnd = Math.max(0.01, sel.duration || 1)
}
onSelChanged: syncStems()
Component.onCompleted: syncStems()
readonly property bool anySolo: soloState.indexOf(true) >= 0
readonly property real span: Math.max(0.01, viewEnd - viewStart)
readonly property real duration: Math.max(0.01, sel.duration || 1)
function zoomAt(fx, factor) {
var t = viewStart + fx * span
var newSpan = Math.max(0.25, Math.min(duration, span * factor))
var s = Math.max(0, Math.min(duration - newSpan, t - fx * newSpan))
viewStart = s
viewEnd = s + newSpan
}
function pan(dt) {
var s = Math.max(0, Math.min(duration - span, viewStart + dt))
viewEnd = s + span
viewStart = s
}
// ------------------------------------------------------------- done
Item {
id: doneView
anchors.fill: parent
visible: tv.status === "done"
Card {
id: header
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 24; bottomMargin: 0 }
height: 104
Column {
anchors { left: parent.left; leftMargin: 20; right: actions.left; rightMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 6
Txt {
width: parent.width
text: tv.sel.fileName || ""
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
}
Txt {
width: parent.width
color: Theme.textLo
font.pixelSize: Theme.fs12
text: [tv.sel.modelLabel, tv.sel.deviceLabel, Theme.time(tv.sel.duration || 0, false),
tv.sel.elapsed > 0 ? "separated in " + tv.sel.elapsed.toFixed(1) + " s" : "from cache"]
.filter(s => s && s.length).join(" · ")
}
Row {
spacing: 6
Icon { name: "folder"; size: 14; color: Theme.textLo; anchors.verticalCenter: parent.verticalCenter }
Txt {
text: (tv.sel.exportedTo ? "Exported to " : "Exports to ") + (tv.sel.target || "")
+ " · " + appSettings.exportFormat.toUpperCase()
font.pixelSize: Theme.fs12
color: tv.sel.exportedTo ? Theme.success : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
}
}
Row {
id: actions
anchors { right: parent.right; rightMargin: 20; verticalCenter: parent.verticalCenter }
spacing: 8
AeroButton {
visible: (tv.sel.rerunLabel || "").length > 0
variant: "text"
iconName: "refresh"
text: "Re-run with " + (tv.sel.rerunLabel || "")
onClicked: app.rerunSelected()
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
size: 40
iconName: "folder"
tip: "Open output folder"
onClicked: app.openTargetFolder()
}
AeroButton {
variant: "tonal"
iconName: "layers"
text: "Export mix"
onClicked: app.exportMix()
}
AeroButton {
variant: "filled"
iconName: "download"
text: "Export all"
onClicked: app.exportAll()
}
}
}
// timeline ruler + zoom hint
Item {
id: ruler
anchors { left: parent.left; right: parent.right; top: header.bottom; leftMargin: 24; rightMargin: 24; topMargin: 12 }
height: 22
readonly property real waveX: 248
Txt {
anchors.verticalCenter: parent.verticalCenter
text: tv.span < tv.duration - 0.01 ? "Zoomed · Ctrl+wheel to zoom, Shift+wheel to pan, double-click to reset"
: "Ctrl+wheel to zoom"
font.pixelSize: 11
color: Theme.alpha(Theme.textLo, 0.8)
}
Repeater {
model: 6
Txt {
required property int index
x: ruler.waveX + (ruler.width - ruler.waveX - 8) * index / 5 - (index === 5 ? width : index === 0 ? 0 : width / 2)
anchors.verticalCenter: parent.verticalCenter
text: Theme.time(tv.viewStart + tv.span * index / 5, tv.span < 20)
font.pixelSize: 11
font.features: { "tnum": 1 }
color: Theme.textLo
}
}
}
Flickable {
id: lanes
anchors { left: parent.left; right: parent.right; top: ruler.bottom; bottom: parent.bottom; leftMargin: 24; rightMargin: 24; topMargin: 6 }
contentHeight: col.height + 24
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
background: Item {}
}
readonly property real waveX: ruler.waveX
onWidthChanged: waveTimer.restart()
Timer { // debounce texture re-renders during window resize
id: waveTimer
interval: 90
onTriggered: tv.waveW = Math.max(10, lanes.width - lanes.waveX - 8)
}
Component.onCompleted: tv.waveW = Math.max(10, lanes.width - lanes.waveX - 8)
Column {
id: col
width: lanes.width
spacing: 8
topPadding: 4
Repeater {
model: tv.stems
StemLane {
width: col.width
jobId: tv.sel.id || ""
waveX: lanes.waveX
waveW: tv.waveW
viewStart: tv.viewStart
viewEnd: tv.viewEnd
anySolo: tv.anySolo
solo: tv.soloState[index] || false
mute: tv.muteState[index] || false
gainDb: tv.gainState[index] || 0
onSoloToggled: (on) => {
var s = tv.soloState.slice(); s[index] = on; tv.soloState = s
audio.setSolo(index, on)
}
onMuteChanged: { var m = tv.muteState.slice(); m[index] = mute; tv.muteState = m }
onGainDbChanged: { var g = tv.gainState.slice(); g[index] = gainDb; tv.gainState = g }
}
}
}
// click / drag on any waveform to seek
MouseArea {
x: lanes.waveX
y: 0
width: tv.waveW
height: col.height
cursorShape: Qt.IBeamCursor
enabled: audio.loaded
function seekTo(mx) {
var t = tv.viewStart + Math.max(0, Math.min(1, mx / width)) * tv.span
audio.seek(t)
tv.win.playPos = t
}
onPressed: (e) => seekTo(e.x)
onPositionChanged: (e) => { if (pressed) seekTo(e.x) }
onDoubleClicked: { tv.viewStart = 0; tv.viewEnd = tv.duration }
}
// playhead (position interpolated per frame from the audio ring buffer)
Rectangle {
readonly property real f: (tv.win.playPos - tv.viewStart) / tv.span
visible: audio.loaded && f >= 0 && f <= 1
x: lanes.waveX + f * tv.waveW - 1
y: 2
width: 2
height: col.height
color: "#FFFFFF"
Glow { radius: 1; sigma: 3; opacity: 0.8 }
}
WheelHandler {
acceptedModifiers: Qt.ControlModifier
onWheel: (e) => {
var fx = Math.max(0, Math.min(1, (point.position.x - lanes.waveX) / tv.waveW))
tv.zoomAt(fx, e.angleDelta.y > 0 ? 0.8 : 1.25)
}
}
WheelHandler {
acceptedModifiers: Qt.ShiftModifier
onWheel: (e) => tv.pan(-(e.angleDelta.y || e.angleDelta.x) / 120 * tv.span * 0.1)
}
}
}
// ------------------------------------------------- running / queued
Card {
visible: tv.status === "running" || tv.status === "queued"
anchors.centerIn: parent
width: Math.min(parent.width - 48, 620)
height: 236
elevation: 2
Column {
anchors { fill: parent; margins: 28 }
spacing: 12
Txt {
width: parent.width
text: tv.sel.fileName || ""
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
}
Txt {
text: tv.status === "queued" ? "Waiting for the GPU · " + (tv.sel.modelLabel || "")
: (tv.sel.stage || "") + " · " + (tv.sel.modelLabel || "")
color: Theme.textLo
}
Row {
spacing: 12
Txt {
text: Math.floor((progressBar.shown) * 100) + "%"
font.pixelSize: Theme.fs28
font.weight: Font.DemiBold
font.features: { "tnum": 1 }
color: tv.status === "running" ? Theme.textHi : Theme.textLo
}
}
StripedProgress {
id: progressBar
width: parent.width
barHeight: 10
value: tv.sel.progress || 0
active: tv.status === "running"
opacity: tv.status === "running" ? 1 : 0.4
}
Item { width: 1; height: 4 }
AeroButton {
variant: "tonal"
tone: Theme.error
iconName: "close"
text: tv.status === "queued" ? "Remove from queue" : "Cancel"
onClicked: app.cancelJob(tv.sel.id)
}
}
}
// ------------------------------------------------ error / cancelled
Card {
visible: tv.status === "error" || tv.status === "cancelled"
anchors.centerIn: parent
width: Math.min(parent.width - 48, 620)
height: errCol.implicitHeight + 56
Column {
id: errCol
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 28 }
spacing: 12
Row {
spacing: 10
Icon {
name: tv.status === "error" ? "error" : "close"
size: 24
color: tv.status === "error" ? Theme.error : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
Txt {
text: tv.status === "error" ? "Separation failed" : "Cancelled"
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
}
Txt { width: parent.width; text: tv.sel.fileName || ""; color: Theme.textLo }
Rectangle {
visible: tv.status === "error"
width: parent.width
height: msg.implicitHeight + 20
radius: 6
color: Theme.alpha(Theme.error, 0.08)
border.color: Theme.alpha(Theme.error, 0.25)
Txt {
id: msg
anchors { fill: parent; margins: 10 }
text: tv.sel.message || ""
wrapMode: Text.WordWrap
elide: Text.ElideNone
font.pixelSize: Theme.fs12
}
}
Row {
spacing: 8
AeroButton { variant: "filled"; iconName: "refresh"; text: "Retry"; onClicked: app.retryJob(tv.sel.id) }
AeroButton { variant: "text"; tone: Theme.textLo; iconName: "delete"; text: "Remove"; onClicked: app.removeJob(tv.sel.id) }
}
}
}
}