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,274 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Dialogs
|
||||
|
||||
// Glass dialog (elevation 3, stronger 24px blur, 50% scrim).
|
||||
Popup {
|
||||
id: dlg
|
||||
property var win: null
|
||||
modal: true
|
||||
focus: true
|
||||
width: Math.min(parent.width - 64, 640)
|
||||
height: Math.min(parent.height - 64, content.implicitHeight + 120)
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
padding: 0
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
onOpened: app.refreshCacheSize()
|
||||
|
||||
Overlay.modal: Rectangle {
|
||||
color: Qt.rgba(0, 0, 0, 0.5)
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.med } }
|
||||
}
|
||||
enter: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
NumberAnimation { property: "scale"; from: 0.95; to: 1; duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
|
||||
}
|
||||
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
|
||||
|
||||
background: Item {
|
||||
Elevation { level: 3; radius: 12 }
|
||||
Image { // stronger-blur glass sheet, pre-rounded, rendered once per open/resize
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
retainWhileLoading: true
|
||||
source: dlg.win && dlg.width > 0
|
||||
? "image://fx/glasscrop/" + dlg.win.fxW + "x" + dlg.win.fxH + "/0/24/"
|
||||
+ Math.round(dlg.x) + "," + Math.round(dlg.y) + "," + Math.round(dlg.width) + "," + Math.round(dlg.height) + "/12"
|
||||
: ""
|
||||
}
|
||||
Rectangle { // Aero gloss
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
|
||||
height: 48
|
||||
topLeftRadius: 11
|
||||
topRightRadius: 11
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.08) }
|
||||
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 12
|
||||
color: "transparent"
|
||||
border.color: Qt.rgba(1, 1, 1, 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
FolderDialog {
|
||||
id: folderDialog
|
||||
title: "Choose the stems folder"
|
||||
currentFolder: "file://" + appSettings.stemsDir
|
||||
onAccepted: appSettings.stemsDir = decodeURIComponent(selectedFolder.toString().replace("file://", ""))
|
||||
}
|
||||
|
||||
component SectionTitle: Txt {
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.8
|
||||
color: Theme.accentGlow
|
||||
topPadding: 8
|
||||
}
|
||||
component Hint: Txt {
|
||||
font.pixelSize: Theme.fs12
|
||||
color: Theme.textLo
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideNone
|
||||
}
|
||||
component Segmented: Row {
|
||||
id: seg
|
||||
property var options: []
|
||||
property string value: ""
|
||||
signal picked(string v)
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: seg.options
|
||||
AbstractButton {
|
||||
id: sb
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool on: seg.value === modelData.v
|
||||
implicitWidth: 84
|
||||
implicitHeight: 34
|
||||
hoverEnabled: true
|
||||
onClicked: seg.picked(modelData.v)
|
||||
background: Rectangle {
|
||||
topLeftRadius: sb.index === 0 ? 17 : 0
|
||||
bottomLeftRadius: sb.index === 0 ? 17 : 0
|
||||
topRightRadius: sb.index === seg.options.length - 1 ? 17 : 0
|
||||
bottomRightRadius: sb.index === seg.options.length - 1 ? 17 : 0
|
||||
color: sb.on ? Theme.alpha(Theme.primary, 0.28) : (sb.hovered ? Qt.rgba(1, 1, 1, 0.07) : Qt.rgba(1, 1, 1, 0.03))
|
||||
border.color: sb.on ? Theme.alpha(Theme.primary, 0.7) : Qt.rgba(1, 1, 1, 0.12)
|
||||
Behavior on color { ColorAnimation { duration: Theme.fast } }
|
||||
}
|
||||
contentItem: Txt {
|
||||
text: sb.modelData.t
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.weight: sb.on ? Font.DemiBold : Font.Normal
|
||||
color: sb.on ? Theme.textHi : Theme.textLo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
Item {
|
||||
id: head
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: 60
|
||||
Txt {
|
||||
anchors { left: parent.left; leftMargin: 24; verticalCenter: parent.verticalCenter }
|
||||
text: "Settings"
|
||||
font.pixelSize: Theme.fs20
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
IconButton {
|
||||
anchors { right: parent.right; rightMargin: 14; verticalCenter: parent.verticalCenter }
|
||||
iconName: "close"
|
||||
onClicked: dlg.close()
|
||||
}
|
||||
Rectangle { anchors { left: parent.left; right: parent.right; bottom: parent.bottom } height: 1; color: Theme.hairline }
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors { left: parent.left; right: parent.right; top: head.bottom; bottom: parent.bottom }
|
||||
contentHeight: content.implicitHeight + 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar { contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
|
||||
background: Item {} }
|
||||
|
||||
Column {
|
||||
id: content
|
||||
x: 24
|
||||
y: 12
|
||||
width: parent.width - 48
|
||||
spacing: 12
|
||||
|
||||
SectionTitle { text: "OUTPUT" }
|
||||
Segmented {
|
||||
options: [{ v: "beside", t: "Beside" }, { v: "folder", t: "Folder" }]
|
||||
value: appSettings.outputMode
|
||||
onPicked: (v) => appSettings.outputMode = v
|
||||
}
|
||||
Hint {
|
||||
width: parent.width
|
||||
text: appSettings.outputMode === "beside"
|
||||
? "Stems go next to the original, in “<name> - stems”."
|
||||
: "Stems go to a folder per track inside the stems folder."
|
||||
}
|
||||
Row {
|
||||
visible: appSettings.outputMode === "folder"
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
Rectangle {
|
||||
width: parent.width - browseBtn.width - 8
|
||||
height: 36
|
||||
radius: 18
|
||||
color: Qt.rgba(1, 1, 1, 0.05)
|
||||
border.color: Qt.rgba(1, 1, 1, 0.12)
|
||||
Txt {
|
||||
anchors { fill: parent; leftMargin: 16; rightMargin: 16 }
|
||||
text: appSettings.stemsDir
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
AeroButton { id: browseBtn; variant: "tonal"; pillHeight: 36; iconName: "folder"; text: "Browse"; onClicked: folderDialog.open() }
|
||||
}
|
||||
Row {
|
||||
spacing: 16
|
||||
Txt { text: "Format"; width: 80; height: 34 }
|
||||
Segmented {
|
||||
options: [{ v: "wav", t: "WAV" }, { v: "flac", t: "FLAC" }, { v: "mp3", t: "MP3" }]
|
||||
value: appSettings.exportFormat
|
||||
onPicked: (v) => appSettings.exportFormat = v
|
||||
}
|
||||
}
|
||||
Hint { width: parent.width; text: "WAV and FLAC are 24-bit (WAV switches to 32-bit float if a stem would clip). MP3 is 320 kbps." }
|
||||
AeroSwitch {
|
||||
width: parent.width
|
||||
text: "Export all stems automatically when separation finishes"
|
||||
checked: appSettings.autoExport
|
||||
onToggled: appSettings.autoExport = checked
|
||||
}
|
||||
|
||||
SectionTitle { text: "SEPARATION" }
|
||||
AeroSwitch {
|
||||
width: parent.width
|
||||
text: "Half precision on GPU (about 2x faster on the RX 9070 XT, same quality)"
|
||||
checked: appSettings.fp16
|
||||
onToggled: appSettings.fp16 = checked
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Segment"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
id: segSlider
|
||||
width: parent.width - 200
|
||||
from: 0; to: 7.8; stepSize: 0.1
|
||||
value: appSettings.segment
|
||||
onMoved: appSettings.segment = value
|
||||
}
|
||||
Txt { text: appSettings.segment <= 0.05 ? "Model default" : appSettings.segment.toFixed(1) + " s"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Demucs chunk length. Lower it only if you run out of VRAM; 16 GB fits every model at the default." }
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Shifts"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
width: parent.width - 200
|
||||
from: 0; to: 5; stepSize: 1; snapMode: Slider.SnapAlways
|
||||
value: appSettings.shifts
|
||||
onMoved: appSettings.shifts = Math.round(value)
|
||||
}
|
||||
Txt { text: appSettings.shifts === 0 ? "Off" : appSettings.shifts + " passes"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Demucs random-shift averaging: small quality gain, each pass adds a full run." }
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Overlap"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
width: parent.width - 200
|
||||
from: 0.1; to: 0.9; stepSize: 0.05
|
||||
value: appSettings.overlap
|
||||
onMoved: appSettings.overlap = value
|
||||
}
|
||||
Txt { text: Math.round(appSettings.overlap * 100) + "%"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Segment overlap. RoFormer models always use at least 75%." }
|
||||
|
||||
SectionTitle { text: "STORAGE" }
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Stem cache uses " + app.cacheText; height: 36 }
|
||||
AeroButton { variant: "tonal"; pillHeight: 36; iconName: "delete"; text: "Clear cache"; onClicked: app.clearCache() }
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Txt { text: "Keep up to"; width: 80; height: 24 }
|
||||
AeroSlider {
|
||||
width: parent.width - 200
|
||||
from: 1; to: 40; stepSize: 1; snapMode: Slider.SnapAlways
|
||||
value: appSettings.cacheLimitGb
|
||||
onMoved: appSettings.cacheLimitGb = Math.round(value)
|
||||
}
|
||||
Txt { text: appSettings.cacheLimitGb.toFixed(0) + " GB"; width: 96; height: 24; color: Theme.textLo }
|
||||
}
|
||||
Hint { width: parent.width; text: "Cached stems make re-opening a track instant. Oldest entries are pruned past the limit." }
|
||||
|
||||
SectionTitle { text: "DEVELOPER" }
|
||||
AeroSwitch {
|
||||
width: parent.width
|
||||
text: "Frame-time overlay (p50 / p99) · F12"
|
||||
checked: appSettings.frameOverlay
|
||||
onToggled: appSettings.frameOverlay = checked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user