diff --git a/CMakeLists.txt b/CMakeLists.txt index 962604dddf76..3e9d6268deb7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2605,6 +2605,7 @@ set( set(QT_EXTRA_COMPONENTS "") if(QT6) find_package(QT 6.2 NAMES Qt6 COMPONENTS Core REQUIRED) + list(APPEND QT_EXTRA_COMPONENTS "ShaderTools") list(APPEND QT_EXTRA_COMPONENTS "SvgWidgets") list(APPEND QT_EXTRA_COMPONENTS "Core5Compat") list(APPEND QT_EXTRA_COMPONENTS "Quick") @@ -2630,6 +2631,8 @@ if(QT_EXTRA_COMPONENTS) endif() if(QML) + add_subdirectory(res/shaders) + set(QT_QML_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml) qt_add_library(mixxx-qml-lib STATIC) foreach(COMPONENT ${QT_COMPONENTS}) @@ -2689,6 +2692,8 @@ if(QML) src/qml/asyncimageprovider.cpp src/qml/qmlapplication.cpp src/qml/qmlautoreload.cpp + src/qml/qmlbeatsmodel.cpp + src/qml/qmlcuesmodel.cpp src/qml/qmlcontrolproxy.cpp src/qml/qmlconfigproxy.cpp src/qml/qmldlgpreferencesproxy.cpp diff --git a/res/qml/Theme/Theme.qml b/res/qml/Theme/Theme.qml index 23301140a0b5..ca1b4d498e51 100644 --- a/res/qml/Theme/Theme.qml +++ b/res/qml/Theme/Theme.qml @@ -36,6 +36,15 @@ QtObject { property color buttonNormalColor: midGray property color textColor: lightGray2 property color toolbarActiveColor: white + property color waveformPrerollColor: midGray + property color waveformPostrollColor: midGray + property color waveformBeatColor: lightGray + property color waveformCursorColor: white + property color waveformMarkerDefault: '#ff7a01' + property color waveformMarkerLabel: Qt.rgba(255, 255, 255, 0.8) + property color waveformMarkerIntroOutroColor: '#2c5c9a' + property color waveformMarkerLoopColor: '#00b400' + property color waveformMarkerLoopColorDisabled: '#FFFFFF' property string fontFamily: "Open Sans" property int textFontPixelSize: 14 property int buttonFontPixelSize: 10 diff --git a/res/qml/WaveformCue.qml b/res/qml/WaveformCue.qml new file mode 100644 index 000000000000..74ba5dff2469 --- /dev/null +++ b/res/qml/WaveformCue.qml @@ -0,0 +1,71 @@ +import Mixxx 1.0 as Mixxx +import QtQuick 2.15 +import QtQuick.Shapes 1.4 +import QtQuick.Window 2.15 + +import QtQuick.Controls 2.15 + +import "Theme" + +Item { + id: root + + property color color: Theme.waveformMarkerDefault + + property real markerHeight: root.height + property color labelColor: Theme.waveformMarkerLabel + property real radiusSize: 4 + property string cueLabel: qsTr("CUE") + + FontMetrics { + id: fontMetrics + font.family: Theme.fontFamily + } + + property rect contentRect: fontMetrics.tightBoundingRect(cueLabel) + + Shape { + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: color + strokeStyle: ShapePath.SolidLine + startX: -1; startY: 0 + + PathLine { x: 8; y: 0 } + PathLine { x: 8 - radiusSize + contentRect.width; y: 0 } + PathArc { + x: 8 + contentRect.width + y: radiusSize + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Clockwise + } + PathLine { x: 8 + contentRect.width; y: 16 - radiusSize } + PathArc { + x: 8 - radiusSize + contentRect.width + y: 16 + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Clockwise + } + PathLine { x: 8; y: 16 } + PathLine { x: 2; y: 16 } + PathLine { x: 2; y: markerHeight } + PathLine { x: -1; y: markerHeight } + PathLine { x: -1; y: 0 } + } + } + Shape { + ShapePath { + fillColor: labelColor + strokeColor: labelColor + PathText { + x: 3 + y: 3 + font.family: Theme.fontFamily + font.pixelSize: 13 + font.weight: Font.Bold + text: cueLabel + } + } + } +} diff --git a/res/qml/WaveformHotcue.qml b/res/qml/WaveformHotcue.qml new file mode 100644 index 000000000000..0c010e42cb03 --- /dev/null +++ b/res/qml/WaveformHotcue.qml @@ -0,0 +1,100 @@ +import "." as Skin +import Mixxx 1.0 as Mixxx +import QtQuick 2.15 +import QtQuick.Shapes 1.4 +import QtQuick.Window 2.15 + +import QtQuick.Controls 2.15 + +import "Theme" + +Item { + id: root + + required property int hotcueNumber + required property string group + required property string label + property bool isLoop: false + + Skin.Hotcue { + id: hotcue + + group: root.group + hotcueNumber: root.hotcueNumber + } + + property real markerHeight: root.height + property color labelColor: Theme.waveformMarkerLabel + property real radiusSize: 4 + property string hotcueLabel: label != "" ? `${hotcueNumber}: ${label}` : `${hotcueNumber}` + + FontMetrics { + id: fontMetrics + font.family: Theme.fontFamily + } + + property rect contentRect: fontMetrics.tightBoundingRect(hotcueLabel) + + Rectangle { + visible: root.isLoop + anchors.fill: parent + color: Qt.alpha(hotcue.color, 0.3) + } + + Shape { + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: hotcue.color + strokeStyle: ShapePath.SolidLine + startX: -1; startY: 0 + + PathLine { x: 1; y: 0 } + PathLine { x: 1; y: markerHeight - 16 } + PathLine { x: 8 - radiusSize + contentRect.width; y: markerHeight - 16 } + PathArc { + x: 8 + contentRect.width + y: markerHeight - 16 + radiusSize + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Clockwise + } + PathLine { x: 8 + contentRect.width; y: markerHeight - radiusSize } + PathArc { + x: 8 - radiusSize + contentRect.width + y: markerHeight + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Clockwise + } + PathLine { x: -1; y: markerHeight } + PathLine { x: -1; y: 0 } + } + } + Shape { + visible: root.isLoop + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: hotcue.color + strokeStyle: ShapePath.SolidLine + startX: root.width - 1; startY: 0 + + PathLine { x: root.width - 1; y: markerHeight } + PathLine { x: root.width + 1; y: markerHeight } + PathLine { x: root.width + 1; y: 0 } + } + } + Shape { + ShapePath { + fillColor: labelColor + strokeColor: labelColor + PathText { + x: 3 + y: markerHeight - 13 + font.family: Theme.fontFamily + font.pixelSize: 13 + font.weight: Font.Medium + text: hotcueLabel + } + } + } +} diff --git a/res/qml/WaveformIntroOutro.qml b/res/qml/WaveformIntroOutro.qml new file mode 100644 index 000000000000..ba6779cd6925 --- /dev/null +++ b/res/qml/WaveformIntroOutro.qml @@ -0,0 +1,95 @@ +import Mixxx 1.0 as Mixxx +import QtQuick 2.15 +import QtQuick.Shapes 1.4 +import QtQuick.Window 2.15 + +import QtQuick.Controls 2.15 + +import "Theme" + +Item { + id: root + + property color mainColor: Theme.waveformMarkerIntroOutroColor + property bool isIntro: true + + property real markerHeight: root.height + property real radiusSize: 4 + + Rectangle { + anchors.fill: parent + color: Qt.alpha(root.mainColor, 0.1) + } + + Shape { + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: root.mainColor + strokeStyle: ShapePath.SolidLine + startX: -1; startY: 0 + + PathLine { x: 16 - radiusSize; y: 0 } + PathArc { + x: 16 + y: radiusSize + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Clockwise + } + PathLine { x: 16; y: 16 - radiusSize } + PathArc { + x: 16 - radiusSize + y: 16 + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Clockwise + } + PathLine { x: 1; y: 16 } + PathLine { x: 1; y: markerHeight } + PathLine { x: -1; y: markerHeight } + } + } + Shape { + visible: root.width != 0 + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: root.mainColor + strokeStyle: ShapePath.SolidLine + startX: root.width - 1; startY: 0 + + PathLine { x: root.width - 16 + radiusSize; y: 0 } + PathArc { + x: root.width - 16 + y: radiusSize + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Counterclockwise + } + PathLine { x: root.width - 16; y: 16 - radiusSize } + PathArc { + x: root.width - 16 + radiusSize + y: 16 + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Counterclockwise + } + PathLine { x: root.width -1; y: 16 } + PathLine { x: root.width -1; y: markerHeight } + PathLine { x: root.width + 1; y: markerHeight } + PathLine { x: root.width + 1; y: 0 } + } + } + Image { + x: 2 + y: 2 + width: 12 + height: 12 + source: `images/mark_${(root.isIntro ? 'intro' : 'outro')}.svg` + } + Image { + visible: root.width != 0 + x: root.width - 14 + y: 2 + width: 12 + height: 12 + source: `images/mark_${(root.isIntro ? 'intro' : 'outro')}.svg` + } +} diff --git a/res/qml/WaveformLoop.qml b/res/qml/WaveformLoop.qml new file mode 100644 index 000000000000..d05e1bbe26c7 --- /dev/null +++ b/res/qml/WaveformLoop.qml @@ -0,0 +1,76 @@ +import Mixxx 1.0 as Mixxx +import QtQuick 2.15 +import QtQuick.Shapes 1.4 +import QtQuick.Window 2.15 + +import QtQuick.Controls 2.15 + +import "Theme" + +Item { + id: root + + property color mainColor: Theme.waveformMarkerLoopColor + property color disabledColor: Theme.waveformMarkerLoopColorDisabled + property real enabledOpacity: 0.8 + property real disabledOpacity: 0.5 + + property real markerHeight: root.height + property real radiusSize: 4 + property bool enabled: true + + Rectangle { + anchors.fill: parent + color: Qt.alpha(root.enabled ? root.mainColor : root.disabledColor, root.enabled ? root.enabledOpacity : root.disabledOpacity) + } + + Shape { + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: root.mainColor + strokeStyle: ShapePath.SolidLine + startX: -1; startY: 0 + + PathLine { x: -16 + radiusSize; y: 0 } + PathArc { + x: -16 + y: radiusSize + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Counterclockwise + } + PathLine { x: -16; y: 16 - radiusSize } + PathArc { + x: -16 + radiusSize + y: 16 + radiusX: radiusSize; radiusY: radiusSize + direction: PathArc.Counterclockwise + } + PathLine { x: -1; y: 16 } + PathLine { x: -1; y: markerHeight } + PathLine { x: 1; y: markerHeight } + PathLine { x: 1; y: 0 } + } + } + Shape { + + ShapePath { + strokeWidth: 0 + strokeColor: 'transparent' + fillColor: root.mainColor + strokeStyle: ShapePath.SolidLine + startX: root.width - 1; startY: 0 + + PathLine { x: root.width - 1; y: markerHeight } + PathLine { x: root.width + 1; y: markerHeight } + PathLine { x: root.width + 1; y: 0 } + } + } + Image { + x: -14 + y: 2 + width: 12 + height: 12 + source: "images/mark_loop.svg" + } +} diff --git a/res/qml/WaveformRow.qml b/res/qml/WaveformRow.qml new file mode 100644 index 000000000000..be129fc42bfa --- /dev/null +++ b/res/qml/WaveformRow.qml @@ -0,0 +1,380 @@ +import "." as Skin +import Mixxx 1.0 as Mixxx +import QtQuick 2.14 +import QtQuick.Shapes 1.12 +import "Theme" + +Item { + id: root + + enum MouseStatus { + Normal, + Bending, + Scratching + } + + property string group // required + property var deckPlayer: Mixxx.PlayerManager.getPlayer(group) + + Item { + id: waveformContainer + + property real duration: samplesControl.value / sampleRateControl.value + + anchors.fill: parent + clip: true + + Mixxx.ControlProxy { + id: samplesControl + + group: root.group + key: "track_samples" + } + + Mixxx.ControlProxy { + id: sampleRateControl + + group: root.group + key: "track_samplerate" + } + + Mixxx.ControlProxy { + id: playPositionControl + + group: root.group + key: "playposition" + } + + Mixxx.ControlProxy { + id: rateRatioControl + + group: root.group + key: "rate_ratio" + } + + Mixxx.ControlProxy { + id: zoomControl + + group: root.group + key: "waveform_zoom" + } + + Mixxx.ControlProxy { + id: introStartPosition + + group: root.group + key: "intro_start_position" + } + + Mixxx.ControlProxy { + id: introEndPosition + + group: root.group + key: "intro_end_position" + } + + Mixxx.ControlProxy { + id: outroStartPosition + + group: root.group + key: "outro_start_position" + } + + Mixxx.ControlProxy { + id: outroEndPosition + + group: root.group + key: "outro_end_position" + } + + Mixxx.ControlProxy { + id: loopStartPosition + + group: root.group + key: "loop_start_position" + } + + Mixxx.ControlProxy { + id: loopEndPosition + + group: root.group + key: "loop_end_position" + } + + Mixxx.ControlProxy { + id: loopEnabled + + group: root.group + key: "loop_enabled" + } + + Mixxx.ControlProxy { + id: mainCuePosition + + group: root.group + key: "cue_point" + } + + Item { + id: waveform + + property real effectiveZoomFactor: (1 / rateRatioControl.value) * (100 / zoomControl.value) + + width: waveformContainer.duration * effectiveZoomFactor + height: parent.height + x: playMarker.screenPosition * waveformContainer.width - playPositionControl.value * width + visible: root.deckPlayer.isLoaded + + WaveformShader { + group: root.group + anchors.fill: parent + } + + Shape { + id: preroll + + property real triangleHeight: waveform.height + property real triangleWidth: 0.25 * waveform.effectiveZoomFactor + property int numTriangles: Math.ceil(width / triangleWidth) + + anchors.top: waveform.top + anchors.right: waveform.left + width: Math.max(0, waveform.x) + height: waveform.height + + ShapePath { + strokeColor: Theme.waveformPrerollColor + strokeWidth: 1 + fillColor: "transparent" + + PathMultiline { + paths: { + let p = []; + for (let i = 0; i < preroll.numTriangles; i++) { + p.push([ + Qt.point(preroll.width - i * preroll.triangleWidth, preroll.triangleHeight / 2), + Qt.point(preroll.width - (i + 1) * preroll.triangleWidth, 0), + Qt.point(preroll.width - (i + 1) * preroll.triangleWidth, preroll.triangleHeight), + Qt.point(preroll.width - i * preroll.triangleWidth, preroll.triangleHeight / 2), + ]); + } + return p; + } + } + } + } + + Shape { + id: postroll + + property real triangleHeight: waveform.height + property real triangleWidth: 0.25 * waveform.effectiveZoomFactor + property int numTriangles: Math.ceil(width / triangleWidth) + + anchors.top: waveform.top + anchors.left: waveform.right + width: waveformContainer.width / 2 + height: waveform.height + + ShapePath { + strokeColor: Theme.waveformPostrollColor + strokeWidth: 1 + fillColor: "transparent" + + PathMultiline { + paths: { + let p = []; + for (let i = 0; i < postroll.numTriangles; i++) { + p.push([ + Qt.point(i * postroll.triangleWidth, postroll.triangleHeight / 2), + Qt.point((i + 1) * postroll.triangleWidth, 0), + Qt.point((i + 1) * postroll.triangleWidth, postroll.triangleHeight), + Qt.point(i * postroll.triangleWidth, postroll.triangleHeight / 2), + ]); + } + return p; + } + } + } + } + + Repeater { + model: root.deckPlayer.beatsModel + + Rectangle { + property real alpha: 0.9 // TODO: Make this configurable (i.e., "[Waveform],beatGridAlpha" config option) + + width: 1 + height: waveform.height + x: (framePosition * 2 / samplesControl.value) * waveform.width + color: Theme.waveformBeatColor + } + } + + Skin.WaveformIntroOutro { + id: intro + + visible: introStartPosition.value != -1 || introEndPosition.value != -1 + + height: waveform.height + x: ((introStartPosition.value != -1 ? introStartPosition.value : introEndPosition.value) / samplesControl.value) * waveform.width + width: introEndPosition.value == -1 ? 0 : ((introEndPosition.value - introStartPosition.value) / samplesControl.value) * waveform.width + } + + Skin.WaveformIntroOutro { + id: outro + + visible: outroStartPosition.value != -1 || outroEndPosition.value != -1 + isIntro: false + + height: waveform.height + x: ((outroStartPosition.value != -1 ? outroStartPosition.value : outroEndPosition.value) / samplesControl.value) * waveform.width + width: outroEndPosition.value == -1 || outroStartPosition.value == -1 ? 0 : ((outroEndPosition.value - outroStartPosition.value) / samplesControl.value) * waveform.width + } + + Skin.WaveformLoop { + id: loop + + visible: loopStartPosition.value != -1 && loopEndPosition.value != -1 + + height: waveform.height + x: (loopStartPosition.value / samplesControl.value) * waveform.width + width: ((loopEndPosition.value - loopStartPosition.value) / samplesControl.value) * waveform.width + enabled: loopEnabled.value + } + + Repeater { + model: root.deckPlayer.hotcuesModel + + Item { + id: cue + + required property int startPosition + required property int endPosition + required property string label + required property bool isLoop + required property int hotcueNumber + + Skin.WaveformHotcue { + group: root.group + hotcueNumber: cue.hotcueNumber + 1 + label: cue.label + isLoop: cue.isLoop + + x: (startPosition * 2 / samplesControl.value) * waveform.width + width: cue.isLoop ? ((endPosition - startPosition) * 2 / samplesControl.value) * waveform.width : null + height: waveform.height + } + } + } + + Skin.WaveformCue { + id: maincue + + height: waveform.height + x: (mainCuePosition.value / samplesControl.value) * waveform.width + } + } + } + + Shape { + id: playMarkerShape + + anchors.fill: parent + + ShapePath { + id: playMarker + + property real screenPosition: 0.5 + + startX: playMarkerShape.width * playMarker.screenPosition + startY: 0 + strokeColor: Theme.waveformCursorColor + strokeWidth: 1 + + PathLine { + id: marker + + x: playMarkerShape.width * playMarker.screenPosition + y: playMarkerShape.height + } + } + } + + Mixxx.ControlProxy { + id: scratchPositionEnableControl + + group: root.group + key: "scratch_position_enable" + } + + Mixxx.ControlProxy { + id: scratchPositionControl + + group: root.group + key: "scratch_position" + } + + Mixxx.ControlProxy { + id: wheelControl + + group: root.group + key: "wheel" + } + + MouseArea { + property int mouseStatus: WaveformRow.MouseStatus.Normal + property point mouseAnchor: Qt.point(0, 0) + + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onPressed: { + mouseAnchor = Qt.point(mouse.x, mouse.y); + if (mouse.button == Qt.LeftButton) { + if (mouseStatus == WaveformRow.MouseStatus.Bending) + wheelControl.parameter = 0.5; + + mouseStatus = WaveformRow.MouseStatus.Scratching; + scratchPositionEnableControl.value = 1; + // TODO: Calculate position properly + scratchPositionControl.value = -mouse.x * waveform.effectiveZoomFactor * 2; + console.log(mouse.x); + } else { + if (mouseStatus == WaveformRow.MouseStatus.Scratching) + scratchPositionEnableControl.value = 0; + + wheelControl.parameter = 0.5; + mouseStatus = WaveformRow.MouseStatus.Bending; + } + } + onPositionChanged: { + switch (mouseStatus) { + case WaveformRow.MouseStatus.Bending: { + const diff = mouse.x - mouseAnchor.x; + // Start at the middle of [0.0, 1.0], and emit values based on how far + // the mouse has traveled horizontally. Note, for legacy (MIDI) reasons, + // this is tuned to 127. + const v = 0.5 + (diff / 1270); + // clamp to [0.0, 1.0] + wheelControl.parameter = Mixxx.MathUtils.clamp(v, 0, 1); + break; + }; + case WaveformRow.MouseStatus.Scratching: + // TODO: Calculate position properly + scratchPositionControl.value = -mouse.x * waveform.effectiveZoomFactor * 2; + break; + } + } + onReleased: { + switch (mouseStatus) { + case WaveformRow.MouseStatus.Bending: + wheelControl.parameter = 0.5; + break; + case WaveformRow.MouseStatus.Scratching: + scratchPositionEnableControl.value = 0; + break; + } + mouseStatus = WaveformRow.MouseStatus.Normal; + } + } +} diff --git a/res/qml/WaveformShader.qml b/res/qml/WaveformShader.qml new file mode 100644 index 000000000000..33de4acde0cf --- /dev/null +++ b/res/qml/WaveformShader.qml @@ -0,0 +1,88 @@ +import Mixxx 1.0 as Mixxx +import QtQuick 2.12 + +ShaderEffect { + id: root + + property string group // required + property var deckPlayer: Mixxx.PlayerManager.getPlayer(group) + property size framebufferSize: Qt.size(width, height) + property int waveformLength: root.deckPlayer.waveformLength + property int textureSize: root.deckPlayer.waveformTextureSize + property int textureStride: root.deckPlayer.waveformTextureStride + property real firstVisualIndex: 1 + property real lastVisualIndex: root.deckPlayer.waveformLength / 2 + property color axesColor: "#FFFFFF" + property color highColor: "#0000FF" + property color midColor: "#00FF00" + property color lowColor: "#FF0000" + property real highGain: filterWaveformEnableControl.value ? (filterHighKillControl.value ? 0 : filterHighControl.value) : 1 + property real midGain: filterWaveformEnableControl.value ? (filterMidKillControl.value ? 0 : filterMidControl.value) : 1 + property real lowGain: filterWaveformEnableControl.value ? (filterLowKillControl.value ? 0 : filterLowControl.value) : 1 + property real allGain: pregainControl.value + property Image waveformTexture + + fragmentShader: "qrc:/shaders/rgbsignal_qml.frag.qsb" + + Mixxx.ControlProxy { + id: pregainControl + + group: root.group + key: "pregain" + } + + Mixxx.ControlProxy { + id: filterWaveformEnableControl + + group: root.group + key: "filterWaveformEnable" + } + + Mixxx.ControlProxy { + id: filterHighControl + + group: "[EqualizerRack1_" + root.group + "_Effect1]" + key: "parameter3" + } + + Mixxx.ControlProxy { + id: filterHighKillControl + + group: "[EqualizerRack1_" + root.group + "_Effect1]" + key: "button_parameter3" + } + + Mixxx.ControlProxy { + id: filterMidControl + + group: "[EqualizerRack1_" + root.group + "_Effect1]" + key: "parameter2" + } + + Mixxx.ControlProxy { + id: filterMidKillControl + + group: "[EqualizerRack1_" + root.group + "_Effect1]" + key: "button_parameter2" + } + + Mixxx.ControlProxy { + id: filterLowControl + + group: "[EqualizerRack1_" + root.group + "_Effect1]" + key: "parameter1" + } + + Mixxx.ControlProxy { + id: filterLowKillControl + + group: "[EqualizerRack1_" + root.group + "_Effect1]" + key: "button_parameter1" + } + + waveformTexture: Image { + visible: false + layer.enabled: false + source: root.deckPlayer.waveformTexture + } +} diff --git a/res/qml/images/mark_intro.svg b/res/qml/images/mark_intro.svg new file mode 100644 index 000000000000..96dd7bcd7025 --- /dev/null +++ b/res/qml/images/mark_intro.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/res/qml/images/mark_loop.svg b/res/qml/images/mark_loop.svg new file mode 100644 index 000000000000..3bcb6feff1c4 --- /dev/null +++ b/res/qml/images/mark_loop.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/res/qml/images/mark_outro.svg b/res/qml/images/mark_outro.svg new file mode 100644 index 000000000000..ad9c98f174ba --- /dev/null +++ b/res/qml/images/mark_outro.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/res/qml/main.qml b/res/qml/main.qml index 73804bfd66bd..15578d303cb2 100644 --- a/res/qml/main.qml +++ b/res/qml/main.qml @@ -98,6 +98,58 @@ ApplicationWindow { } } + WaveformRow { + id: deck3waveform + + group: "[Channel3]" + width: root.width + height: 60 + visible: root.show4decks && !root.maximizeLibrary + + FadeBehavior on visible { + fadeTarget: deck3waveform + } + } + + WaveformRow { + id: deck1waveform + + group: "[Channel1]" + width: root.width + height: 60 + visible: !root.maximizeLibrary + + FadeBehavior on visible { + fadeTarget: deck1waveform + } + } + + WaveformRow { + id: deck2waveform + + group: "[Channel2]" + width: root.width + height: 60 + visible: !root.maximizeLibrary + + FadeBehavior on visible { + fadeTarget: deck2waveform + } + } + + WaveformRow { + id: deck4waveform + + group: "[Channel4]" + width: root.width + height: 60 + visible: root.show4decks && !root.maximizeLibrary + + FadeBehavior on visible { + fadeTarget: deck4waveform + } + } + Skin.DeckRow { id: decks12 diff --git a/res/shaders/CMakeLists.txt b/res/shaders/CMakeLists.txt new file mode 100644 index 000000000000..c54470accf9a --- /dev/null +++ b/res/shaders/CMakeLists.txt @@ -0,0 +1,5 @@ +qt_add_shaders(mixxx-lib "waveform_shaders" + PREFIX "/shaders" + FILES + "rgbsignal_qml.frag" +) diff --git a/res/shaders/rgbsignal_qml.frag b/res/shaders/rgbsignal_qml.frag new file mode 100644 index 000000000000..602f858ba71a --- /dev/null +++ b/res/shaders/rgbsignal_qml.frag @@ -0,0 +1,144 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + + vec2 framebufferSize; + vec4 axesColor; + vec4 lowColor; + vec4 midColor; + vec4 highColor; + + int waveformLength; + int textureSize; + int textureStride; + + float allGain; + float lowGain; + float midGain; + float highGain; + float firstVisualIndex; + float lastVisualIndex; +}; + +layout(binding = 1) uniform sampler2D waveformTexture; + +vec4 getWaveformData(float index) { + vec2 uv_data; + uv_data.y = floor(index / float(textureStride)); + uv_data.x = floor(index - uv_data.y * float(textureStride)); + // Divide again to convert to normalized UV coordinates. + return texture(waveformTexture, uv_data / float(textureStride)); +} + +void main(void) { + vec2 uv = qt_TexCoord0.st; + vec4 pixel = gl_FragCoord; + fragColor = vec4(1, 0, 0, 1); + + float new_currentIndex = + floor(firstVisualIndex + + uv.x * (lastVisualIndex - firstVisualIndex)) * + 2; + + // Texture coordinates put (0,0) at the bottom left, so show the right + // channel if we are in the bottom half. + if (uv.y < 0.5) { + new_currentIndex += 1; + } + + vec4 outputColor = vec4(0.0, 0.0, 0.0, 0.0); + bool showing = false; + bool showingUnscaled = false; + vec4 showingColor = vec4(0.0, 0.0, 0.0, 0.0); + vec4 showingUnscaledColor = vec4(0.0, 0.0, 0.0, 0.0); + + // We don't exit early if the waveform data is not valid because we may want + // to show other things (e.g. the axes lines) even when we are on a pixel + // that does not have valid waveform data. + if (new_currentIndex >= 0 && new_currentIndex <= waveformLength - 1) { + vec4 new_currentDataUnscaled = getWaveformData(new_currentIndex) * allGain; + + vec4 new_currentData = new_currentDataUnscaled; + new_currentData.x *= lowGain; + new_currentData.y *= midGain; + new_currentData.z *= highGain; + + //(vrince) debug see pre-computed signal + // gl_FragColor = new_currentData; + // return; + + // Represents the [-1, 1] distance of this pixel. Subtracting this from + // the signal data in new_currentData, we can tell if a signal band should + // show in this pixel if the component is > 0. + float ourDistance = abs((uv.y - 0.5) * 2.0); + + // Since the magnitude of the (low, mid, high) vector is used as the + // waveform height, re-scale the maximum height to 1. + const float scaleFactor = 1.0 / sqrt(3.0); + + float signalDistance = sqrt(new_currentData.x * new_currentData.x + + new_currentData.y * new_currentData.y + + new_currentData.z * new_currentData.z) * + scaleFactor; + showing = (signalDistance - ourDistance) >= 0.0; + + // Linearly combine the low, mid, and high colors according to the low, + // mid, and high components. + showingColor = lowColor * new_currentData.x + + midColor * new_currentData.y + + highColor * new_currentData.z; + + // Re-scale the color by the maximum component. + float showingMax = max(showingColor.x, max(showingColor.y, showingColor.z)); + showingColor = showingColor / showingMax; + showingColor.w = 1.0; + + // Now do it all over again for the unscaled version of the waveform, + // which we will draw at very low opacity. + float signalDistanceUnscaled = + sqrt(new_currentDataUnscaled.x * new_currentDataUnscaled.x + + new_currentDataUnscaled.y * new_currentDataUnscaled.y + + new_currentDataUnscaled.z * new_currentDataUnscaled.z) * + scaleFactor; + showingUnscaled = (signalDistanceUnscaled - ourDistance) >= 0.0; + + // Linearly combine the low, mid, and high colors according to the + // original low, mid, and high components. + showingUnscaledColor = lowColor * new_currentDataUnscaled.x + + midColor * new_currentDataUnscaled.y + + highColor * new_currentDataUnscaled.z; + + // Re-scale the color by the maximum component. + float showingUnscaledMax = max(showingUnscaledColor.x, + max(showingUnscaledColor.y, showingUnscaledColor.z)); + showingUnscaledColor = showingUnscaledColor / showingUnscaledMax; + showingUnscaledColor.w = 1.0; + } + + // Draw the axes color as the lowest item on the screen. + // TODO(owilliams): The 4 in this line makes sure the axis gets + // rendered even when the waveform is fairly short. Really this + // value should be based on the size of the widget. + if (abs(framebufferSize.y / 2 - pixel.y) <= 4) { + outputColor.xyz = mix(outputColor.xyz, axesColor.xyz, axesColor.w); + outputColor.w = 1.0; + } + + if (showingUnscaled) { + float alpha = 0.4; + outputColor.xyz = mix(outputColor.xyz, showingUnscaledColor.xyz, alpha); + outputColor.w = 1.0; + } + + if (showing) { + float alpha = 0.8; + outputColor.xyz = mix(outputColor.xyz, showingColor.xyz, alpha); + outputColor.w = 1.0; + } + fragColor = outputColor; +} diff --git a/src/qml/qmlbeatsmodel.cpp b/src/qml/qmlbeatsmodel.cpp new file mode 100644 index 000000000000..09b84f907123 --- /dev/null +++ b/src/qml/qmlbeatsmodel.cpp @@ -0,0 +1,73 @@ +#include "qml/qmlbeatsmodel.h" + +#include + +namespace mixxx { +namespace qml { +namespace { +const QHash kRoleNames = { + {QmlBeatsModel::FramePositionRole, "framePosition"}, +}; +} + +QmlBeatsModel::QmlBeatsModel( + QObject* parent) + : QAbstractListModel(parent), m_pBeats(nullptr), m_numBeats(0) { +} + +void QmlBeatsModel::setBeats(const BeatsPointer pBeats, audio::FramePos trackEndPosition) { + beginResetModel(); + m_numBeats = 0; + m_pBeats = pBeats; + if (pBeats != nullptr) { + m_numBeats = pBeats->numBeatsInRange(audio::kStartFramePos, trackEndPosition); + } + endResetModel(); +} + +QVariant QmlBeatsModel::data(const QModelIndex& index, int role) const { + if (index.row() < 0 || index.row() >= m_numBeats) { + return QVariant(); + } + + const BeatsPointer pBeats = m_pBeats; + if (pBeats == nullptr) { + return QVariant(); + } + + auto it = pBeats->iteratorFrom(audio::kStartFramePos) + index.row(); + VERIFY_OR_DEBUG_ASSERT(it != pBeats->cend()) { + return QVariant(); + } + + switch (role) { + case QmlBeatsModel::FramePositionRole: + return (*it).value(); + default: + return QVariant(); + } +} + +int QmlBeatsModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + return 0; + } + + return m_numBeats; +} + +QHash QmlBeatsModel::roleNames() const { + return kRoleNames; +} + +QVariant QmlBeatsModel::get(int row) const { + QModelIndex idx = index(row, 0); + QVariantMap dataMap; + for (auto it = kRoleNames.constBegin(); it != kRoleNames.constEnd(); it++) { + dataMap.insert(it.value(), data(idx, it.key())); + } + return dataMap; +} + +} // namespace qml +} // namespace mixxx diff --git a/src/qml/qmlbeatsmodel.h b/src/qml/qmlbeatsmodel.h new file mode 100644 index 000000000000..0ce8590db55e --- /dev/null +++ b/src/qml/qmlbeatsmodel.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include + +#include "track/beats.h" + +namespace mixxx { +namespace qml { + +class QmlBeatsModel : public QAbstractListModel { + Q_OBJECT + public: + enum Roles { + FramePositionRole = Qt::UserRole + 1, + }; + Q_ENUM(Roles) + + explicit QmlBeatsModel(QObject* parent = nullptr); + + void setBeats(const BeatsPointer pBeats, audio::FramePos trackEndPosition); + + QVariant data(const QModelIndex& index, int role) const override; + int rowCount(const QModelIndex& parent) const override; + QHash roleNames() const override; + Q_INVOKABLE QVariant get(int row) const; + + private: + BeatsPointer m_pBeats; + int m_numBeats; +}; + +} // namespace qml +} // namespace mixxx diff --git a/src/qml/qmlcuesmodel.cpp b/src/qml/qmlcuesmodel.cpp new file mode 100644 index 000000000000..353a176132eb --- /dev/null +++ b/src/qml/qmlcuesmodel.cpp @@ -0,0 +1,83 @@ +#include "qml/qmlcuesmodel.h" + +#include + +#include "moc_qmlcuesmodel.cpp" +#include "track/cue.h" + +namespace mixxx { +namespace qml { +namespace { +const QHash kRoleNames = { + {QmlCuesModel::StartPositionRole, "startPosition"}, + {QmlCuesModel::EndPositionRole, "endPosition"}, + {QmlCuesModel::LabelRole, "label"}, + {QmlCuesModel::IsLoopRole, "isLoop"}, + {QmlCuesModel::HotcueNumberRole, "hotcueNumber"}, +}; +} + +QmlCuesModel::QmlCuesModel( + QObject* parent) + : QAbstractListModel(parent), m_pCues() { +} + +void QmlCuesModel::setCues(const QList pCues) { + beginResetModel(); + m_pCues = QList(pCues); + endResetModel(); +} + +QVariant QmlCuesModel::data(const QModelIndex& index, int role) const { + if (index.row() < 0 || index.row() >= m_pCues.size()) { + return QVariant(); + } + + const CuePointer& pCue = m_pCues.at(index.row()); + VERIFY_OR_DEBUG_ASSERT(pCue.get()) { + return QVariant(); + } + + switch (role) { + case QmlCuesModel::StartPositionRole: { + const auto position = pCue->getPosition(); + return position.isValid() ? position.value() : QVariant(); + } + case QmlCuesModel::EndPositionRole: { + const auto position = pCue->getEndPosition(); + return position.isValid() ? position.value() : QVariant(); + } + case QmlCuesModel::LabelRole: + return pCue->getLabel(); + case QmlCuesModel::IsLoopRole: + return pCue->getType() == CueType::Loop; + case QmlCuesModel::HotcueNumberRole: + return pCue->getHotCue(); + default: + return QVariant(); + } +} + +int QmlCuesModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + return 0; + } + + return m_pCues.size(); +} + +QHash QmlCuesModel::roleNames() const { + return kRoleNames; +} + +QVariant QmlCuesModel::get(int row) const { + QModelIndex idx = index(row, 0); + QVariantMap dataMap; + for (auto it = kRoleNames.constBegin(); it != kRoleNames.constEnd(); it++) { + dataMap.insert(it.value(), data(idx, it.key())); + } + return dataMap; +} + +} // namespace qml +} // namespace mixxx diff --git a/src/qml/qmlcuesmodel.h b/src/qml/qmlcuesmodel.h new file mode 100644 index 000000000000..25abdfe6b2bc --- /dev/null +++ b/src/qml/qmlcuesmodel.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include + +class CuePointer; + +namespace mixxx { +namespace qml { + +class QmlCuesModel : public QAbstractListModel { + Q_OBJECT + public: + enum Roles { + StartPositionRole = Qt::UserRole + 1, + EndPositionRole, + LabelRole, + IsLoopRole, + HotcueNumberRole, + }; + Q_ENUM(Roles) + + explicit QmlCuesModel(QObject* parent = nullptr); + + void setCues(const QList pCues); + + QVariant data(const QModelIndex& index, int role) const override; + int rowCount(const QModelIndex& parent) const override; + QHash roleNames() const override; + Q_INVOKABLE QVariant get(int row) const; + + private: + QList m_pCues; +}; + +} // namespace qml +} // namespace mixxx diff --git a/src/qml/qmlplayerproxy.cpp b/src/qml/qmlplayerproxy.cpp index e35a9fc99465..12b1b6f7ff0e 100644 --- a/src/qml/qmlplayerproxy.cpp +++ b/src/qml/qmlplayerproxy.cpp @@ -1,5 +1,7 @@ #include "qml/qmlplayerproxy.h" +#include + #include "mixer/basetrackplayer.h" #include "moc_qmlplayerproxy.cpp" #include "qml/asyncimageprovider.h" @@ -26,7 +28,10 @@ namespace mixxx { namespace qml { QmlPlayerProxy::QmlPlayerProxy(BaseTrackPlayer* pTrackPlayer, QObject* parent) - : QObject(parent), m_pTrackPlayer(pTrackPlayer) { + : QObject(parent), + m_pTrackPlayer(pTrackPlayer), + m_pBeatsModel(new QmlBeatsModel(this)), + m_pHotcuesModel(new QmlCuesModel(this)) { connect(m_pTrackPlayer, &BaseTrackPlayer::loadingTrack, this, @@ -112,6 +117,20 @@ void QmlPlayerProxy::slotTrackLoaded(TrackPointer pTrack) { &Track::colorUpdated, this, &QmlPlayerProxy::colorChanged); + connect(pTrack.get(), + &Track::waveformUpdated, + this, + &QmlPlayerProxy::slotWaveformChanged); + connect(pTrack.get(), + &Track::beatsUpdated, + this, + &QmlPlayerProxy::slotBeatsChanged); + connect(pTrack.get(), + &Track::cuesUpdated, + this, + &QmlPlayerProxy::slotHotcuesChanged); + slotBeatsChanged(); + slotHotcuesChanged(); } emit trackChanged(); emit trackLoaded(); @@ -135,6 +154,7 @@ void QmlPlayerProxy::slotLoadingTrack(TrackPointer pNewTrack, TrackPointer pOldT } m_pCurrentTrack.reset(); m_pCurrentTrack = pNewTrack; + m_waveformTexture = QImage(); emit trackChanged(); emit trackLoading(); } @@ -155,6 +175,118 @@ void QmlPlayerProxy::slotTrackChanged() { emit colorChanged(); emit coverArtUrlChanged(); emit trackLocationUrlChanged(); + + emit waveformLengthChanged(); + emit waveformTextureChanged(); + emit waveformTextureSizeChanged(); + emit waveformTextureStrideChanged(); +} + +void QmlPlayerProxy::slotWaveformChanged() { + emit waveformLengthChanged(); + emit waveformTextureSizeChanged(); + emit waveformTextureStrideChanged(); + + const TrackPointer pTrack = m_pCurrentTrack; + if (pTrack) { + const ConstWaveformPointer pWaveform = pTrack->getWaveform(); + const int textureWidth = pWaveform->getTextureStride(); + const int textureHeight = pWaveform->getTextureSize() / pWaveform->getTextureStride(); + if (pWaveform) { + const uchar* data = reinterpret_cast(pWaveform->data()); + m_waveformTexture = QImage(data, textureWidth, textureHeight, QImage::Format_RGBA8888); + emit waveformTextureChanged(); + } + } +} + +void QmlPlayerProxy::slotBeatsChanged() { + VERIFY_OR_DEBUG_ASSERT(m_pBeatsModel != nullptr) { + return; + } + + const TrackPointer pTrack = m_pCurrentTrack; + if (pTrack) { + const auto trackEndPosition = mixxx::audio::FramePos{ + pTrack->getDuration() * pTrack->getSampleRate()}; + const auto pBeats = pTrack->getBeats(); + m_pBeatsModel->setBeats(pBeats, trackEndPosition); + } else { + m_pBeatsModel->setBeats(nullptr, audio::kStartFramePos); + } +} + +void QmlPlayerProxy::slotHotcuesChanged() { + VERIFY_OR_DEBUG_ASSERT(m_pHotcuesModel != nullptr) { + return; + } + + QList hotcues; + + const TrackPointer pTrack = m_pCurrentTrack; + if (pTrack) { + for (const auto& cuePoint : pTrack->getCuePoints()) { + if (cuePoint->getHotCue() == Cue::kNoHotCue) + continue; + hotcues.append(cuePoint); + } + } + m_pHotcuesModel->setCues(hotcues); + emit cuesChanged(); +} + +int QmlPlayerProxy::getWaveformLength() const { + const TrackPointer pTrack = m_pCurrentTrack; + if (pTrack) { + const ConstWaveformPointer pWaveform = pTrack->getWaveform(); + if (pWaveform) { + return pWaveform->getDataSize(); + } + } + return 0; +} + +QString QmlPlayerProxy::getWaveformTexture() const { + if (m_waveformTexture.isNull()) { + return QString(); + } + QByteArray byteArray; + QBuffer buffer(&byteArray); + buffer.open(QIODevice::WriteOnly); + m_waveformTexture.save(&buffer, "png"); + + QString imageData = QString::fromLatin1(byteArray.toBase64().data()); + if (imageData.isEmpty()) { + return QString(); + } + + return QStringLiteral("data:image/png;base64,") + imageData; +} + +int QmlPlayerProxy::getWaveformTextureSize() const { + const TrackPointer pTrack = m_pCurrentTrack; + if (pTrack) { + const ConstWaveformPointer pWaveform = pTrack->getWaveform(); + if (pWaveform) { + return pWaveform->getTextureSize(); + } + } + return 0; +} + +int QmlPlayerProxy::getWaveformTextureStride() const { + const TrackPointer pTrack = m_pCurrentTrack; + if (pTrack) { + const ConstWaveformPointer pWaveform = pTrack->getWaveform(); + if (pWaveform) { + return pWaveform->getTextureStride(); + } + } + return 0; +} + +bool QmlPlayerProxy::isLoaded() const { + return m_pCurrentTrack != nullptr; } PROPERTY_IMPL(QString, artist, getArtist, setArtist) diff --git a/src/qml/qmlplayerproxy.h b/src/qml/qmlplayerproxy.h index 3ca284ab6ec5..7bcc20fa5583 100644 --- a/src/qml/qmlplayerproxy.h +++ b/src/qml/qmlplayerproxy.h @@ -7,6 +7,9 @@ #include #include "mixer/basetrackplayer.h" +#include "qml/qmlbeatsmodel.h" +#include "qml/qmlcuesmodel.h" +#include "track/cueinfo.h" #include "track/track.h" namespace mixxx { @@ -14,6 +17,7 @@ namespace qml { class QmlPlayerProxy : public QObject { Q_OBJECT + Q_PROPERTY(bool isLoaded READ isLoaded NOTIFY trackChanged) Q_PROPERTY(QString artist READ getArtist WRITE setArtist NOTIFY artistChanged) Q_PROPERTY(QString title READ getTitle WRITE setTitle NOTIFY titleChanged) Q_PROPERTY(QString album READ getAlbum WRITE setAlbum NOTIFY albumChanged) @@ -34,9 +38,20 @@ class QmlPlayerProxy : public QObject { QML_NAMED_ELEMENT(Player) QML_UNCREATABLE("Only accessible via Mixxx.PlayerManager.getPlayer(group)") + Q_PROPERTY(int waveformLength READ getWaveformLength NOTIFY waveformLengthChanged) + Q_PROPERTY(QString waveformTexture READ getWaveformTexture NOTIFY waveformTextureChanged) + Q_PROPERTY(int waveformTextureSize READ getWaveformTextureSize NOTIFY + waveformTextureSizeChanged) + Q_PROPERTY(int waveformTextureStride READ getWaveformTextureStride NOTIFY + waveformTextureStrideChanged) + + Q_PROPERTY(mixxx::qml::QmlBeatsModel* beatsModel MEMBER m_pBeatsModel CONSTANT); + Q_PROPERTY(mixxx::qml::QmlCuesModel* hotcuesModel MEMBER m_pHotcuesModel CONSTANT); + public: explicit QmlPlayerProxy(BaseTrackPlayer* pTrackPlayer, QObject* parent = nullptr); + bool isLoaded() const; QString getTrack() const; QString getTitle() const; QString getArtist() const; @@ -54,6 +69,11 @@ class QmlPlayerProxy : public QObject { QUrl getCoverArtUrl() const; QUrl getTrackLocationUrl() const; + int getWaveformLength() const; + QString getWaveformTexture() const; + int getWaveformTextureSize() const; + int getWaveformTextureStride() const; + /// Needed for interacting with the raw track player object. BaseTrackPlayer* internalTrackPlayer() const { return m_pTrackPlayer; @@ -66,6 +86,9 @@ class QmlPlayerProxy : public QObject { void slotTrackLoaded(TrackPointer pTrack); void slotLoadingTrack(TrackPointer pNewTrack, TrackPointer pOldTrack); void slotTrackChanged(); + void slotWaveformChanged(); + void slotBeatsChanged(); + void slotHotcuesChanged(); void setArtist(const QString& artist); void setTitle(const QString& title); @@ -102,12 +125,21 @@ class QmlPlayerProxy : public QObject { void colorChanged(); void coverArtUrlChanged(); void trackLocationUrlChanged(); + void cuesChanged(); void loadTrackFromLocationRequested(const QString& trackLocation, bool play); + void waveformLengthChanged(); + void waveformTextureChanged(); + void waveformTextureSizeChanged(); + void waveformTextureStrideChanged(); + private: + QImage m_waveformTexture; QPointer m_pTrackPlayer; TrackPointer m_pCurrentTrack; + QmlBeatsModel* m_pBeatsModel; + QmlCuesModel* m_pHotcuesModel; }; } // namespace qml diff --git a/tools/debian_buildenv.sh b/tools/debian_buildenv.sh index 2c1c79750eb3..1c002fb5b2dc 100755 --- a/tools/debian_buildenv.sh +++ b/tools/debian_buildenv.sh @@ -80,6 +80,7 @@ case "$1" in libportmidi-dev \ libprotobuf-dev \ libqt6core5compat6-dev\ + libqt6shadertools6-dev \ libqt6opengl6-dev \ libqt6sql6-sqlite \ libqt6svg6-dev \