- 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>
137 lines
5.6 KiB
QML
137 lines
5.6 KiB
QML
import QtQuick
|
|
|
|
// The stem faders as an Excel column chart. Drag a column to set its level; double-click
|
|
// for 0 dB. Below each category: M(ute) and S(olo).
|
|
Rectangle {
|
|
id: ch
|
|
color: "#FFFFFF"
|
|
border.color: "#D9D9D9"
|
|
readonly property var stems: player.stems
|
|
readonly property int n: stems.length
|
|
readonly property real unity: 0.8
|
|
|
|
T {
|
|
id: title
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
y: 6
|
|
text: "Stem Mix"
|
|
font.family: cellFont
|
|
font.pixelSize: 17
|
|
color: X.chartText
|
|
}
|
|
|
|
Item {
|
|
id: plot
|
|
x: 34
|
|
y: title.y + title.height + 18
|
|
width: parent.width - x - 8
|
|
height: parent.height - y - 52
|
|
|
|
// value axis: fader position, 0 dB marked
|
|
Repeater {
|
|
model: [0, 0.25, 0.5, 0.8, 1.0]
|
|
delegate: Item {
|
|
required property var modelData
|
|
y: plot.height * (1 - modelData)
|
|
width: plot.width
|
|
Rectangle { width: parent.width; height: 1; color: modelData === 0.8 ? "#A6A6A6" : X.chartLine }
|
|
T {
|
|
x: -32
|
|
width: 28
|
|
y: -7
|
|
height: 14
|
|
horizontalAlignment: Text.AlignRight
|
|
font.family: cellFont
|
|
font.pixelSize: 11
|
|
color: X.chartText
|
|
text: modelData === 0 ? "-∞" : modelData === 0.8 ? "0 dB" : modelData === 1 ? "+4" : (40 * Math.log(modelData / 0.8) / Math.LN10).toFixed(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: ch.stems
|
|
delegate: Item {
|
|
id: cat
|
|
required property int index
|
|
required property var modelData
|
|
readonly property real cw: plot.width / Math.max(1, ch.n)
|
|
x: index * cw
|
|
width: cw
|
|
height: plot.height
|
|
|
|
Rectangle {
|
|
id: bar
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: cat.cw * 0.62
|
|
height: Math.max(1, plot.height * cat.modelData.level)
|
|
y: plot.height - height
|
|
color: cat.modelData.color
|
|
opacity: cat.modelData.audible ? 1 : 0.22
|
|
border.color: dm.containsMouse || dm.pressed ? "#404040" : "transparent"
|
|
Behavior on height { enabled: !dm.pressed; NumberAnimation { duration: X.easeMs; easing.type: Easing.OutCubic } }
|
|
}
|
|
T { // data label
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
y: bar.y - 16
|
|
font.family: cellFont
|
|
font.pixelSize: 11
|
|
color: X.chartText
|
|
text: cat.modelData.muted ? "Muted" : !cat.modelData.audible ? "—" : cat.modelData.level <= 0.001 ? "-∞" : (cat.modelData.db >= 0 ? "+" : "") + cat.modelData.db.toFixed(1)
|
|
}
|
|
MouseArea {
|
|
id: dm
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.SizeVerCursor
|
|
function set(m) { player.setLevel(cat.index, Math.max(0, Math.min(1, 1 - m.y / plot.height))) }
|
|
onPressed: (m) => set(m)
|
|
onPositionChanged: (m) => { if (pressed) set(m) }
|
|
onDoubleClicked: player.setLevel(cat.index, ch.unity)
|
|
onWheel: (w) => player.setLevel(cat.index, Math.max(0, Math.min(1, cat.modelData.level + (w.angleDelta.y > 0 ? 0.02 : -0.02))))
|
|
}
|
|
Tip { target: dm; text: cat.modelData.label + ": drag to set, double-click for 0 dB" }
|
|
|
|
// category axis label and the M / S toggles
|
|
Column {
|
|
y: plot.height + 4
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: 3
|
|
T {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
font.family: cellFont
|
|
font.pixelSize: 12
|
|
color: X.chartText
|
|
text: cat.modelData.label
|
|
width: Math.min(implicitWidth, cat.cw)
|
|
}
|
|
Row {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: 2
|
|
Toggle { label: "M"; on: cat.modelData.muted; onFill: X.bad; onText: X.badText; tip: "Mute " + cat.modelData.label; onClicked: player.toggleMute(cat.index) }
|
|
Toggle { label: "S"; on: cat.modelData.solo; onFill: X.neutral; onText: X.neutralText; tip: "Solo " + cat.modelData.label; onClicked: player.toggleSolo(cat.index) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Rectangle { y: plot.height; width: plot.width; height: 1; color: "#BFBFBF" }
|
|
}
|
|
|
|
component Toggle: Rectangle {
|
|
id: tg
|
|
property string label
|
|
property bool on: false
|
|
property color onFill
|
|
property color onText
|
|
property string tip
|
|
signal clicked()
|
|
width: 18
|
|
height: 18
|
|
color: on ? onFill : tgm.containsMouse ? "#F2F2F2" : "#FFFFFF"
|
|
border.color: on ? onText : "#BFBFBF"
|
|
T { anchors.centerIn: parent; text: tg.label; font.pixelSize: 11; font.bold: tg.on; color: tg.on ? tg.onText : X.inkSoft }
|
|
MouseArea { id: tgm; anchors.fill: parent; hoverEnabled: true; onClicked: tg.clicked() }
|
|
Tip { target: tgm; text: tg.tip }
|
|
}
|
|
}
|