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

+84
View File
@@ -0,0 +1,84 @@
import QtQuick
import QtQuick.Controls.Basic
// Glossy pill, 40px. variant: "filled" | "tonal" | "text". Cyan glow on hover.
Button {
id: b
property string iconName: ""
property string variant: "filled"
property color tone: Theme.primary
property int pillHeight: 40
readonly property bool filled: variant === "filled"
readonly property bool tonal: variant === "tonal"
readonly property color fg: filled ? "#FFFFFF" : (variant === "text" ? tone : Theme.textHi)
implicitHeight: pillHeight
implicitWidth: Math.max(pillHeight, row.implicitWidth + leftPadding + rightPadding)
leftPadding: text.length ? (iconName.length ? 14 : 18) : 0
rightPadding: text.length ? 18 : 0
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
font.weight: Font.DemiBold
hoverEnabled: true
focusPolicy: Qt.TabFocus
opacity: enabled ? 1 : 0.4
contentItem: Item {
implicitWidth: row.implicitWidth
implicitHeight: row.implicitHeight
Row {
id: row
anchors.centerIn: parent
spacing: 8
Icon {
visible: b.iconName.length > 0
name: b.iconName
color: b.fg
size: 18
anchors.verticalCenter: parent.verticalCenter
}
Txt {
visible: b.text.length > 0
text: b.text
font: b.font
color: b.fg
anchors.verticalCenter: parent.verticalCenter
}
}
}
background: Item {
implicitHeight: b.pillHeight
Glow {
radius: Math.round(b.pillHeight / 2)
opacity: (b.hovered || b.visualFocus) && b.enabled ? (b.filled ? 0.55 : 0.35) : 0
}
Rectangle {
id: base
anchors.fill: parent
radius: height / 2
color: b.filled ? b.tone : (b.tonal ? Theme.alpha(b.tone, 0.16) : "transparent")
border.width: b.variant === "text" ? 0 : 1
border.color: b.filled ? Qt.lighter(b.tone, 1.3) : Theme.alpha(b.tone, 0.35)
// Aero gloss on the top half
Rectangle {
visible: b.variant !== "text"
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: (parent.height - 2) / 2
topLeftRadius: height
topRightRadius: height
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, b.filled ? 0.32 : 0.10) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, b.filled ? 0.06 : 0.0) }
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: b.down ? Qt.rgba(0, 0, 0, 0.18) : (b.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent")
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
}
}
}
+133
View File
@@ -0,0 +1,133 @@
import QtQuick
import QtQuick.Controls.Basic
// Tonal glossy dropdown; menu on surface-2 at elevation 3.
ComboBox {
id: control
property string iconName: ""
property string subRole: "sub"
property string caption: ""
property int popupWidth: 280
textRole: "label"
valueRole: "id"
implicitHeight: 36
hoverEnabled: true
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
background: Item {
Glow { radius: 18; sigma: 4; opacity: (control.hovered || control.visualFocus) ? 0.3 : 0 }
Rectangle {
anchors.fill: parent
radius: height / 2
color: control.down || control.popup.visible ? Qt.rgba(1, 1, 1, 0.11) : (control.hovered ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.05))
border.color: control.popup.visible ? Theme.alpha(Theme.accentGlow, 0.6) : Qt.rgba(1, 1, 1, 0.10)
Behavior on color { ColorAnimation { duration: Theme.fast } }
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: (parent.height - 2) / 2
topLeftRadius: height
topRightRadius: height
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.08) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
}
}
contentItem: Row {
leftPadding: control.iconName.length ? 12 : 16
spacing: 8
Icon {
visible: control.iconName.length > 0
name: control.iconName
size: 18
color: Theme.accentGlow
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
Txt {
visible: control.caption.length > 0
text: control.caption
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 0.6
color: Theme.textLo
}
Txt {
text: control.displayText
font: control.font
width: control.width - 64
}
}
}
indicator: Icon {
x: control.width - width - 10
anchors.verticalCenter: control.verticalCenter
name: "expand"
size: 18
color: Theme.textLo
rotation: control.popup.visible ? 180 : 0
Behavior on rotation { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
}
delegate: ItemDelegate {
id: d
required property var modelData
required property int index
width: ListView.view ? ListView.view.width : control.popupWidth
height: 52
highlighted: control.highlightedIndex === index
hoverEnabled: true
contentItem: Column {
spacing: 2
leftPadding: 4
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
Txt {
text: d.modelData[control.textRole]
font.weight: control.currentIndex === d.index ? Font.DemiBold : Font.Normal
color: control.currentIndex === d.index ? Theme.accentGlow : Theme.textHi
}
Txt {
text: d.modelData[control.subRole] || ""
visible: text.length > 0
font.pixelSize: Theme.fs12
color: Theme.textLo
}
}
background: Rectangle {
radius: 6
color: d.highlighted ? Theme.alpha(Theme.primary, 0.18) : (d.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent")
}
}
popup: Popup {
y: control.height + 6
width: Math.max(control.width, control.popupWidth)
padding: 6
transformOrigin: Item.Top
implicitHeight: Math.min(contentItem.implicitHeight + 12, 420)
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: control.popup.visible ? control.delegateModel : null
currentIndex: control.highlightedIndex
spacing: 2
boundsBehavior: Flickable.StopAtBounds
}
background: Rectangle {
color: Theme.surface2
radius: 8
border.color: Theme.outline
Elevation { level: 3; radius: 8 }
}
enter: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.fast }
NumberAnimation { property: "scale"; from: 0.96; to: 1; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
}
}
+46
View File
@@ -0,0 +1,46 @@
import QtQuick
import QtQuick.Controls.Basic
Slider {
id: s
property color tone: Theme.primary
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
x: s.leftPadding
y: s.topPadding + s.availableHeight / 2 - height / 2
width: s.availableWidth
height: 4
radius: 2
color: Qt.rgba(1, 1, 1, 0.10)
Rectangle {
width: s.visualPosition * parent.width
height: parent.height
radius: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.alpha(s.tone, 0.7) }
GradientStop { position: 1; color: s.tone }
}
}
}
handle: Item {
x: s.leftPadding + s.visualPosition * (s.availableWidth - width)
y: s.topPadding + s.availableHeight / 2 - height / 2
width: 14
height: 14
Glow { radius: 7; sigma: 4; color: s.tone; opacity: s.hovered || s.pressed ? 0.6 : 0 }
Rectangle {
anchors.fill: parent
radius: 7
color: s.pressed ? Qt.lighter(s.tone, 1.2) : s.tone
border.color: Qt.lighter(s.tone, 1.4)
Rectangle { // gloss
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
height: 5
radius: 3
color: Qt.rgba(1, 1, 1, 0.45)
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls.Basic
Switch {
id: sw
hoverEnabled: true
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
spacing: 12
indicator: Rectangle {
implicitWidth: 40
implicitHeight: 22
x: sw.leftPadding
y: parent.height / 2 - height / 2
radius: 11
color: sw.checked ? Theme.primary : Qt.rgba(1, 1, 1, 0.10)
border.color: sw.checked ? Qt.lighter(Theme.primary, 1.3) : Qt.rgba(1, 1, 1, 0.16)
Behavior on color { ColorAnimation { duration: Theme.med } }
Glow { radius: 11; sigma: 4; opacity: sw.hovered ? 0.35 : 0 }
Rectangle {
x: sw.checked ? parent.width - width - 3 : 3
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
radius: 8
color: "#FFFFFF"
Behavior on x { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
height: 6
radius: 3
color: Qt.rgba(1, 1, 1, 0.6)
}
}
}
contentItem: Txt {
text: sw.text
font: sw.font
leftPadding: sw.indicator.width + sw.spacing
wrapMode: Text.WordWrap
elide: Text.ElideNone
}
}
@@ -0,0 +1,31 @@
import QtQuick
import QtQuick.Controls.Basic
// Win7-flavoured caption button; close turns glossy red on hover.
AbstractButton {
id: b
property string iconName: "minimize"
property bool danger: false
implicitWidth: 46
implicitHeight: 30
hoverEnabled: true
focusPolicy: Qt.NoFocus
background: Rectangle {
radius: 6
gradient: Gradient {
GradientStop { position: 0; color: b.danger && b.hovered ? "#F0645A" : (b.hovered ? Qt.rgba(1, 1, 1, 0.14) : "transparent") }
GradientStop { position: 0.5; color: b.danger && b.hovered ? "#D8392B" : (b.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent") }
GradientStop { position: 1; color: b.danger && b.hovered ? "#C42B1C" : (b.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent") }
}
border.color: b.hovered ? (b.danger ? "#FF8A80" : Qt.rgba(1, 1, 1, 0.14)) : "transparent"
Rectangle {
anchors.fill: parent
radius: parent.radius
color: b.down ? Qt.rgba(0, 0, 0, 0.2) : "transparent"
}
Glow { radius: 6; sigma: 5; color: "#FF5C4C"; opacity: b.danger && b.hovered ? 0.5 : 0 }
}
contentItem: Item {
Icon { anchors.centerIn: parent; name: b.iconName; size: 16; color: b.hovered ? "#FFFFFF" : Theme.textHi }
}
}
+26
View File
@@ -0,0 +1,26 @@
import QtQuick
// Material card (elevation 2, radius 8) with Aero top gloss: white 6% -> 0 over the top 40%.
Rectangle {
id: card
property int elevation: 2
property bool glossy: true
radius: 8
color: Theme.surface1
border.color: Theme.hairline
border.width: 1
Elevation { level: card.elevation; radius: card.radius }
Rectangle {
visible: card.glossy
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: (parent.height - 2) * 0.4
topLeftRadius: card.radius - 1
topRightRadius: card.radius - 1
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.06) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import QtQuick
import QtQuick.Shapes
// Static dashed rounded border (geometry built once; only opacity animates).
Shape {
id: s
property color color: Theme.accentGlow
property real radius: 12
property real lineWidth: 2
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: s.color
strokeWidth: s.lineWidth
strokeStyle: ShapePath.DashLine
dashPattern: [5, 4]
fillColor: "transparent"
PathRectangle {
x: s.lineWidth / 2
y: s.lineWidth / 2
width: s.width - s.lineWidth
height: s.height - s.lineWidth
radius: s.radius
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import QtQuick
// Empty state: card filling the main pane; dashed border glows on drag-over.
Item {
id: dz
property bool dragging: false
signal browse()
Card {
id: card
anchors.fill: parent
anchors.margins: 24
radius: 16
color: dz.dragging ? Theme.alpha(Theme.primary, 0.08) : Theme.alpha(Theme.surface1, 0.72)
Behavior on color { ColorAnimation { duration: Theme.med } }
Glow { radius: 16; sigma: 8; opacity: dz.dragging ? 0.55 : 0 }
DashedFrame {
anchors.fill: parent
anchors.margins: 10
radius: 12
color: dz.dragging ? Theme.accentGlow : Theme.alpha(Theme.textLo, 0.35)
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: dz.browse()
}
Column {
anchors.centerIn: parent
spacing: 16
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 88
height: 88
radius: 44
color: Theme.alpha(Theme.primary, dz.dragging ? 0.28 : 0.14)
border.color: Theme.alpha(Theme.accentGlow, 0.5)
scale: dz.dragging ? 1.08 : 1
Behavior on scale { NumberAnimation { duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Behavior on color { ColorAnimation { duration: Theme.med } }
Glow { radius: 44; sigma: 8; opacity: 0.35 }
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 3 }
height: 40
topLeftRadius: 41
topRightRadius: 41
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.18) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
Icon { anchors.centerIn: parent; name: "upload"; size: 40; color: Theme.accentGlow }
}
Txt {
anchors.horizontalCenter: parent.horizontalCenter
text: dz.dragging ? "Release to separate" : "Drop audio files here"
font.pixelSize: Theme.fs28
font.weight: Font.DemiBold
}
Txt {
anchors.horizontalCenter: parent.horizontalCenter
text: "or click to browse · MP3, WAV, FLAC, M4A, OGG, OPUS and more"
color: Theme.textLo
font.pixelSize: Theme.fs14
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
topPadding: 8
Repeater {
model: ["vocals", "drums", "bass", "other"]
Rectangle {
required property string modelData
height: 26
width: chipText.implicitWidth + 28
radius: 13
color: Theme.alpha(Theme.stemColor(modelData), 0.14)
border.color: Theme.alpha(Theme.stemColor(modelData), 0.4)
Rectangle { x: 10; anchors.verticalCenter: parent.verticalCenter; width: 6; height: 6; radius: 3; color: Theme.stemColor(parent.modelData) }
Txt { id: chipText; x: 21; anchors.verticalCenter: parent.verticalCenter; text: parent.modelData; font.pixelSize: Theme.fs12; color: Theme.textHi }
}
}
}
Txt {
anchors.horizontalCenter: parent.horizontalCenter
topPadding: 4
text: "Separates with " + (app.models.find(m => m.id === appSettings.modelId) || {label: ""}).label
+ " on " + ((app.devices.find(d => d.id === appSettings.deviceId) || {label: "Auto"}).label)
color: Theme.alpha(Theme.textLo, 0.8)
font.pixelSize: Theme.fs12
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import QtQuick
// Static 9-patch shadow: no runtime blur, one textured quad.
BorderImage {
id: shadow
property int level: 2
property int radius: 8
readonly property real sigma: Theme.shadowSigma[level]
readonly property int m: Math.ceil(sigma * 3) + 1
readonly property int dy: Theme.shadowDy[level]
visible: level > 0
z: -1
anchors.fill: parent
anchors.leftMargin: -m
anchors.rightMargin: -m
anchors.topMargin: -m + dy
anchors.bottomMargin: -m - dy
border { left: m + radius; right: m + radius; top: m + radius; bottom: m + radius }
source: "image://fx/shadow/" + sigma + "/" + Theme.shadowAlpha[level] + "/" + radius
cache: true
}
@@ -0,0 +1,74 @@
import QtQuick
// Dev overlay: p50/p99 frame interval (and render-thread work with --render-hooks).
// While visible, the heartbeat bar changes every frame so the window renders at
// full refresh and the numbers describe real 240Hz cadence, not idle frames.
Rectangle {
id: o
property var s: ({})
property real acc: 0
property var batch: []
property real beat: 0
width: 236
height: col.implicitHeight + 24
radius: 8
color: Theme.alpha(Theme.surface1, 0.92)
border.color: Theme.outline
readonly property color good: Theme.success
readonly property color bad: Theme.error
onVisibleChanged: frameStats.setHooks(visible)
FrameAnimation {
running: o.visible
onTriggered: {
o.batch.push(frameTime * 1000)
if (frameTime * 1000 > 1.5 * (o.s.budget_ms || 4.17)) frameStats.miss(frameTime * 1000)
o.beat = (o.beat + frameTime) % 1
o.acc += frameTime
if (o.acc > 0.25) {
o.acc = 0
frameStats.push(o.batch)
o.batch = []
o.s = frameStats.summary()
}
}
}
Rectangle { // heartbeat: moves every frame
anchors.bottom: parent.bottom
anchors.bottomMargin: 3
x: 8 + (parent.width - 16 - width) * o.beat
width: 24
height: 2
radius: 1
color: Theme.accentGlow
opacity: 0.6
}
Column {
id: col
anchors { left: parent.left; top: parent.top; margins: 10 }
spacing: 3
Txt {
text: (o.s.refresh_hz || 0).toFixed(0) + " Hz panel · " + (o.s.fps || 0).toFixed(1) + " fps"
font.pixelSize: Theme.fs12
font.weight: Font.DemiBold
font.features: { "tnum": 1 }
color: (o.s.fps || 0) >= (o.s.refresh_hz || 240) * 0.97 ? o.good : Theme.warning
}
Txt {
text: o.s.work_p50 === null || o.s.work_p50 === undefined ? "frame time measuring…"
: "frame time p50 " + o.s.work_p50.toFixed(2) + " p99 " + o.s.work_p99.toFixed(2) + " ms"
font.pixelSize: 11; font.features: { "tnum": 1 }
color: (o.s.work_p99 || 0) < (o.s.budget_ms || 4.17) ? Theme.textHi : o.bad
}
Txt {
text: "interval p50 " + (o.s.interval_p50 || 0).toFixed(2) + " p99 " + (o.s.interval_p99 || 0).toFixed(2) + " ms"
font.pixelSize: 11; font.features: { "tnum": 1 }
color: Theme.textLo
}
Txt {
text: "budget " + (o.s.budget_ms || 0).toFixed(2) + " ms · missed " + (o.s.missed || 0) + "/" + (o.s.frames || 0)
font.pixelSize: 11; font.features: { "tnum": 1 }
color: (o.s.missed || 0) === 0 ? Theme.textLo : o.bad
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import QtQuick
// Aero glass for chrome. Shows the window's cached, pre-blurred backdrop
// (refreshed only on resize), so nothing is blurred per frame.
Item {
id: root
property real offsetX: 0
property real offsetY: 0
property int blur: 16
property real topLeftRadius: 0
property real topRightRadius: 0
property real bottomLeftRadius: 0
property real bottomRightRadius: 0
property bool gloss: true
property bool innerBorder: true
clip: true
readonly property var win: Window.window
Image {
x: -root.offsetX
y: -root.offsetY
width: root.win ? root.win.width : 0
height: root.win ? root.win.height : 0
source: root.win ? "image://fx/glass/" + root.win.fxW + "x" + root.win.fxH + "/" + root.win.radius + "/" + root.blur : ""
asynchronous: true
retainWhileLoading: true
cache: true
smooth: true
}
// Aero gloss: top highlight band
Rectangle {
visible: root.gloss
anchors { left: parent.left; right: parent.right; top: parent.top }
height: Math.min(parent.height * 0.5, 30)
topLeftRadius: root.topLeftRadius
topRightRadius: root.topRightRadius
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.07) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
// 1px inner white border at 8%
Rectangle {
visible: root.innerBorder
anchors.fill: parent
color: "transparent"
border.color: Theme.outline
border.width: 1
topLeftRadius: root.topLeftRadius
topRightRadius: root.topRightRadius
bottomLeftRadius: root.bottomLeftRadius
bottomRightRadius: root.bottomRightRadius
}
}
+18
View File
@@ -0,0 +1,18 @@
import QtQuick
// Static 9-patch Aero hover glow; animate `opacity` only.
BorderImage {
property color color: Theme.accentGlow
property int radius: 20
property int sigma: 6
readonly property int m: Math.ceil(sigma * 3) + 1
anchors.fill: parent
anchors.margins: -m
border { left: m + radius; right: m + radius; top: m + radius; bottom: m + radius }
source: "image://fx/glow/" + Theme.hex(color) + "/" + sigma + "/" + radius
opacity: 0
visible: opacity > 0.001
z: -1
cache: true
Behavior on opacity { NumberAnimation { duration: Theme.fast; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
}
+14
View File
@@ -0,0 +1,14 @@
import QtQuick
// Material icon rendered once per (name, color, size) by the icon provider.
Image {
property string name: "music"
property color color: Theme.textHi
property int size: 20
width: size
height: size
sourceSize: Qt.size(size * Screen.devicePixelRatio, size * Screen.devicePixelRatio)
source: Theme.icon(name, color)
smooth: true
asynchronous: false
}
+47
View File
@@ -0,0 +1,47 @@
import QtQuick
import QtQuick.Controls.Basic
// Round icon button; checkable variant used for solo / mute.
AbstractButton {
id: b
property string iconName: "music"
property color iconColor: Theme.textLo
property color activeColor: Theme.primary
property color hoverColor: Theme.alpha(Theme.textHi, 0.08)
property int size: 32
property int iconSize: 18
property string tip: ""
property bool glow: true
implicitWidth: size
implicitHeight: size
hoverEnabled: true
focusPolicy: Qt.TabFocus
opacity: enabled ? 1 : 0.35
background: Item {
Glow {
radius: Math.round(b.size / 2)
sigma: 4
color: b.checked ? b.activeColor : Theme.accentGlow
opacity: b.glow && (b.hovered || b.visualFocus) && b.enabled ? 0.35 : 0
}
Rectangle {
anchors.fill: parent
radius: width / 2
color: b.checked ? Theme.alpha(b.activeColor, b.down ? 0.34 : 0.22)
: (b.down ? Theme.alpha(Theme.textHi, 0.14) : (b.hovered ? b.hoverColor : "transparent"))
border.width: b.checked ? 1 : 0
border.color: Theme.alpha(b.activeColor, 0.6)
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
}
contentItem: Item {
Icon {
anchors.centerIn: parent
name: b.iconName
size: b.iconSize
color: b.checked ? b.activeColor : (b.hovered ? Theme.textHi : b.iconColor)
}
}
Tip { visible: b.hovered && b.tip.length > 0; text: b.tip }
}
+73
View File
@@ -0,0 +1,73 @@
import QtQuick
// Stereo peak meter fed per frame from the audio ring buffer (via Main).
Item {
id: m
property real levelLeft: 0
property real levelRight: 0
property real holdL: 0
property real holdR: 0
implicitWidth: 150
implicitHeight: 22
function frac(v) {
if (v <= 0.000001) return 0
var db = 20 * Math.log(v) / Math.LN10
return Math.max(0, Math.min(1, (db + 54) / 54))
}
onLevelLeftChanged: holdL = Math.max(holdL, frac(levelLeft))
onLevelRightChanged: holdR = Math.max(holdR, frac(levelRight))
FrameAnimation {
running: m.holdL > 0.001 || m.holdR > 0.001
onTriggered: {
var d = frameTime * 0.35
m.holdL = Math.max(m.frac(m.levelLeft), m.holdL - d)
m.holdR = Math.max(m.frac(m.levelRight), m.holdR - d)
}
}
Column {
anchors.fill: parent
spacing: 4
Repeater {
model: 2
Item {
required property int index
readonly property real level: m.frac(index === 0 ? m.levelLeft : m.levelRight)
readonly property real hold: index === 0 ? m.holdL : m.holdR
width: m.width
height: (m.height - 4) / 2
Rectangle {
anchors.fill: parent
radius: 2
color: Qt.rgba(1, 1, 1, 0.07)
}
Item {
width: parent.width * parent.level
height: parent.height
clip: true
Rectangle {
width: m.width
height: parent.height
radius: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.success }
GradientStop { position: 0.72; color: Theme.accentGlow }
GradientStop { position: 0.88; color: Theme.warning }
GradientStop { position: 1.0; color: Theme.error }
}
}
}
Rectangle {
visible: parent.hold > 0.01
x: Math.min(parent.width - 2, parent.width * parent.hold)
width: 2
height: parent.height
color: parent.hold > 0.95 ? Theme.error : Theme.textHi
opacity: 0.85
}
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Dialogs
import StemRoller
Window {
id: win
width: 1440
height: 900
minimumWidth: 960
minimumHeight: 600
visible: true
title: "StemRoller"
color: "transparent"
flags: Qt.Window | Qt.FramelessWindowHint
readonly property bool maximized: visibility === Window.Maximized || visibility === Window.FullScreen
readonly property int radius: maximized ? 0 : 10
function toggleMaximized() { maximized ? showNormal() : showMaximized() }
// Glass snapshot size: refreshed only after a resize settles.
property int fxW: width
property int fxH: height
Timer { id: fxTimer; interval: 150; onTriggered: { win.fxW = win.width; win.fxH = win.height } }
onWidthChanged: fxTimer.restart()
onHeightChanged: fxTimer.restart()
// Per-frame playback state, read from the audio ring buffer.
property real playPos: 0
property real levelL: 0
property real levelR: 0
FrameAnimation {
running: audio.playing || win.levelL > 0.0005 || win.levelR > 0.0005
onTriggered: {
var s = audio.snapshot()
if (s[3]) win.playPos = s[0]
var k = Math.exp(-frameTime * 10)
win.levelL = Math.max(s[1], win.levelL * k)
win.levelR = Math.max(s[2], win.levelR * k)
if (win.levelL < 0.0005) win.levelL = 0
if (win.levelR < 0.0005) win.levelR = 0
}
}
Connections {
target: audio
function onLoadedChanged() { win.playPos = 0 }
}
Connections {
target: app
function onToast(msg, kind, folder) { toast.show(msg, kind, folder) }
}
Component.onCompleted: Theme.fontFamily = uiFont
FileDialog {
id: fileDialog
title: "Add audio files"
fileMode: FileDialog.OpenFiles
nameFilters: ["Audio (*.mp3 *.wav *.flac *.ogg *.oga *.opus *.m4a *.aac *.aif *.aiff *.wma *.mp4 *.mkv *.webm *.mka *.wv *.ape)", "All files (*)"]
onAccepted: app.addUrls(selectedFiles)
}
Shortcut { sequences: ["Space"]; enabled: audio.loaded && !settings.opened; onActivated: audio.toggle() }
Shortcut { sequences: ["Home"]; enabled: audio.loaded; onActivated: { audio.seek(0); win.playPos = 0 } }
Shortcut { sequences: [StandardKey.Open]; onActivated: fileDialog.open() }
Shortcut { sequences: ["Ctrl+,"]; onActivated: settings.open() }
Shortcut { sequences: ["F12"]; onActivated: appSettings.frameOverlay = !appSettings.frameOverlay }
Shortcut { sequences: ["F11"]; onActivated: win.toggleMaximized() }
Shortcut { sequences: [StandardKey.Quit]; onActivated: win.close() }
Item {
id: frame
anchors.fill: parent
// window background: placeholder until the aurora texture arrives
Rectangle {
anchors.fill: parent
radius: win.radius
color: Theme.bg
}
Image {
id: backdrop
anchors.fill: parent
source: "image://fx/backdrop/" + win.fxW + "x" + win.fxH + "/" + win.radius
asynchronous: true
retainWhileLoading: true
cache: true
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Theme.slow } }
}
TitleBar {
id: titleBar
anchors { left: parent.left; right: parent.right; top: parent.top }
offsetX: 0
offsetY: 0
onOpenSettings: settings.open()
}
Sidebar {
id: sidebar
anchors { left: parent.left; top: titleBar.bottom; bottom: transport.top }
width: 220
offsetX: 0
offsetY: titleBar.height
onBrowse: fileDialog.open()
Rectangle { anchors { right: parent.right; top: parent.top; bottom: parent.bottom } width: 1; color: Theme.hairline }
}
Item {
id: mainPane
anchors { left: sidebar.right; right: parent.right; top: titleBar.bottom; bottom: transport.top }
clip: true
Image { // dither the dark gradients behind content
anchors.fill: parent
source: "image://fx/noise"
fillMode: Image.Tile
cache: true
}
DropZone {
anchors.fill: parent
visible: !app.selected.valid
dragging: drop.containsDrag
onBrowse: fileDialog.open()
}
TrackView {
anchors.fill: parent
visible: app.selected.valid
}
// drag-over affordance when a track is already showing
Item {
anchors.fill: parent
visible: opacity > 0
opacity: drop.containsDrag && app.selected.valid ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Theme.med } }
Rectangle { anchors.fill: parent; color: Theme.alpha(Theme.bg, 0.82) }
DashedFrame { anchors.fill: parent; anchors.margins: 24; radius: 16 }
Glow { anchors.margins: 24 - m; radius: 16; sigma: 8; opacity: 0.4 }
Txt {
anchors.centerIn: parent
text: "Drop to add to the queue"
font.pixelSize: Theme.fs28
font.weight: Font.DemiBold
}
}
FrameOverlay {
visible: appSettings.frameOverlay || devOverlay
anchors { right: parent.right; bottom: parent.bottom; margins: 12 }
z: 100
}
}
Transport {
id: transport
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
height: 64
offsetX: 0
offsetY: win.height - 64
}
Item {
anchors { horizontalCenter: mainPane.horizontalCenter; bottom: transport.top; bottomMargin: 16 }
width: toast.width
height: 48
Toast { id: toast; maxWidth: mainPane.width - 48 }
}
// window edge highlight
Rectangle {
anchors.fill: parent
radius: win.radius
color: "transparent"
border.width: win.maximized ? 0 : 1
border.color: Qt.rgba(1, 1, 1, 0.12)
}
DropArea {
id: drop
anchors.fill: parent
keys: ["text/uri-list"]
onDropped: (e) => {
if (e.hasUrls) {
app.addUrls(e.urls)
e.acceptProposedAction()
}
}
}
// resize grips (compositor-driven)
Item {
anchors.fill: parent
visible: !win.maximized
readonly property int g: 6
ResizeEdge { edges: Qt.LeftEdge; x: 0; y: 12; width: parent.g; height: parent.height - 24 }
ResizeEdge { edges: Qt.RightEdge; x: parent.width - parent.g; y: 12; width: parent.g; height: parent.height - 24 }
ResizeEdge { edges: Qt.TopEdge; x: 12; y: 0; width: parent.width - 24; height: parent.g - 2 }
ResizeEdge { edges: Qt.BottomEdge; x: 12; y: parent.height - parent.g; width: parent.width - 24; height: parent.g }
ResizeEdge { edges: Qt.LeftEdge | Qt.TopEdge; x: 0; y: 0; width: 12; height: 12 }
ResizeEdge { edges: Qt.RightEdge | Qt.TopEdge; x: parent.width - 12; y: 0; width: 12; height: 12 }
ResizeEdge { edges: Qt.LeftEdge | Qt.BottomEdge; x: 0; y: parent.height - 12; width: 12; height: 12 }
ResizeEdge { edges: Qt.RightEdge | Qt.BottomEdge; x: parent.width - 12; y: parent.height - 12; width: 12; height: 12 }
}
}
SettingsDialog { id: settings; parent: Overlay.overlay; win: win }
}
+89
View File
@@ -0,0 +1,89 @@
import QtQuick
import QtQuick.Controls.Basic
ItemDelegate {
id: item
required property string jobId
required property string fileName
required property string modelLabel
required property string status
required property real progress
required property string stage
required property int index
readonly property bool selected: app.selected.valid && app.selected.id === jobId
readonly property bool active: status === "running" || status === "queued"
width: ListView.view ? ListView.view.width : 200
height: active ? 84 : 66
hoverEnabled: true
padding: 0
onClicked: app.select(jobId)
Behavior on height { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
background: Rectangle {
radius: 8
color: item.selected ? Theme.alpha(Theme.primary, 0.16) : (item.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent")
border.color: item.selected ? Theme.alpha(Theme.primary, 0.45) : "transparent"
Behavior on color { ColorAnimation { duration: Theme.fast } }
Rectangle {
visible: item.selected
x: 0
anchors.verticalCenter: parent.verticalCenter
width: 3
height: parent.height - 20
radius: 2
color: Theme.accentGlow
}
}
contentItem: Item {
Column {
anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 12; rightMargin: 6; topMargin: 10 }
spacing: 6
Row {
width: parent.width
spacing: 8
Icon {
anchors.verticalCenter: parent.verticalCenter
size: 16
name: item.status === "done" ? "check" : item.status === "error" ? "error" : "music"
color: item.status === "done" ? Theme.success : item.status === "error" ? Theme.error : Theme.textLo
}
Txt {
width: parent.width - 16 - 28 - 16
anchors.verticalCenter: parent.verticalCenter
text: item.fileName
font.weight: item.selected ? Font.DemiBold : Font.Medium
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
size: 24
iconSize: 14
iconName: "close"
glow: false
tip: item.active ? "Cancel" : "Remove"
onClicked: item.active ? app.cancelJob(item.jobId) : app.removeJob(item.jobId)
}
}
Row {
width: parent.width
spacing: 8
Txt {
width: parent.width - chip.width - 14
anchors.verticalCenter: parent.verticalCenter
text: item.status === "running" ? item.stage : item.modelLabel
font.pixelSize: Theme.fs12
color: Theme.textLo
}
StatusChip { id: chip; status: item.status; progress: item.progress }
}
StripedProgress {
visible: item.active
width: parent.width - 6
value: item.progress
active: item.status === "running"
opacity: item.status === "running" ? 1 : 0.35
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import QtQuick
// Invisible edge/corner grip that hands resizing to the compositor.
MouseArea {
property int edges: 0
property var win: Window.window
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: edges === (Qt.LeftEdge | Qt.TopEdge) || edges === (Qt.RightEdge | Qt.BottomEdge) ? Qt.SizeFDiagCursor
: edges === (Qt.RightEdge | Qt.TopEdge) || edges === (Qt.LeftEdge | Qt.BottomEdge) ? Qt.SizeBDiagCursor
: (edges & (Qt.LeftEdge | Qt.RightEdge)) ? Qt.SizeHorCursor : Qt.SizeVerCursor
onPressed: win.startSystemResize(edges)
}
@@ -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
}
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import QtQuick
import QtQuick.Controls.Basic
GlassPanel {
id: side
signal browse()
Item {
id: header
anchors { left: parent.left; right: parent.right; top: parent.top }
height: 52
Txt {
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
text: "Queue"
font.pixelSize: Theme.fs16
font.weight: Font.DemiBold
}
Rectangle {
visible: list.count > 0
anchors { left: parent.left; leftMargin: 76; verticalCenter: parent.verticalCenter }
width: cnt.implicitWidth + 12
height: 18
radius: 9
color: Qt.rgba(1, 1, 1, 0.08)
Txt { id: cnt; anchors.centerIn: parent; text: list.count; font.pixelSize: 11; color: Theme.textLo }
}
IconButton {
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
iconName: "add"
tip: "Add files (Ctrl+O)"
onClicked: side.browse()
}
}
ListView {
id: list
anchors { left: parent.left; right: parent.right; top: header.bottom; bottom: parent.bottom; margins: 8; topMargin: 0 }
model: jobsModel
spacing: 4
clip: true
boundsBehavior: Flickable.StopAtBounds
delegate: QueueItem {}
add: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.med }
NumberAnimation { property: "x"; from: -16; duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
displaced: Transition {
NumberAnimation { properties: "y"; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
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 {}
}
Column {
visible: list.count === 0
anchors.centerIn: parent
spacing: 8
width: parent.width - 32
Icon { anchors.horizontalCenter: parent.horizontalCenter; name: "upload"; size: 28; color: Theme.alpha(Theme.textLo, 0.7) }
Txt {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: "Drop audio anywhere to queue it"
color: Theme.textLo
font.pixelSize: Theme.fs12
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import QtQuick
Rectangle {
id: chip
property string status: "queued"
property real progress: 0
readonly property color tone: status === "done" ? Theme.success
: status === "error" ? Theme.error
: status === "running" ? Theme.primary : Theme.textLo
readonly property string label: status === "running" ? Math.floor(progress * 100) + "%"
: status === "done" ? "Done"
: status === "error" ? "Failed"
: status === "cancelled" ? "Cancelled" : "Queued"
implicitWidth: t.implicitWidth + 16
implicitHeight: 20
radius: 10
color: Theme.alpha(tone, 0.16)
border.color: Theme.alpha(tone, 0.35)
Txt {
id: t
anchors.centerIn: parent
text: chip.label
color: chip.tone
font.pixelSize: 11
font.weight: Font.DemiBold
font.features: { "tnum": 1 }
}
}
+147
View File
@@ -0,0 +1,147 @@
import QtQuick
import QtQuick.Controls.Basic
// One lane per stem: color strip, solo / mute / gain / export, waveform texture.
Item {
id: lane
required property int index
required property var modelData
property string jobId: ""
property real viewStart: 0
property real viewEnd: 1
property int waveW: 100
property int waveH: 60
property real waveX: 0
property bool anySolo: false
property bool solo: false
property bool mute: false
property real gainDb: 0
signal soloToggled(bool on)
readonly property string stem: modelData.name
readonly property color tone: modelData.color
readonly property bool audible: anySolo ? solo : !mute
height: 92
Card {
anchors.fill: parent
clip: false
// color strip with soft glow
Rectangle {
x: 0
width: 4
height: parent.height
topLeftRadius: 8
bottomLeftRadius: 8
color: lane.tone
}
Rectangle {
x: 4
width: 24
height: parent.height
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.alpha(lane.tone, 0.14) }
GradientStop { position: 1; color: Theme.alpha(lane.tone, 0.0) }
}
}
Column {
id: controls
x: 18
width: lane.waveX - 30
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Row {
spacing: 6
width: parent.width
Txt {
width: parent.width - 3 * 30 - 12
text: lane.stem.charAt(0).toUpperCase() + lane.stem.slice(1)
font.pixelSize: Theme.fs16
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
IconButton {
size: 30; iconSize: 16
iconName: "headphones"
checkable: true
checked: lane.solo
activeColor: Theme.warning
tip: "Solo"
onClicked: lane.soloToggled(checked)
}
IconButton {
size: 30; iconSize: 16
iconName: lane.mute ? "volume_off" : "volume"
checkable: true
checked: lane.mute
activeColor: Theme.error
tip: "Mute"
onClicked: { lane.mute = checked; audio.setMute(lane.index, checked) }
}
IconButton {
size: 30; iconSize: 16
iconName: "download"
tip: "Export " + lane.stem
onClicked: app.exportStem(lane.stem)
}
}
Row {
spacing: 8
width: parent.width
AeroSlider {
id: gain
width: parent.width - 52
from: -24
to: 12
stepSize: 0.5
value: lane.gainDb
tone: lane.tone
onMoved: { lane.gainDb = value; audio.setGain(lane.index, value) }
// double-click resets to unity
TapHandler { onDoubleTapped: { lane.gainDb = 0; audio.setGain(lane.index, 0) } }
}
Txt {
width: 44
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: (lane.gainDb > 0 ? "+" : "") + lane.gainDb.toFixed(1) + " dB"
font.pixelSize: 11
font.features: { "tnum": 1 }
color: lane.gainDb === 0 ? Theme.textLo : Theme.textHi
}
}
}
Rectangle { // waveform well
x: lane.waveX
y: 8
width: lane.waveW
height: parent.height - 16
radius: 6
color: Qt.rgba(0, 0, 0, 0.22)
border.color: Qt.rgba(1, 1, 1, 0.04)
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: Theme.alpha(lane.tone, 0.18)
}
Image {
anchors.fill: parent
asynchronous: true
retainWhileLoading: true
cache: false
smooth: true
opacity: lane.audible ? 1 : 0.28
Behavior on opacity { NumberAnimation { duration: Theme.med } }
source: lane.jobId && lane.waveW > 4
? "image://wave/" + lane.jobId + "/" + lane.stem + "/"
+ Math.round(lane.waveW * Screen.devicePixelRatio) + "x" + Math.round((lane.height - 16) * Screen.devicePixelRatio)
+ "/" + lane.viewStart.toFixed(3) + "/" + lane.viewEnd.toFixed(3) + "/" + Theme.hex(lane.tone)
: ""
}
}
}
}
@@ -0,0 +1,63 @@
import QtQuick
// Win7-style green-to-cyan striped bar. Backend sends <= 30 updates/s;
// `shown` eases toward `value` and the stripes advance per frame by delta-time.
Item {
id: root
property real value: 0
property bool active: true
property real shown: 0
property real phase: 0
property int barHeight: 6
implicitHeight: barHeight
readonly property int tile: barHeight * 2
onValueChanged: if (value < shown) shown = value
Rectangle {
anchors.fill: parent
radius: height / 2
color: Qt.rgba(1, 1, 1, 0.07)
border.color: Qt.rgba(1, 1, 1, 0.05)
}
Item {
id: fill
height: parent.height
width: Math.max(root.active ? root.barHeight * 2 : 0, parent.width * Math.min(1, root.shown))
clip: true
Rectangle {
anchors.fill: parent
radius: height / 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.success }
GradientStop { position: 1; color: Theme.accentGlow }
}
}
Image {
visible: root.active
x: -root.phase
width: parent.width + root.tile
height: parent.height
fillMode: Image.Tile
source: "image://fx/stripes/" + root.barHeight
cache: true
}
Rectangle { // gloss
anchors { left: parent.left; right: parent.right; top: parent.top }
height: parent.height / 2
topLeftRadius: height
topRightRadius: height
color: Qt.rgba(1, 1, 1, 0.28)
}
}
FrameAnimation {
running: root.visible && (root.active || Math.abs(root.value - root.shown) > 0.0005)
onTriggered: {
root.shown += (root.value - root.shown) * (1 - Math.exp(-frameTime * 10))
if (root.active)
root.phase = (root.phase + frameTime * root.tile * 2.5) % root.tile
}
}
}
+65
View File
@@ -0,0 +1,65 @@
pragma Singleton
import QtQuick
// Design tokens: Material structure (8dp grid, type ramp, motion), Aero surface.
QtObject {
// dark gray tokens
readonly property color bg: "#1D1E21"
readonly property color surface1: "#26272B"
readonly property color surface2: "#2F3034"
readonly property color glassTint: Qt.rgba(36 / 255, 37 / 255, 41 / 255, 0.70)
readonly property color primary: "#3BA8FF"
readonly property color accentGlow: "#5FE3FF"
readonly property color success: "#4CD07D"
readonly property color error: "#FF5C6C"
readonly property color warning: "#FFB547"
readonly property color textHi: "#ECEDEF"
readonly property color textLo: "#9B9DA3"
readonly property color outline: Qt.rgba(1, 1, 1, 0.08)
readonly property color hairline: Qt.rgba(1, 1, 1, 0.06)
// 8dp grid
readonly property int u: 8
// type ramp 12 / 14 / 16 / 20 / 28
property string fontFamily: "Inter"
readonly property int fs12: 12
readonly property int fs14: 14
readonly property int fs16: 16
readonly property int fs20: 20
readonly property int fs28: 28
// motion: Material standard easing, nothing over 300 ms
readonly property int fast: 100
readonly property int med: 200
readonly property int slow: 300
readonly property var ease: [0.2, 0, 0, 1, 1, 1]
// elevation: blur sigma, alpha, y offset per level
readonly property var shadowSigma: [0, 2, 4, 8]
readonly property var shadowAlpha: [0, 0.35, 0.45, 0.55]
readonly property var shadowDy: [0, 1, 2, 4]
readonly property var stemColors: ({
"vocals": "#FF7AB6", "drums": "#FFB547", "bass": "#7C8CFF", "other": "#4CD07D",
"guitar": "#FF8A5C", "piano": "#B48CFF", "instrumental": "#3DD6C6"
})
readonly property var stemIcons: ({
"vocals": "music", "drums": "equalizer", "bass": "speed", "other": "layers",
"guitar": "music", "piano": "music", "instrumental": "layers"
})
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
function hex(c) { return c.toString().substring(1, 7).toUpperCase() }
function icon(name, color) { return "image://icon/" + name + "/" + hex(color) }
function stemColor(name) { return stemColors[name] || "#9B9DA3" }
function time(sec, tenths) {
if (!isFinite(sec) || sec < 0) sec = 0
var m = Math.floor(sec / 60)
var s = sec - m * 60
var ss = tenths ? s.toFixed(1) : Math.floor(s).toString()
if ((tenths && s < 10) || (!tenths && s < 10)) ss = "0" + ss
return m + ":" + ss
}
}
+18
View File
@@ -0,0 +1,18 @@
import QtQuick
import QtQuick.Controls.Basic
ToolTip {
id: tip
delay: 600
timeout: 4000
padding: 8
contentItem: Txt { text: tip.text; font.pixelSize: Theme.fs12 }
background: Rectangle {
color: Theme.surface2
radius: 6
border.color: Theme.outline
Elevation { level: 2; radius: 6 }
}
enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.fast } }
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
}
+182
View File
@@ -0,0 +1,182 @@
import QtQuick
import QtQuick.Controls.Basic
GlassPanel {
id: bar
property var win: Window.window
signal openSettings()
height: 52
topLeftRadius: win.radius
topRightRadius: win.radius
// Drag to move / double-click to maximize via the compositor (Wayland-native).
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) bar.win.startSystemMove()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onDoubleTapped: bar.win.toggleMaximized()
}
Row {
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 10
Rectangle {
width: 28
height: 28
radius: 8
anchors.verticalCenter: parent.verticalCenter
gradient: Gradient {
GradientStop { position: 0; color: "#5FC8FF" }
GradientStop { position: 1; color: "#2A7BD8" }
}
border.color: Qt.rgba(1, 1, 1, 0.3)
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: 12
topLeftRadius: 7
topRightRadius: 7
color: Qt.rgba(1, 1, 1, 0.25)
}
Icon { anchors.centerIn: parent; name: "equalizer"; size: 18; color: "#FFFFFF" }
}
Txt {
text: "StemRoller"
font.pixelSize: Theme.fs16
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
Rectangle { // engine status
anchors.verticalCenter: parent.verticalCenter
height: 22
width: st.implicitWidth + 26
radius: 11
readonly property color tone: app.workerState === "ready" ? Theme.success
: app.workerState === "busy" ? Theme.primary
: app.workerState === "crashed" ? Theme.error : Theme.warning
color: Theme.alpha(tone, 0.12)
border.color: Theme.alpha(tone, 0.3)
Rectangle {
x: 9
anchors.verticalCenter: parent.verticalCenter
width: 7
height: 7
radius: 4
color: parent.tone
}
Txt {
id: st
x: 20
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: 11
font.weight: Font.Medium
color: parent.tone
text: app.workerState === "ready" ? "Engine ready"
: app.workerState === "busy" ? "Separating"
: app.workerState === "crashed" ? "Engine restarting" : "Starting engine"
}
}
}
Row {
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
spacing: 8
AeroComboBox {
id: modelPicker
width: 240
caption: "MODEL"
iconName: "layers"
implicitHeight: 40
subRole: "note"
popupWidth: 300
model: app.models
currentIndex: app.models.findIndex(m => m.id === appSettings.modelId)
onActivated: appSettings.modelId = currentValue
anchors.verticalCenter: parent.verticalCenter
}
AbstractButton { // 2-stem vocals toggle chip
id: twoStem
readonly property var model: app.models.find(m => m.id === appSettings.modelId)
readonly property bool applicable: !!model && model.stems > 2
anchors.verticalCenter: parent.verticalCenter
checkable: true
checked: appSettings.twoStems && applicable
enabled: applicable
onToggled: appSettings.twoStems = checked
hoverEnabled: true
implicitHeight: 32
implicitWidth: twoLbl.implicitWidth + 40
opacity: enabled ? 1 : 0.4
background: Rectangle {
radius: 16
color: twoStem.checked ? Theme.alpha(Theme.stemColor("vocals"), 0.2) : (twoStem.hovered ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.04))
border.color: twoStem.checked ? Theme.alpha(Theme.stemColor("vocals"), 0.6) : Qt.rgba(1, 1, 1, 0.10)
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
contentItem: Row {
leftPadding: 12
spacing: 6
Icon {
name: twoStem.checked ? "check" : "music"
size: 16
color: twoStem.checked ? Theme.stemColor("vocals") : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
Txt {
id: twoLbl
text: "Vocals only"
font.pixelSize: Theme.fs12
font.weight: Font.Medium
color: twoStem.checked ? Theme.textHi : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
}
Tip { visible: twoStem.hovered; text: twoStem.applicable ? "2-stem mode: vocals + instrumental" : "This model is already 2-stem" }
}
AeroComboBox {
id: devicePicker
width: 250
caption: app.vram.valid === true && appSettings.deviceId !== "cpu"
? "DEVICE · " + (app.vram.free / 1024).toFixed(1) + " GB FREE" : "DEVICE"
iconName: "chip"
implicitHeight: 40
popupWidth: 300
model: app.devices
currentIndex: Math.max(0, app.devices.findIndex(d => d.id === appSettings.deviceId))
onActivated: appSettings.deviceId = currentValue
anchors.verticalCenter: parent.verticalCenter
HoverHandler { id: devHover }
Tip { visible: devHover.hovered && app.vram.valid === true; text: app.vram.text || "" }
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "settings"
size: 36
tip: "Settings (Ctrl+,)"
onClicked: bar.openSettings()
}
Rectangle { width: 1; height: 24; color: Theme.hairline; anchors.verticalCenter: parent.verticalCenter }
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
CaptionButton { iconName: "minimize"; onClicked: bar.win.showMinimized() }
CaptionButton { iconName: bar.win.maximized ? "restore" : "maximize"; onClicked: bar.win.toggleMaximized() }
CaptionButton { iconName: "close"; danger: true; onClicked: bar.win.close() }
}
}
Rectangle { // separator to content (elevation 1 edge)
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
height: 1
color: Theme.hairline
}
}
+65
View File
@@ -0,0 +1,65 @@
import QtQuick
// Material snackbar on surface-2.
Item {
id: t
property string message: ""
property string kind: "info"
property string folder: ""
property real maxWidth: 800
width: Math.min(maxWidth, row.implicitWidth + 32)
height: 48
opacity: 0
visible: opacity > 0
y: shown ? 0 : 12
property bool shown: false
function show(m, k, f) {
message = m; kind = k; folder = f
shown = true
hideTimer.restart()
}
Timer { id: hideTimer; interval: 5000; onTriggered: t.shown = false }
states: State { when: t.shown; PropertyChanges { t.opacity: 1 } }
Behavior on opacity { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Behavior on y { NumberAnimation { duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Rectangle {
anchors.fill: parent
radius: 8
color: Theme.surface2
border.color: t.kind === "error" ? Theme.alpha(Theme.error, 0.5) : t.kind === "success" ? Theme.alpha(Theme.success, 0.45) : Theme.outline
Elevation { level: 3; radius: 8 }
}
Row {
id: row
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 12
Icon {
anchors.verticalCenter: parent.verticalCenter
name: t.kind === "error" ? "error" : t.kind === "success" ? "check" : "music"
size: 18
color: t.kind === "error" ? Theme.error : t.kind === "success" ? Theme.success : Theme.accentGlow
}
Txt {
anchors.verticalCenter: parent.verticalCenter
text: t.message
width: Math.min(implicitWidth, 640)
}
AeroButton {
visible: t.folder.length > 0
anchors.verticalCenter: parent.verticalCenter
variant: "text"
pillHeight: 32
text: "Open folder"
onClicked: { app.openFolder(t.folder); t.shown = false }
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
size: 28; iconSize: 14
iconName: "close"
glow: false
onClicked: t.shown = false
}
}
}
+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) }
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import QtQuick
import QtQuick.Controls.Basic
GlassPanel {
id: tr
property var win: Window.window
readonly property bool ready: audio.loaded
readonly property real dur: audio.duration
bottomLeftRadius: win.radius
bottomRightRadius: win.radius
gloss: true
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top }
height: 1
color: Theme.hairline
}
Row {
id: left
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 10
IconButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "skip_start"
enabled: tr.ready
tip: "Back to start (Home)"
onClicked: { audio.seek(0); tr.win.playPos = 0 }
}
AbstractButton { // glossy round play/pause
id: play
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 44
enabled: tr.ready
hoverEnabled: true
opacity: enabled ? 1 : 0.4
onClicked: audio.toggle()
background: Item {
Glow { radius: 22; opacity: play.hovered && play.enabled ? 0.6 : 0 }
Rectangle {
anchors.fill: parent
radius: 22
gradient: Gradient {
GradientStop { position: 0; color: "#6CC4FF" }
GradientStop { position: 1; color: "#1F7FE0" }
}
border.color: Qt.rgba(1, 1, 1, 0.35)
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
height: 19
topLeftRadius: 20
topRightRadius: 20
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.45) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.08) }
}
}
Rectangle { anchors.fill: parent; radius: 22; color: play.down ? Qt.rgba(0, 0, 0, 0.2) : "transparent" }
}
}
contentItem: Item {
Icon { anchors.centerIn: parent; name: audio.playing ? "pause" : "play"; size: 24; color: "#FFFFFF" }
}
}
Txt {
anchors.verticalCenter: parent.verticalCenter
width: 118
text: Theme.time(tr.win.playPos, true) + " / " + Theme.time(tr.dur, false)
font.features: { "tnum": 1 }
font.pixelSize: Theme.fs14
color: tr.ready ? Theme.textHi : Theme.textLo
}
}
// scrub bar
Item {
id: scrub
anchors { left: left.right; leftMargin: 16; right: volume.left; rightMargin: 24; verticalCenter: parent.verticalCenter }
height: 24
enabled: tr.ready
readonly property real frac: tr.dur > 0 ? Math.min(1, tr.win.playPos / tr.dur) : 0
Rectangle {
id: track
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: scrubArea.containsMouse || scrubArea.pressed ? 6 : 4
radius: 3
color: Qt.rgba(1, 1, 1, 0.10)
Behavior on height { NumberAnimation { duration: Theme.fast } }
Rectangle {
width: parent.width * scrub.frac
height: parent.height
radius: 3
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.primary }
GradientStop { position: 1; color: Theme.accentGlow }
}
}
}
Rectangle {
visible: tr.ready
x: scrub.width * scrub.frac - width / 2
anchors.verticalCenter: parent.verticalCenter
width: scrubArea.containsMouse || scrubArea.pressed ? 14 : 10
height: width
radius: width / 2
color: "#FFFFFF"
Behavior on width { NumberAnimation { duration: Theme.fast } }
Glow { radius: 7; sigma: 4; opacity: scrubArea.containsMouse ? 0.7 : 0.3 }
}
MouseArea {
id: scrubArea
anchors.fill: parent
hoverEnabled: true
enabled: tr.ready
cursorShape: Qt.PointingHandCursor
function seekTo(x) {
var t = Math.max(0, Math.min(1, x / width)) * tr.dur
audio.seek(t)
tr.win.playPos = t
}
onPressed: (e) => seekTo(e.x)
onPositionChanged: (e) => { if (pressed) seekTo(e.x) }
}
}
// master volume: monitoring only, never baked into exports
Row {
id: volume
anchors { right: meter.left; rightMargin: 24; verticalCenter: parent.verticalCenter }
spacing: 6
IconButton {
id: muteBtn
property real restore: 1.0
anchors.verticalCenter: parent.verticalCenter
iconName: audio.volume <= 0 ? "volume_off" : "volume"
tip: audio.volume <= 0 ? "Unmute" : "Mute"
onClicked: {
if (audio.volume > 0) {
restore = audio.volume
audio.volume = 0
} else {
audio.volume = restore > 0 ? restore : 1.0
}
appSettings.masterVolume = audio.volume
}
}
AeroSlider {
anchors.verticalCenter: parent.verticalCenter
width: 112
from: 0
to: 1
value: audio.volume
onMoved: audio.volume = value
onPressedChanged: if (!pressed) appSettings.masterVolume = audio.volume
WheelHandler {
onWheel: (e) => {
audio.volume = Math.max(0, Math.min(1, audio.volume + e.angleDelta.y / 120 * 0.05))
appSettings.masterVolume = audio.volume
}
}
}
Txt {
anchors.verticalCenter: parent.verticalCenter
width: 36
text: Math.round(audio.volume * 100) + "%"
font.features: { "tnum": 1 }
font.pixelSize: Theme.fs12
color: Theme.textLo
}
}
Row {
id: meter
anchors { right: parent.right; rightMargin: 20; verticalCenter: parent.verticalCenter }
spacing: 10
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Txt { text: "L"; font.pixelSize: 9; color: Theme.textLo; height: 9 }
Txt { text: "R"; font.pixelSize: 9; color: Theme.textLo; height: 9 }
}
LevelMeter {
anchors.verticalCenter: parent.verticalCenter
width: 160
levelLeft: tr.win.levelL
levelRight: tr.win.levelR
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import QtQuick
Text {
color: Theme.textHi
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
renderType: Text.NativeRendering
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
+30
View File
@@ -0,0 +1,30 @@
module StemRoller
singleton Theme 1.0 Theme.qml
AeroButton 1.0 AeroButton.qml
AeroComboBox 1.0 AeroComboBox.qml
AeroSlider 1.0 AeroSlider.qml
AeroSwitch 1.0 AeroSwitch.qml
CaptionButton 1.0 CaptionButton.qml
Card 1.0 Card.qml
DashedFrame 1.0 DashedFrame.qml
DropZone 1.0 DropZone.qml
Elevation 1.0 Elevation.qml
FrameOverlay 1.0 FrameOverlay.qml
GlassPanel 1.0 GlassPanel.qml
Glow 1.0 Glow.qml
IconButton 1.0 IconButton.qml
Icon 1.0 Icon.qml
LevelMeter 1.0 LevelMeter.qml
QueueItem 1.0 QueueItem.qml
ResizeEdge 1.0 ResizeEdge.qml
SettingsDialog 1.0 SettingsDialog.qml
Sidebar 1.0 Sidebar.qml
StatusChip 1.0 StatusChip.qml
StemLane 1.0 StemLane.qml
StripedProgress 1.0 StripedProgress.qml
Tip 1.0 Tip.qml
TitleBar 1.0 TitleBar.qml
Toast 1.0 Toast.qml
TrackView 1.0 TrackView.qml
Transport 1.0 Transport.qml
Txt 1.0 Txt.qml