diff --git a/.gitignore b/.gitignore index 6fe019a..9800ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__ build/ dist/ env/ +prof/ .venv/ .env/ .vscode/ @@ -24,3 +25,5 @@ ffmpeg *.goutput* *.kate-swp *.code-workspace +.coverage +htmlcov/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a245e09..2f0647c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "uv_build" name = "audio-visualizer-python" description = "Create audio visualization videos from a GUI or commandline" readme = "README.md" -version = "2.2.0" +version = "2.2.1" requires-python = ">= 3.12" license = "MIT" classifiers=[ diff --git a/src/avp/__init__.py b/src/avp/__init__.py index ea32f26..a88bf10 100644 --- a/src/avp/__init__.py +++ b/src/avp/__init__.py @@ -3,7 +3,7 @@ import logging -__version__ = "2.2.0" +__version__ = "2.2.1" class Logger(logging.getLoggerClass()): diff --git a/src/avp/component.py b/src/avp/component.py index 6c5e381..5906ab1 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -18,6 +18,7 @@ setWidgetValue, connectWidget, rgbFromString, + randomColor, blockSignals, ) @@ -635,9 +636,17 @@ def pickColor_(): self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]} for attr, func in self._colorFuncs.items(): + colorText = self._trackedWidgets[attr].text() + if colorText == "": + rndColor = randomColor() + self._trackedWidgets[attr].setText(str(rndColor)[1:-1]) self._colorWidgets[attr].clicked.connect(func) self._colorWidgets[attr].setStyleSheet( - "QPushButton {" "background-color : #FFFFFF; outline: none; }" + "QPushButton {" + "background-color : %s; outline: none; }" + % QColor( + *rgbFromString(colorText) if colorText else rndColor + ).name() ) if kwarg == "relativeWidgets": @@ -798,6 +807,7 @@ def updateRelativeWidget(self, attr): if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal: # Float changed without pixel value changing, which # means the pixel value needs to be updated + # TODO QDoubleSpinBox doesn't work with relativeWidgets because of this log.debug( "Updating %s #%s's relative widget: %s", self.__class__.name, diff --git a/src/avp/components/color.py b/src/avp/components/color.py index 1f32c23..cb0960a 100644 --- a/src/avp/components/color.py +++ b/src/avp/components/color.py @@ -2,7 +2,7 @@ import logging from ..component import Component -from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter log = logging.getLogger("AVP.Components.Color") @@ -152,13 +152,13 @@ def drawFrame(self, width, height): elif self.spread == 2: spread = QtGui.QGradient.Spread.RepeatSpread brush.setSpread(spread) - brush.setColorAt(0.0, PaintColor(*self.color1)) + brush.setColorAt(0.0, QtGui.QColor(*self.color1)) if self.trans: - brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) + brush.setColorAt(1.0, QtGui.QColor(0, 0, 0, 0)) elif self.fillType == 1 and self.stretch: - brush.setColorAt(0.2, PaintColor(*self.color2)) + brush.setColorAt(0.2, QtGui.QColor(*self.color2)) else: - brush.setColorAt(1.0, PaintColor(*self.color2)) + brush.setColorAt(1.0, QtGui.QColor(*self.color2)) image.setBrush(brush) image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight) diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui index c1713fb..c36bdd8 100644 --- a/src/avp/components/color.ui +++ b/src/avp/components/color.ui @@ -74,7 +74,7 @@ - 0,0,0 + 12 @@ -84,10 +84,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -176,7 +176,7 @@ Width - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter @@ -246,10 +246,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -370,7 +370,7 @@ -1 - QComboBox::AdjustToContentsOnFirstShow + QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow @@ -422,10 +422,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Minimum + QSizePolicy::Policy::Minimum @@ -461,7 +461,7 @@ -1 0 561 - 31 + 34 @@ -503,7 +503,7 @@ End - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -523,7 +523,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -543,7 +543,7 @@ -1 -1 561 - 31 + 34 @@ -559,7 +559,7 @@ Start - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -588,7 +588,7 @@ End - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -617,14 +617,14 @@ Centre - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::PlusMinus + QAbstractSpinBox::ButtonSymbols::PlusMinus -10000 @@ -640,7 +640,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal diff --git a/src/avp/components/image.py b/src/avp/components/image.py index bada15f..e012cec 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -4,13 +4,13 @@ from copy import copy from ..component import Component -from ..toolkit.frame import BlankFrame -from .original import Component as Visualizer +from ..toolkit.frame import BlankFrame, addShadow +from ..toolkit.visualizer import createSpectrumArray class Component(Component): name = "Image" - version = "2.0.0" + version = "2.1.0" def widget(self, *args): super().widget(*args) @@ -35,6 +35,7 @@ def widget(self, *args): "mirror": self.page.checkBox_mirror, "respondToAudio": self.page.checkBox_respondToAudio, "sensitivity": self.page.spinBox_sensitivity, + "shadow": self.page.checkBox_shadow, }, presetNames={ "imagePath": "image", @@ -75,31 +76,16 @@ def preFrameRender(self, **kwargs): # Trigger creation of new base image self.existingImage = None - smoothConstantDown = 0.08 + 0 - smoothConstantUp = 0.8 - 0 - self.lastSpectrum = None - self.spectrumArray = {} - - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = Visualizer.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - self.sensitivity, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + 0.08, + 0.8, + self.sensitivity, + self.progressBarUpdate, + self.progressBarSetText, + ) def frameRender(self, frameNo): return self.drawFrame( @@ -139,9 +125,16 @@ def drawFrame(self, width, height, dynamicScale): self.existingImage = image # Respond to audio + resolutionFactor = height / 1080 + shadX = int(resolutionFactor * 1) + shadY = int(resolutionFactor * -1) + shadBlur = resolutionFactor * 3.50 scale = 0 if dynamicScale is not None: scale = dynamicScale[36 * 4] / 4 + shadX += int((scale / 4) * resolutionFactor) + shadY += int((scale / 2) * resolutionFactor) + shadBlur += (scale / 8) * resolutionFactor image = ImageOps.contain( image, ( @@ -161,6 +154,8 @@ def drawFrame(self, width, height, dynamicScale): ) if self.rotate != 0: frame = frame.rotate(self.rotate) + if self.shadow: + frame = addShadow(frame, shadBlur, shadX, shadY) return frame diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui index 72593a3..b53c4b0 100644 --- a/src/avp/components/image.ui +++ b/src/avp/components/image.ui @@ -306,6 +306,16 @@ + + + + Qt::LayoutDirection::RightToLeft + + + Shadow + + + diff --git a/src/avp/components/life.py b/src/avp/components/life.py index 9e5e202..a062617 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -8,8 +8,8 @@ from ..component import Component -from ..toolkit.frame import BlankFrame, scale -from .original import Component as Visualizer +from ..toolkit.frame import BlankFrame, scale, addShadow +from ..toolkit.visualizer import createSpectrumArray log = logging.getLogger("AVP.Component.Life") @@ -17,7 +17,7 @@ class Component(Component): name = "Conway's Game of Life" - version = "2.0.0" + version = "2.0.1" def widget(self, *args): super().widget(*args) @@ -27,26 +27,26 @@ def widget(self, *args): # https://conwaylife.com/wiki/Queen_bee_shuttle self.startingGrid = set( [ - (3, 7), - (3, 8), - (4, 7), - (4, 8), - (8, 7), - (9, 6), - (9, 8), - (10, 5), + (3, 11), + (3, 12), + (4, 11), + (4, 12), + (8, 11), + (9, 10), + (9, 12), (10, 9), - (11, 6), - (11, 7), - (11, 8), - (12, 4), - (12, 5), + (10, 13), + (11, 10), + (11, 11), + (11, 12), + (12, 8), (12, 9), - (12, 10), - (23, 6), - (23, 7), - (24, 6), - (24, 7), + (12, 13), + (12, 14), + (23, 10), + (23, 11), + (24, 10), + (24, 11), ] ) @@ -163,41 +163,27 @@ def updateGridSize(self): def previewRender(self): image = self.drawGrid(self.startingGrid, self.color) image = self.addKaleidoscopeEffect(image) - image = self.addShadow(image) + if self.shadow: + image = addShadow(image, 5.00, -2, 2) image = self.addGridLines(image) return image def preFrameRender(self, *args, **kwargs): super().preFrameRender(*args, **kwargs) self.tickGrids = {0: self.startingGrid} - - smoothConstantDown = 0.08 + 0 - smoothConstantUp = 0.8 - 0 - self.lastSpectrum = None - self.spectrumArray = {} if self.sensitivity == 0: return - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = Visualizer.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - self.sensitivity, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + 0.08, + 0.8, + 20, + self.progressBarUpdate, + self.progressBarSetText, + ) def properties(self): if self.customImg and (not self.image or not os.path.exists(self.image)): @@ -256,21 +242,11 @@ def frameRender(self, frameNo): if not self.customImg: image = Image.alpha_composite(previousGridImage, image) image = self.addKaleidoscopeEffect(image) - image = self.addShadow(image) + if self.shadow: + image = addShadow(image, 5.00, -2, 2) image = self.addGridLines(image) return image - def addShadow(self, frame): - if not self.shadow: - return frame - - shadImg = ImageEnhance.Contrast(frame).enhance(0.0) - shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) - shadImg = ImageChops.offset(shadImg, -2, 2) - shadImg.paste(frame, box=(0, 0), mask=frame) - frame = shadImg - return frame - def addGridLines(self, frame): if not self.showGrid: return frame @@ -299,11 +275,6 @@ def addKaleidoscopeEffect(self, frame): flippedImage = frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT) frame.paste(flippedImage, (0, 0), mask=flippedImage) - flippedImage = frame.transpose(Image.Transpose.ROTATE_90) - frame.paste(flippedImage, (0, 0), mask=flippedImage) - - flippedImage = frame.transpose(Image.Transpose.ROTATE_270) - frame.paste(flippedImage, (0, 0), mask=flippedImage) return frame def drawGrid(self, grid, color, spectrumData=None, didntChange=None): @@ -506,10 +477,10 @@ def slantLine(difference): for x, y in grid: drawPtX = x * self.pxWidth - if drawPtX > self.width: + if drawPtX > self.width or drawPtX + self.pxWidth < 0: continue drawPtY = y * self.pxHeight - if drawPtY > self.height: + if drawPtY > self.height or drawPtY + self.pxHeight < 0: continue audioMorphWidth = ( diff --git a/src/avp/components/life.ui b/src/avp/components/life.ui index a0c8999..0070fa4 100644 --- a/src/avp/components/life.ui +++ b/src/avp/components/life.ui @@ -68,7 +68,7 @@ - 255,255,255 + diff --git a/src/avp/components/original.py b/src/avp/components/original.py index 64eba4d..0da78dc 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/original.py @@ -4,11 +4,12 @@ from ..component import Component from ..toolkit.frame import BlankFrame +from ..toolkit.visualizer import createSpectrumArray class Component(Component): name = "Classic Visualizer" - version = "1.0.0" + version = "1.1.0" def names(*args): return ["Original Audio Visualization"] @@ -18,6 +19,7 @@ def properties(self): def widget(self, *args): self.scale = 20 + self.bars = 63 self.y = 0 super().widget(*args) @@ -27,15 +29,14 @@ def widget(self, *args): self.page.comboBox_visLayout.addItem("Top") self.page.comboBox_visLayout.setCurrentIndex(0) - self.page.lineEdit_visColor.setText("255,255,255") - self.trackWidgets( { "visColor": self.page.lineEdit_visColor, "layout": self.page.comboBox_visLayout, "scale": self.page.spinBox_scale, "y": self.page.spinBox_y, - "smooth": self.page.spinBox_smooth, + "smooth": self.page.spinBox_sensitivity, + "bars": self.page.spinBox_bars, }, colorWidgets={ "visColor": self.page.pushButton_visColor, @@ -59,29 +60,16 @@ def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15 smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15 - self.lastSpectrum = None - self.spectrumArray = {} - - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = self.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - self.scale, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + smoothConstantDown, + smoothConstantUp, + self.scale, + self.progressBarUpdate, + self.progressBarSetText, + ) def frameRender(self, frameNo): arrayNo = frameNo * self.sampleSize @@ -93,60 +81,10 @@ def frameRender(self, frameNo): self.layout, ) - @staticmethod - def transformData( - i, - completeAudioArray, - sampleSize, - smoothConstantDown, - smoothConstantUp, - lastSpectrum, - scale, - ): - if len(completeAudioArray) < (i + sampleSize): - sampleSize = len(completeAudioArray) - i - - window = numpy.hanning(sampleSize) - data = completeAudioArray[i : i + sampleSize][::1] * window - paddedSampleSize = 2048 - paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") - spectrum = numpy.fft.fft(paddedData) - sample_rate = 44100 - frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) - - y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) - - # filter the noise away - # y[y<80] = 0 - - with numpy.errstate(divide="ignore"): - y = scale * numpy.log10(y) - - y[numpy.isinf(y)] = 0 - - if lastSpectrum is not None: - lastSpectrum[y < lastSpectrum] = y[ - y < lastSpectrum - ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( - 1 - smoothConstantDown - ) - - lastSpectrum[y >= lastSpectrum] = y[ - y >= lastSpectrum - ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * ( - 1 - smoothConstantUp - ) - else: - lastSpectrum = y - - x = frequencies[0 : int(paddedSampleSize / 2) - 1] - - return lastSpectrum - def drawBars(self, width, height, spectrum, color, layout): bigYCoord = height - height / 8 smallYCoord = height / 1200 - bigXCoord = width / 64 + bigXCoord = width / (self.bars + 1) middleXCoord = bigXCoord / 2 smallXCoord = bigXCoord / 4 @@ -155,7 +93,7 @@ def drawBars(self, width, height, spectrum, color, layout): r, g, b = color color2 = (r, g, b, 125) - for i in range(0, 63): + for i in range(self.bars): x0 = middleXCoord + i * bigXCoord y0 = bigYCoord + smallXCoord y1 = bigYCoord + smallXCoord - spectrum[i * 4] * smallYCoord - middleXCoord diff --git a/src/avp/components/original.ui b/src/avp/components/original.ui index c7b7e22..8dbdaa2 100644 --- a/src/avp/components/original.ui +++ b/src/avp/components/original.ui @@ -44,10 +44,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -84,15 +84,19 @@ - + + + + + - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -112,7 +116,7 @@ - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows -5000 @@ -131,7 +135,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -158,7 +162,7 @@ - QAbstractSpinBox::PlusMinus + QAbstractSpinBox::ButtonSymbols::PlusMinus 1 @@ -168,13 +172,27 @@ + + + + Sensitivity + + + + + + + 5 + + + - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Expanding + QSizePolicy::Policy::Expanding @@ -189,29 +207,35 @@ - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint 4 - + - Sensitivity + Bars - + + + 63 + - 5 + 64 + + + 63 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -226,7 +250,7 @@ - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/avp/components/text.py b/src/avp/components/text.py index 40c981a..bee117e 100644 --- a/src/avp/components/text.py +++ b/src/avp/components/text.py @@ -5,7 +5,7 @@ import logging from ..component import Component -from ..toolkit.frame import FramePainter, PaintColor +from ..toolkit.frame import FramePainter, addShadow log = logging.getLogger("AVP.Components.Text") @@ -26,7 +26,6 @@ def widget(self, *args): self.page.comboBox_textAlign.addItem("Right") self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) self.page.spinBox_fontSize.setValue(int(self.fontSize)) - self.page.lineEdit_title.setText(self.title) self.page.pushButton_center.clicked.connect(self.centerXY) self.page.fontComboBox_titleFont.currentFontChanged.connect( @@ -35,7 +34,7 @@ def widget(self, *args): # The QFontComboBox must be connected directly to the Qt Signal # which triggers the preview to update. # This unfortunately makes changing the font into a non-undoable action. - # Must be something broken in the conversion to a ComponentAction + # Fix requires updating ComponentAction to handle fonts self.trackWidgets( { @@ -173,7 +172,7 @@ def addText(self, width, height): path.addText(x, y, font, self.title) path = outliner.createStroke(path) image.setPen(QtCore.Qt.PenStyle.NoPen) - image.setBrush(PaintColor(*self.strokeColor)) + image.setBrush(QtGui.QColor(*self.strokeColor)) image.drawPath(path) image.setFont(font) @@ -183,11 +182,7 @@ def addText(self, width, height): # turn QImage into Pillow frame frame = image.finalize() if self.shadow: - shadImg = ImageEnhance.Contrast(frame).enhance(0.0) - shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur)) - shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY) - shadImg.paste(frame, box=(0, 0), mask=frame) - frame = shadImg + frame = addShadow(frame, self.shadBlur / 10, self.shadX, self.shadY) return frame diff --git a/src/avp/components/text.ui b/src/avp/components/text.ui index b62e0ed..ce961fe 100644 --- a/src/avp/components/text.ui +++ b/src/avp/components/text.ui @@ -15,646 +15,645 @@ - - - 6 - - - QLayout::SetDefaultConstraint - + + + + + Title + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Text + + + + + + + + 0 + 0 + + + + Font + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + - 4 + 0 - - - - - Title - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - Testing New GUI - - - - - - - - 0 - 0 - - - - Font - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - + + + + 0 + 0 + + + + Text Layout + + + + + + + + 0 + 0 + + + + + 100 + 16777215 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Center Text + + - - + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 50 + 16777215 + + + + + 0 + 0 + + + 0 - - - - - 0 - 0 - - - - Text Layout - - - - - - - - 0 - 0 - - - - - 100 - 16777215 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Center Text - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 50 - 16777215 - - - - - 0 - 0 - - - - 0 - - - 999999999 - - - 0 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 50 - 16777215 - - - - 999999999 - - - - + + 999999999 + + + 0 + + - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Text Color - - - - - - - - 0 - 0 - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Font Size - - - - - - - - 0 - 0 - - - - - - - - - - 1 - - - 500 - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Font Style - - - - - - - - Normal - - - - - Semi-Bold - - - - - Bold - - - - - Italic - - - - - Bold Italic - - - - - Faux Italic - - - - - Small Caps - - - - - + + + + 0 + 0 + + + + Y + + - - - - - - 0 - 0 - - - - - 0 - 16777215 - - - - Qt::NoFocus - - - 255,255,255 - - - - - - - - 0 - 0 - - - - Stroke - - - - - - - - 0 - 0 - - - - px - - - - - - - - 0 - 0 - - - - Stroke Color - - - - - - - - 0 - 0 - - - - - 0 - 16777215 - - - - Qt::NoFocus - - - 0,0,0 - - - - - - - - 0 - 0 - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + + + + 0 + 0 + + + + + 50 + 16777215 + + + + 999999999 + + + + + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Text Color + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Size + + + + + + + + 0 + 0 + + + + + + + + + + 1 + + + 500 + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Style + + - + - - - - 0 - 0 - - - - Shadow - - + + Normal + - - - - 0 - 0 - - - - Shadow Offset - - + + Semi-Bold + - - - - 0 - 0 - - - - -1000 - - - 1000 - - - -4 - - + + Bold + - - - - 0 - 0 - - - - -1000 - - - 1000 - - - 8 - - + + Italic + - - - - 0 - 0 - - - - Shadow Blur - - + + Bold Italic + - - - - 0 - 0 - - - - 99.000000000000000 - - - 0.100000000000000 - - - 5.000000000000000 - - + + Faux Italic + - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 40 - 20 - - - + + Small Caps + - + + + + + + + + + + + 0 + 0 + + + + + 0 + 16777215 + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + + + 0 + 0 + + + + Stroke + + + + + + + + 0 + 0 + + + + px + + + + + + + + 0 + 0 + + + + Stroke Color + + + + + + + + 0 + 0 + + + + + 0 + 16777215 + + + + Qt::FocusPolicy::NoFocus + + + 0,0,0 + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + Shadow + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Preferred + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Shadow Offset + + + + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + 2 + + + + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + -2 + + + + + + + + 0 + 0 + + + + Shadow Blur + + + + + + + + 0 + 0 + + + + QAbstractSpinBox::ButtonSymbols::PlusMinus + + + QAbstractSpinBox::CorrectionMode::CorrectToPreviousValue + + + 1000 + + + QAbstractSpinBox::StepType::DefaultStepType + + + 35 + + + 10 + + - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/avp/components/video.ui b/src/avp/components/video.ui index 08d15d3..72ecb0c 100644 --- a/src/avp/components/video.ui +++ b/src/avp/components/video.ui @@ -27,168 +27,161 @@ - - - 4 - + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Video + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Video - - - - - - - - 1 - 0 - - - - - - - - - 0 - 0 - - - - - 1 - 0 - - - - - 32 - 32 - - - - ... - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -10000 - - - 10000 - - - 0 - - - - + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + @@ -204,7 +197,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -227,14 +220,14 @@ Scale - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -296,7 +289,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -311,7 +304,7 @@ - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py index 7dc0b99..e10dec2 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -1,12 +1,12 @@ -from PIL import Image -from PyQt6 import QtGui, QtCore, QtWidgets +from PIL import Image, ImageChops from PyQt6.QtGui import QColor import os -import math import subprocess import logging +from copy import copy from ..component import Component +from ..toolkit.visualizer import transformData, createSpectrumArray from ..toolkit.frame import BlankFrame, scale from ..toolkit import checkOutput from ..toolkit.ffmpeg import ( @@ -23,14 +23,20 @@ class Component(Component): name = "Waveform" - version = "1.0.0" + version = "2.0.0" + + @property + def updateInterval(self): + """How many frames from FFmpeg are ignored between each final frame""" + return 100 - self.speed + + def properties(self): + return [] if self.speed == 100 else ["pcm"] def widget(self, *args): super().widget(*args) self._image = BlankFrame(self.width, self.height) - self.page.lineEdit_color.setText("255,255,255") - if hasattr(self.parent, "lineEdit_audioFile"): self.parent.lineEdit_audioFile.textChanged.connect(self.update) @@ -46,6 +52,7 @@ def widget(self, *args): "opacity": self.page.spinBox_opacity, "compress": self.page.checkBox_compress, "mono": self.page.checkBox_mono, + "speed": self.page.spinBox_speed, }, colorWidgets={ "color": self.page.pushButton_color, @@ -65,6 +72,10 @@ def previewRender(self): return frame def preFrameRender(self, **kwargs): + self._fadingImage = None + self._prevImage = None + self._currImage = None + self._lastUpdatedFrame = 0 super().preFrameRender(**kwargs) self.updateChunksize() w, h = scale(self.scale, self.width, self.height, str) @@ -79,11 +90,64 @@ def preFrameRender(self, **kwargs): component=self, debug=True, ) + if self.speed == 100: + return + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + 0.08, + 0.8, + 20, + self.progressBarUpdate, + self.progressBarSetText, + ) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: raise FfmpegVideo.threadError - return self.finalizeFrame(self.video.frame(frameNo)) + newFrame = self.finalizeFrame(self.video.frame(frameNo)) + if self.speed == 100: + return newFrame + frameDiff = 0 if frameNo == 0 else frameNo % self.updateInterval + peaks = [ + self.spectrumArray[frameNo * self.sampleSize][i * 4] for i in range(64) + ] + peakValue = 70 - (max(*peaks) - min(*peaks)) + isValidPeak = ( + peakValue > 27 + and frameNo - self._lastUpdatedFrame > self.updateInterval / 2 + ) + if frameDiff == 0 or isValidPeak: + self._lastUpdatedFrame = frameNo + self._fadingImage = self._prevImage + self._prevImage = self._image + self._currImage = newFrame + usualAlpha = 0.0 + (1 / self.updateInterval) * frameDiff + alpha = max( + 0.1 + + ( + 1 + / max( + 10, + peakValue, + ) + ), + usualAlpha, + ) + baseImage = self._prevImage + if self._fadingImage is not None: + # fade away the old previous frame from ages ago + baseImage = ImageChops.blend( + self._prevImage, self._fadingImage, max(0.0, 0.9 - usualAlpha) + ) + blendedImage = ImageChops.blend( + baseImage, + ImageChops.lighter(self._prevImage, newFrame), + alpha, + ) + baseImage.paste(blendedImage, (0, 0), mask=blendedImage) + return Image.alpha_composite(self._currImage, baseImage) def postFrameRender(self): closePipe(self.video.pipe) @@ -162,17 +226,17 @@ def getPreviewFrame(self, width, height): def makeFfmpegFilter(self, preview=False, startPt=0): w, h = scale(self.scale, self.width, self.height, str) if self.amplitude == 0: - amplitude = "lin" - elif self.amplitude == 1: amplitude = "log" - elif self.amplitude == 2: + elif self.amplitude == 1: amplitude = "sqrt" - elif self.amplitude == 3: + elif self.amplitude == 2: amplitude = "cbrt" + elif self.amplitude == 3: + amplitude = "lin" hexcolor = QColor(*self.color).name() opacity = "{0:.1f}".format(self.opacity / 100) genericPreview = self.settings.value("pref_genericPreview") - if self.mode < 3: + if self.mode > 1: filter_ = ( "showwaves=" f'r={str(self.settings.value("outputFrameRate"))}:' @@ -180,10 +244,10 @@ def makeFfmpegFilter(self, preview=False, startPt=0): f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:' f"colors={hexcolor}@{opacity}:scale={amplitude}" ) - elif self.mode > 2: + elif self.mode < 2: filter_ = ( f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:' - f'mode={"line" if self.mode == 4 else "bar"}:' + f'mode={"line" if self.mode == 0 else "bar"}:' f"colors={hexcolor}@{opacity}" f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}" ) diff --git a/src/avp/components/waveform.ui b/src/avp/components/waveform.ui index 5473f33..434ba62 100644 --- a/src/avp/components/waveform.ui +++ b/src/avp/components/waveform.ui @@ -27,156 +27,144 @@ - - - 4 - + - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Mode - - - - - - - - Cline - - - - - Line - - - - - Point - - - - - Frequency Bar - - - - - Frequency Line - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - + + + + 0 + 0 + + + + + 31 + 0 + + + + Mode + + + + + - - - - 0 - 0 - - - - X - - + + Frequency Line + - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - + + Frequency Bar + - - - - 0 - 0 - - - - Y - - + + Cline + - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -10000 - - - 10000 - - - 0 - - + + Line + - + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + @@ -192,7 +180,7 @@ - Qt::ImhNone + Qt::InputMethodHint::ImhNone @@ -224,7 +212,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -240,14 +228,14 @@ Opacity - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -269,14 +257,14 @@ Scale - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -301,6 +289,9 @@ Compress + + true + @@ -320,7 +311,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -341,32 +332,75 @@ - Linear + Logarithmic - Logarithmic + Square root - Square root + Cubic root - Cubic root + Linear + + + + + + Animation Speed + + + + + + + % + + + 10 + + + 100 + + + 10 + + + 50 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/avp/core.py b/src/avp/core.py index 196cd7d..099b0b4 100644 --- a/src/avp/core.py +++ b/src/avp/core.py @@ -12,7 +12,7 @@ from . import toolkit - +appName = "Audio Visualizer Python" log = logging.getLogger("AVP.Core") STDOUT_LOGLVL = logging.WARNING diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index e7a5fe3..5a051fd 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -7,7 +7,7 @@ from PyQt6 import QtCore, QtWidgets, uic import PyQt6.QtWidgets as QtWidgets -from PyQt6.QtGui import QUndoStack, QShortcut +from PyQt6.QtGui import QShortcut from PIL import Image from queue import Queue import sys @@ -16,12 +16,16 @@ import filecmp import time import logging +from textwrap import wrap -from ..core import Core +from ..__init__ import __version__ +from ..core import Core, appName +from .undostack import UndoStack from . import preview_thread from .preview_win import PreviewWindow from .presetmanager import PresetManager from .actions import * +from ..toolkit.ffmpeg import createFfmpegCommand from ..toolkit import ( disableWhenEncoding, disableWhenOpeningProject, @@ -30,25 +34,9 @@ ) -appName = "Audio Visualizer" log = logging.getLogger("AVP.Gui.MainWindow") -class MyQUndoStack(QUndoStack): - # FIXME move this class - @property - def encoding(self): - return self.parent().encoding - - @disableWhenEncoding - def undo(self, *args, **kwargs): - super().undo(*args, **kwargs) - - @disableWhenEncoding - def redo(self, *args, **kwargs): - super().redo(*args, **kwargs) - - class MainWindow(QtWidgets.QMainWindow): """ The MainWindow wraps many Core methods in order to update the GUI @@ -91,7 +79,7 @@ def __init__(self, project, dpi): self.settings = Core.settings # Create stack of undoable user actions - self.undoStack = MyQUndoStack(self) + self.undoStack = UndoStack(self) undoLimit = self.settings.value("pref_undoLimit") self.undoStack.setUndoLimit(undoLimit) @@ -102,6 +90,7 @@ def __init__(self, project, dpi): layout = QtWidgets.QVBoxLayout() layout.addWidget(undoView) self.undoDialog.setLayout(layout) + self.undoDialog.setMinimumWidth(int(self.width() / 2)) # Create Preset Manager self.presetManager = PresetManager(self) @@ -325,7 +314,11 @@ def changedField(): self.drawPreview(True) log.info("Pillow version %s", Image.__version__) - log.info("PyQt version %s (Qt version %s)", QtCore.PYQT_VERSION_STR, QtCore.QT_VERSION_STR) + log.info( + "PyQt version %s (Qt version %s)", + QtCore.PYQT_VERSION_STR, + QtCore.QT_VERSION_STR, + ) # verify Ffmpeg version if not self.core.FFMPEG_BIN: @@ -408,6 +401,7 @@ def changedField(): activated=lambda: self.moveComponent("bottom"), ) + QShortcut("F1", self, self.showHelpWindow) QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand) QShortcut("Ctrl+Shift+U", self, self.showUndoStack) @@ -422,6 +416,12 @@ def changedField(): if not self.core.selectedComponents: self.core.insertComponent(0, 0, self) self.core.insertComponent(1, 1, self) + # set colors to white and black to match classic appearance of program + self.core.selectedComponents[0].page.lineEdit_visColor.setText( + "255,255,255" + ) + self.core.selectedComponents[1].page.lineEdit_color1.setText("0,0,0") + self.undoStack.clear() def __repr__(self): return ( @@ -762,10 +762,10 @@ def showPreviewImage(self, image): def showUndoStack(self): self.undoDialog.show() - def showFfmpegCommand(self): - from textwrap import wrap - from ..toolkit.ffmpeg import createFfmpegCommand + def showHelpWindow(self): + self.showMessage(msg=f"{appName} v{__version__}") + def showFfmpegCommand(self): command = createFfmpegCommand( self.lineEdit_audioFile.text(), self.lineEdit_outputFile.text(), @@ -899,7 +899,9 @@ def clear(self): @disableWhenEncoding def createNewProject(self, prompt=True): if prompt: - self.openSaveChangesDialog("starting a new project") + ch = self.openSaveChangesDialog("starting a new project") + if ch is None: + return self.clear() self.currentProject = None @@ -919,18 +921,19 @@ def saveCurrentProject(self): def openSaveChangesDialog(self, phrase): success = True + ch = True if self.autosaveExists(identical=False): ch = self.showMessage( msg="You have unsaved changes in project '%s'. " "Save before %s?" % (os.path.basename(self.currentProject)[:-4], phrase), - showCancel=True, + showDiscard=True, ) if ch: success = self.saveProjectChanges() - - if success and os.path.exists(self.autosavePath): + if ch is not None and success and os.path.exists(self.autosavePath): os.remove(self.autosavePath) + return success and ch def openSaveProjectDialog(self): filename, _ = QtWidgets.QFileDialog.getSaveFileName( @@ -967,10 +970,12 @@ def openProject(self, filepath, prompt=True): ): return - self.clear() # ask to save any changes that are about to get deleted if prompt: - self.openSaveChangesDialog("opening another project") + ch = self.openSaveChangesDialog("opening another project") + if ch is None: + return + self.clear() self.currentProject = filepath self.settings.setValue("currentProject", filepath) @@ -992,7 +997,13 @@ def showMessage(self, **kwargs): else QtWidgets.QMessageBox.Icon.Information ) msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None) - if "showCancel" in kwargs and kwargs["showCancel"]: + if "showDiscard" in kwargs and kwargs["showDiscard"]: + msg.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Save + | QtWidgets.QMessageBox.StandardButton.Discard + | QtWidgets.QMessageBox.StandardButton.Cancel + ) + elif "showCancel" in kwargs and kwargs["showCancel"]: msg.setStandardButtons( QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel @@ -1000,9 +1011,14 @@ def showMessage(self, **kwargs): else: msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) ch = msg.exec() - if ch == 1024: + if ch == 1024 or ch == 2048: + # OK or Save return True - return False + elif ch > 8000000: + # Discard + return False + # Cancel + return None @disableWhenEncoding def componentContextMenu(self, QPos): diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py index 980a969..ca0029d 100644 --- a/src/avp/gui/presetmanager.py +++ b/src/avp/gui/presetmanager.py @@ -9,7 +9,7 @@ import logging from ..toolkit import badName -from ..core import Core +from ..core import Core, appName from .actions import * @@ -137,7 +137,7 @@ def openSavePresetDialog(self): currentPreset = selectedComponents[index].currentPreset newName, OK = QtWidgets.QInputDialog.getText( self.parent, - "Audio Visualizer", + appName, "New Preset Name:", QtWidgets.QLineEdit.EchoMode.Normal, currentPreset, diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py index 1d78516..a59652a 100644 --- a/src/avp/gui/preview_thread.py +++ b/src/avp/gui/preview_thread.py @@ -65,17 +65,18 @@ def process(self): component.unlockSize() frame = Image.alpha_composite(frame, newFrame) - except ValueError as e: + except (AttributeError, ValueError) as e: errMsg = ( "Bad frame returned by %s's preview renderer. " - "%s. New frame size was %s*%s; should be %s*%s." + "%s. New frame %s." % ( str(component), str(e).capitalize(), - newFrame.width, - newFrame.height, - width, - height, + "is None" if newFrame is None else "size was %s*%s; should be %s*%s" % ( + newFrame.width, + newFrame.height, + width, + height), ) ) log.critical(errMsg) diff --git a/src/avp/gui/undostack.py b/src/avp/gui/undostack.py new file mode 100644 index 0000000..fd1a3e9 --- /dev/null +++ b/src/avp/gui/undostack.py @@ -0,0 +1,16 @@ +from PyQt6.QtGui import QUndoStack +from ..toolkit.common import disableWhenEncoding + + +class UndoStack(QUndoStack): + @property + def encoding(self): + return self.parent().encoding + + @disableWhenEncoding + def undo(self, *args, **kwargs): + super().undo(*args, **kwargs) + + @disableWhenEncoding + def redo(self, *args, **kwargs): + super().redo(*args, **kwargs) diff --git a/src/avp/toolkit/common.py b/src/avp/toolkit/common.py index e35aba2..a6195ed 100644 --- a/src/avp/toolkit/common.py +++ b/src/avp/toolkit/common.py @@ -4,7 +4,7 @@ from PyQt6 import QtWidgets import string -import os +import random import sys import subprocess import logging @@ -135,7 +135,12 @@ def rgbFromString(string): if i > 255 or i < 0: raise ValueError return tup - except: + except Exception as e: + log.warning( + "Could not parse '%s' as a color (encountered %s).", + string, + type(e).__name__, + ) return (255, 255, 255) @@ -150,6 +155,7 @@ def formatTraceback(tb=None): def connectWidget(widget, func): + unsupportedWidgets = ["QtWidgets.QFontComboBox"] if type(widget) == QtWidgets.QLineEdit: widget.textChanged.connect(func) elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: @@ -158,6 +164,10 @@ def connectWidget(widget, func): widget.stateChanged.connect(func) elif type(widget) == QtWidgets.QComboBox: widget.currentIndexChanged.connect(func) + elif type(widget) in unsupportedWidgets: + log.info( + "Could not connect %s using connectWidget()", str(widget.__class__.__name__) + ) else: log.warning("Failed to connect %s ", str(widget.__class__.__name__)) return False @@ -190,3 +200,7 @@ def getWidgetValue(widget): return widget.isChecked() elif type(widget) == QtWidgets.QComboBox: return widget.currentIndex() + + +def randomColor(): + return (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) diff --git a/src/avp/toolkit/frame.py b/src/avp/toolkit/frame.py index 94537a6..829b05b 100644 --- a/src/avp/toolkit/frame.py +++ b/src/avp/toolkit/frame.py @@ -3,7 +3,7 @@ """ from PyQt6 import QtGui -from PIL import Image +from PIL import Image, ImageEnhance, ImageChops, ImageFilter from PIL.ImageQt import ImageQt from PyQt6 import QtCore import sys @@ -30,7 +30,7 @@ def __init__(self, width, height): def setPen(self, penStyle): if type(penStyle) is tuple: - super().setPen(PaintColor(*penStyle)) + super().setPen(QtGui.QColor(*penStyle)) else: super().setPen(penStyle) @@ -45,24 +45,14 @@ def finalize(self): buffer.close() self.end() return frame - imBytes = self.image.bits().asstring(self.image.byteCount()) - frame = Image.frombytes( - "RGBA", (self.image.width(), self.image.height()), imBytes - ) - self.end() - return frame - -class PaintColor(QtGui.QColor): - """ - Subclass of QtGui.QColor with an added scale() method - Previously this class reversed the painter colour to solve - hardware issues related to endianness, - but Qt appears to deal with this itself nowadays - """ - def __init__(self, r, g, b, a=255): - super().__init__(r, g, b, a) +def addShadow(frame, blurRadius, blurOffsetX, blurOffsetY): + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(blurRadius)) + frame = shadImg.paste(frame, box=(-blurOffsetX, -blurOffsetY), mask=frame) + frame = shadImg + return frame def scale(scalePercent, width, height, returntype=None): diff --git a/src/avp/toolkit/visualizer.py b/src/avp/toolkit/visualizer.py new file mode 100644 index 0000000..c55a3f3 --- /dev/null +++ b/src/avp/toolkit/visualizer.py @@ -0,0 +1,87 @@ +"""Functions used to transform and manipulate audio for use by visualizers""" + +from copy import copy +import numpy + + +def createSpectrumArray( + component, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + scale, + progressBarUpdate, + progressBarSetText, +): + lastSpectrum = None + spectrumArray = {} + for i in range(0, len(completeAudioArray), sampleSize): + if component.canceled: + break + lastSpectrum = transformData( + i, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + lastSpectrum, + scale, + ) + spectrumArray[i] = copy(lastSpectrum) + + progress = int(100 * (i / len(completeAudioArray))) + if progress >= 100: + progress = 100 + progressText = f"Analyzing audio: {str(progress)}%" + progressBarSetText.emit(progressText) + progressBarUpdate.emit(int(progress)) + return spectrumArray + + +def transformData( + i, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + lastSpectrum, + scale, +): + if len(completeAudioArray) < (i + sampleSize): + sampleSize = len(completeAudioArray) - i + + window = numpy.hanning(sampleSize) + data = completeAudioArray[i : i + sampleSize][::1] * window + paddedSampleSize = 2048 + paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") + spectrum = numpy.fft.fft(paddedData) + sample_rate = 44100 + frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) + + y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) + + # filter the noise away + # y[y<80] = 0 + + with numpy.errstate(divide="ignore"): + y = scale * numpy.log10(y) + + y[numpy.isinf(y)] = 0 + + if lastSpectrum is not None: + lastSpectrum[y < lastSpectrum] = y[ + y < lastSpectrum + ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( + 1 - smoothConstantDown + ) + + lastSpectrum[y >= lastSpectrum] = y[ + y >= lastSpectrum + ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) + else: + lastSpectrum = y + + x = frequencies[0 : int(paddedSampleSize / 2) - 1] + + return lastSpectrum diff --git a/tests/test_commandline_parser.py b/tests/test_commandline_parser.py index d092072..77836ce 100644 --- a/tests/test_commandline_parser.py +++ b/tests/test_commandline_parser.py @@ -51,6 +51,11 @@ def test_commandline_parses_classic_by_alias(qtbot): assert command.parseCompName("original") == "Classic Visualizer" -def test_commandline_parses_conway_by_name(qtbot): +def test_commandline_parses_conway_by_short_name(qtbot): command = Command() assert command.parseCompName("conway") == "Conway's Game of Life" + + +def test_commandline_parses_image_by_name(qtbot): + command = Command() + assert command.parseCompName("image") == "Image" diff --git a/tests/test_comp_color.py b/tests/test_comp_color.py new file mode 100644 index 0000000..6b82e4c --- /dev/null +++ b/tests/test_comp_color.py @@ -0,0 +1,22 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithColorComp(qtbot): + """Fixture providing a Command object with Color component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent(0, command.core.moduleIndexFor("Color"), command) + yield command.core + + +def test_comp_color_set_color(coreWithColorComp): + "Set imagePath of Image component" + comp = coreWithColorComp.selectedComponents[0] + comp.page.lineEdit_color1.setText("111,111,111") + image = comp.previewRender() + assert imageDataSum(image) == 1219276800 diff --git a/tests/test_image_comp.py b/tests/test_comp_image.py similarity index 100% rename from tests/test_image_comp.py rename to tests/test_comp_image.py diff --git a/tests/test_comp_life.py b/tests/test_comp_life.py new file mode 100644 index 0000000..ad78e52 --- /dev/null +++ b/tests/test_comp_life.py @@ -0,0 +1,23 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithLifeComp(qtbot): + """Fixture providing a Command object with Waveform component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent( + 0, command.core.moduleIndexFor("Conway's Game of Life"), command + ) + yield command.core + + +def test_comp_life_previewRender(coreWithLifeComp): + comp = coreWithLifeComp.selectedComponents[0] + comp.page.lineEdit_color.setText("111,111,111") + image = comp.previewRender() + assert imageDataSum(image) == 339785512 diff --git a/tests/test_classic_visualizer.py b/tests/test_comp_original.py similarity index 88% rename from tests/test_classic_visualizer.py rename to tests/test_comp_original.py index e301263..6264644 100644 --- a/tests/test_classic_visualizer.py +++ b/tests/test_comp_original.py @@ -1,4 +1,5 @@ from avp.command import Command +from avp.toolkit.visualizer import transformData from pytestqt import qtbot from pytest import fixture from . import audioData, MockSignal, imageDataSum @@ -31,13 +32,9 @@ def test_comp_classic_removed(coreWithClassicComp): def test_comp_classic_drawBars(coreWithClassicComp, audioData): """Call drawBars after creating audio spectrum data manually.""" - spectrumArray = { - 0: coreWithClassicComp.selectedComponents[0].transformData( - 0, audioData[0], sampleSize, 0.08, 0.8, None, 20 - ) - } + spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} for i in range(sampleSize, len(audioData[0]), sampleSize): - spectrumArray[i] = coreWithClassicComp.selectedComponents[0].transformData( + spectrumArray[i] = transformData( i, audioData[0], sampleSize, diff --git a/tests/test_comp_spectrum.py b/tests/test_comp_spectrum.py new file mode 100644 index 0000000..44fb257 --- /dev/null +++ b/tests/test_comp_spectrum.py @@ -0,0 +1,20 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithSpectrumComp(qtbot): + """Fixture providing a Command object with Spectrum component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent(0, command.core.moduleIndexFor("Spectrum"), command) + yield command.core + + +def test_comp_waveform_previewRender(coreWithSpectrumComp): + comp = coreWithSpectrumComp.selectedComponents[0] + image = comp.previewRender() + assert imageDataSum(image) == 71992628 diff --git a/tests/test_comp_text.py b/tests/test_comp_text.py new file mode 100644 index 0000000..e389ff9 --- /dev/null +++ b/tests/test_comp_text.py @@ -0,0 +1,45 @@ +from avp.command import Command +from PyQt6.QtGui import QFont +from pytestqt import qtbot +from pytest import fixture, mark +from . import audioData, MockSignal, imageDataSum + + +@fixture +def coreWithTextComp(qtbot): + """Fixture providing a Command object with Title Text component added""" + command = Command() + command.core.insertComponent(0, command.core.moduleIndexFor("Title Text"), command) + yield command.core + + +def setTextSettings(comp): + comp.page.spinBox_fontSize.setValue(40) + comp.page.checkBox_shadow.setChecked(True) + comp.page.spinBox_shadBlur.setValue(0) + comp.page.spinBox_shadX.setValue(2) + comp.page.spinBox_shadY.setValue(-2) + comp.page.fontComboBox_titleFont.setCurrentFont(QFont("Noto Sans")) + comp.page.lineEdit_textColor.setText("255,255,255") + + +@mark.parametrize( + "width, height", + ((1920, 1080), (1280, 720)), +) +def test_comp_text_renderFrame(coreWithTextComp, width, height): + """Call renderFrame of Title Text component added to Command object.""" + comp = coreWithTextComp.selectedComponents[0] + comp.parent.settings.setValue("outputWidth", width) + comp.parent.settings.setValue("outputHeight", height) + setTextSettings(comp) + comp.centerXY() + image = comp.frameRender(0) + assert comp.titleFont.family() == "Noto Sans" + assert comp.xPosition == width / 2 + assert image.width == width + assert comp.fontSize == 40 + assert comp.shadX == 2 + assert comp.shadY == -2 + assert comp.shadBlur == 0 + assert imageDataSum(image) == 727403 or 738586 diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py new file mode 100644 index 0000000..a71040b --- /dev/null +++ b/tests/test_comp_waveform.py @@ -0,0 +1,17 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture + + +@fixture +def coreWithWaveformComp(qtbot): + """Fixture providing a Command object with Waveform component added""" + command = Command() + command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) + yield command.core + + +def test_comp_waveform_setColor(coreWithWaveformComp): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + assert comp.color == (255, 255, 255) diff --git a/tests/test_text_comp.py b/tests/test_text_comp.py deleted file mode 100644 index 23dd1fd..0000000 --- a/tests/test_text_comp.py +++ /dev/null @@ -1,34 +0,0 @@ -from avp.command import Command -from PyQt6.QtGui import QFont -from pytestqt import qtbot -from pytest import fixture -from . import audioData, MockSignal, imageDataSum - - -@fixture -def coreWithTextComp(qtbot): - """Fixture providing a Command object with Title Text component added""" - command = Command() - command.core.insertComponent(0, command.core.moduleIndexFor("Title Text"), command) - yield command.core - - -def test_comp_text_renderFrame_resize(coreWithTextComp): - """Call renderFrame of Title Text component added to Command object.""" - comp = coreWithTextComp.selectedComponents[0] - comp.parent.settings.setValue("outputWidth", 1920) - comp.parent.settings.setValue("outputHeight", 1080) - comp.parent.core.updateComponent(0) - comp.titleFont = QFont("Noto Sans") - image = comp.frameRender(0) - assert imageDataSum(image) == 2957069 - - -def test_comp_text_renderFrame(coreWithTextComp): - """Call renderFrame of Title Text component added to Command object.""" - comp = coreWithTextComp.selectedComponents[0] - comp.parent.settings.setValue("outputWidth", 1280) - comp.parent.settings.setValue("outputHeight", 720) - comp.parent.core.updateComponent(0) - image = comp.frameRender(0) - assert imageDataSum(image) == 1412293 or 1379298 diff --git a/tests/test_toolkit_common.py b/tests/test_toolkit_common.py index d903842..8e9dca2 100644 --- a/tests/test_toolkit_common.py +++ b/tests/test_toolkit_common.py @@ -1,6 +1,27 @@ +from pytest import fixture from pytestqt import qtbot from avp.command import Command -from avp.toolkit import blockSignals +from avp.toolkit import blockSignals, rgbFromString + + +@fixture +def gotWarning(): + """Check if a function called log.warning""" + import avp.toolkit.common as tk + warning = False + def gotWarning(): + nonlocal warning + return warning + class log: + def warning(self, *args): + nonlocal warning + warning = True + oldLog = tk.log + tk.log = log() + try: + yield gotWarning + finally: + tk.log = oldLog def test_blockSignals(qtbot): @@ -11,3 +32,13 @@ def test_blockSignals(qtbot): with blockSignals(comp.page.spinBox_scale): assert comp.page.spinBox_scale.signalsBlocked() == True assert comp.page.spinBox_scale.signalsBlocked() == False + + +def test_rgbFromString(gotWarning): + assert rgbFromString("255,255,255") == (255, 255, 255) + assert not gotWarning() + + +def test_rgbFromString_error(gotWarning): + assert rgbFromString("255,255,256") == (255, 255, 255) + assert gotWarning() \ No newline at end of file diff --git a/uv.lock b/uv.lock index a3d6bfb..edc9406 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "audio-visualizer-python" -version = "2.2.0" +version = "2.2.1" source = { editable = "." } dependencies = [ { name = "numpy" },