From b6b45d12702f18f041acf65b0d5e34714835ecb4 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 30 Jul 2017 13:04:02 -0400 Subject: [PATCH] added Spectrum component with many options tweaked Waveform, added some ffmpeg logging, made generic widget functions --- src/component.py | 54 ++-- src/components/spectrum.py | 239 +++++++++++++++ src/components/spectrum.ui | 582 +++++++++++++++++++++++++++++++++++++ src/components/waveform.py | 48 ++- src/components/waveform.ui | 21 +- src/mainwindow.py | 2 +- src/toolkit/common.py | 43 +++ src/toolkit/ffmpeg.py | 41 ++- 8 files changed, 959 insertions(+), 71 deletions(-) create mode 100644 src/components/spectrum.py create mode 100644 src/components/spectrum.ui diff --git a/src/component.py b/src/component.py index 6d49406..1a5a5a4 100644 --- a/src/component.py +++ b/src/component.py @@ -4,9 +4,11 @@ ''' from PyQt5 import uic, QtCore, QtWidgets import os +import sys import time from toolkit.frame import BlankFrame +from toolkit import getWidgetValue, setWidgetValue, connectWidget class ComponentMetaclass(type(QtCore.QObject)): @@ -273,14 +275,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): widgets['spinBox'].extend( self.page.findChildren(QtWidgets.QDoubleSpinBox) ) - for widget in widgets['lineEdit']: - widget.textChanged.connect(self.update) - for widget in widgets['checkBox']: - widget.stateChanged.connect(self.update) - for widget in widgets['spinBox']: - widget.valueChanged.connect(self.update) - for widget in widgets['comboBox']: - widget.currentIndexChanged.connect(self.update) + for widgetList in widgets.values(): + for widget in widgetList: + connectWidget(widget, self.update) def update(self): ''' @@ -289,15 +286,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): Call super() at the END if you need to subclass this. ''' for attr, widget in self._trackedWidgets.items(): - if type(widget) == QtWidgets.QLineEdit: - setattr(self, attr, widget.text()) - elif type(widget) == QtWidgets.QSpinBox \ - or type(widget) == QtWidgets.QDoubleSpinBox: - setattr(self, attr, widget.value()) - elif type(widget) == QtWidgets.QCheckBox: - setattr(self, attr, widget.isChecked()) - elif type(widget) == QtWidgets.QComboBox: - setattr(self, attr, widget.currentIndex()) + setattr(self, attr, getWidgetValue(widget)) if not self.core.openingProject: self.parent.drawPreview() saveValueStore = self.savePreset() @@ -313,19 +302,10 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.currentPreset = presetName \ if presetName is not None else presetDict['preset'] for attr, widget in self._trackedWidgets.items(): - val = presetDict[ - attr if attr not in self._presetNames + key = attr if attr not in self._presetNames \ else self._presetNames[attr] - ] - if type(widget) == QtWidgets.QLineEdit: - widget.setText(val) - elif type(widget) == QtWidgets.QSpinBox \ - or type(widget) == QtWidgets.QDoubleSpinBox: - widget.setValue(val) - elif type(widget) == QtWidgets.QCheckBox: - widget.setChecked(val) - elif type(widget) == QtWidgets.QComboBox: - widget.setCurrentIndex(val) + val = presetDict[key] + setWidgetValue(widget, val) def savePreset(self): saveValueStore = {} @@ -420,24 +400,30 @@ class ComponentError(RuntimeError): prevErrors = [] lastTime = time.time() - def __init__(self, caller, name): - print('##### ComponentError by %s: %s' % (caller.name, name)) + def __init__(self, caller, name, msg=None): + if msg is None and sys.exc_info()[0] is not None: + msg = str(sys.exc_info()[1]) + else: + msg = 'Unknown error.' + print("##### ComponentError by %s's %s: %s" % ( + caller.name, name, msg)) + + # Don't create multiple windows for quickly repeated messages if len(ComponentError.prevErrors) > 1: ComponentError.prevErrors.pop() ComponentError.prevErrors.insert(0, name) curTime = time.time() if name in ComponentError.prevErrors[1:] \ and curTime - ComponentError.lastTime < 1.0: - # Don't create multiple windows for quickly repeated messages return ComponentError.lastTime = time.time() from toolkit import formatTraceback - import sys if sys.exc_info()[0] is not None: string = ( - "%s component's %s encountered %s %s: %s" % ( + "%s component (#%s): %s encountered %s %s: %s" % ( caller.__class__.name, + str(caller.compPos), name, 'an' if any([ sys.exc_info()[0].__name__.startswith(vowel) diff --git a/src/components/spectrum.py b/src/components/spectrum.py new file mode 100644 index 0000000..261d9cc --- /dev/null +++ b/src/components/spectrum.py @@ -0,0 +1,239 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import math +import subprocess +import time + +from component import Component +from toolkit.frame import BlankFrame, scale +from toolkit import checkOutput, rgbFromString, pickColor, connectWidget +from toolkit.ffmpeg import ( + openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound +) + + +class Component(Component): + name = 'Spectrum' + version = '1.0.0' + + def widget(self, *args): + self.color = (255, 255, 255) + self.previewFrame = None + super().widget(*args) + self.chunkSize = 4 * self.width * self.height + self.changedOptions = True + + if hasattr(self.parent, 'window'): + # update preview when audio file changes (if genericPreview is off) + self.parent.window.lineEdit_audioFile.textChanged.connect( + self.update + ) + + self.trackWidgets( + { + 'filterType': self.page.comboBox_filterType, + 'window': self.page.comboBox_window, + 'amplitude': self.page.comboBox_amplitude, + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'mirror': self.page.checkBox_mirror, + 'scale': self.page.spinBox_scale, + 'color': self.page.comboBox_color, + 'compress': self.page.checkBox_compress, + 'mono': self.page.checkBox_mono, + } + ) + for widget in self._trackedWidgets.values(): + connectWidget(widget, lambda: self.changed()) + + def changed(self): + self.changedOptions = True + + def update(self): + count = self.page.stackedWidget.count() + i = self.page.comboBox_filterType.currentIndex() + self.page.stackedWidget.setCurrentIndex(i if i < count else count - 1) + super().update() + + def previewRender(self): + changedSize = self.updateChunksize() + if not changedSize \ + and not self.changedOptions \ + and self.previewFrame is not None: + return self.previewFrame + + frame = self.getPreviewFrame() + self.changedOptions = False + if not frame: + self.previewFrame = None + return BlankFrame(self.width, self.height) + else: + self.previewFrame = frame + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.updateChunksize() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, height=h, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, component=self, + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.window.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', self.settings.value("outputFrameRate"), + '-ss', "{0:.3f}".format(startPt), + '-i', + os.path.join(self.core.wd, 'background.png') + if genericPreview else inputFile, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend([ + '-an', + '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), + '-codec:v', 'rawvideo', '-', + '-frames:v', '1', + ]) + logFilename = os.path.join( + self.core.dataDir, 'preview_%s.log' % str(self.compPos)) + with open(logFilename, 'w') as log: + log.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as log: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=log, bufsize=10**8 + ) + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + w, h = scale(self.scale, self.width, self.height, str) + if self.amplitude == 0: + amplitude = 'sqrt' + elif self.amplitude == 1: + amplitude = 'cbrt' + elif self.amplitude == 2: + amplitude = '4thrt' + elif self.amplitude == 3: + amplitude = '5thrt' + elif self.amplitude == 4: + amplitude = 'lin' + elif self.amplitude == 5: + amplitude = 'log' + color = self.page.comboBox_color.currentText().lower() + genericPreview = self.settings.value("pref_genericPreview") + + if self.filterType == 0: # Spectrum + filter_ = ( + 'showspectrum=s=%sx%s:slide=scroll:win_func=%s:' + 'color=%s:scale=%s' % ( + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + self.page.comboBox_window.currentText(), + color, amplitude, + ) + ) + elif self.filterType == 1: # Histogram + filter_ = ( + 'ahistogram=r=%s:s=%sx%s:dmode=separate' % ( + self.settings.value("outputFrameRate"), + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + ) + ) + elif self.filterType == 2: # Vector Scope + filter_ = ( + 'avectorscope=s=%sx%s:draw=line:m=polar:scale=log' % ( + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + ) + ) + elif self.filterType == 3: # Musical Scale + filter_ = ( + 'showcqt=r=%s:s=%sx%s:count=30:text=0' % ( + self.settings.value("outputFrameRate"), + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + ) + ) + elif self.filterType == 4: # Phase + filter_ = ( + 'aphasemeter=r=%s:s=%sx%s:mpc=white:video=1[atrash][vtmp]; ' + '[atrash] anullsink; [vtmp] null' % ( + self.settings.value("outputFrameRate"), + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + ) + ) + + return [ + '-filter_complex', + '%s%s%s%s%s [v1]; ' + '[v1] scale=%s:%s%s [v]' % ( + exampleSound() if preview and genericPreview else '[0:a] ', + 'compand=gain=4,' if self.compress else '', + 'aformat=channel_layouts=mono,' if self.mono else '', + filter_, + ', hflip' if self.mirror else'', + w, h, + ', trim=start=%s:end=%s' % ( + "{0:.3f}".format(startPt + 15), + "{0:.3f}".format(startPt + 15.5) + ) if preview else '', + ), + '-map', '[v]', + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + oldChunkSize = int(self.chunkSize) + self.chunkSize = 4 * width * height + changed = self.chunkSize != oldChunkSize + return changed + + def finalizeFrame(self, imageData): + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData + ) + if self.scale != 100 \ + or self.x != 0 or self.y != 0: + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + else: + frame = image + return frame diff --git a/src/components/spectrum.ui b/src/components/spectrum.ui new file mode 100644 index 0000000..59ca0b8 --- /dev/null +++ b/src/components/spectrum.ui @@ -0,0 +1,582 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + + 0 + 0 + + + + + 0 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + Type + + + + + + + + Spectrum + + + + + Histogram + + + + + Vector Scope + + + + + Musical Scale + + + + + Phase + + + + + + + + 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 + + + + + + + + + + + Compress + + + + + + + Mono + + + + + + + Mirror + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + + 0 + 0 + + + + false + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + + + 0 + 0 + 561 + 72 + + + + + QLayout::SetMaximumSize + + + 0 + + + + + QLayout::SetDefaultConstraint + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Window + + + 4 + + + + + + + + hann + + + + + gauss + + + + + tukey + + + + + dolph + + + + + cauchy + + + + + parzen + + + + + poisson + + + + + rect + + + + + bartlett + + + + + hanning + + + + + hamming + + + + + blackman + + + + + welch + + + + + flattop + + + + + bharris + + + + + bnuttall + + + + + lanczos + + + + + + + + + 0 + 0 + + + + Amplitude + + + 4 + + + + + + + + Square root + + + + + Cubic root + + + + + 4thrt + + + + + 5thrt + + + + + Linear + + + + + Logarithmic + + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 10 + 20 + + + + + + + + + + + + + 0 + 0 + + + + Color + + + 4 + + + + + + + + Channel + + + + + Intensity + + + + + Rainbow + + + + + Moreland + + + + + Nebulae + + + + + Fire + + + + + Fiery + + + + + Fruit + + + + + Cool + + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 10 + 20 + + + + + + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 10 + + + + + + + + + diff --git a/src/components/waveform.py b/src/components/waveform.py index b4b19e9..6c5133d 100644 --- a/src/components/waveform.py +++ b/src/components/waveform.py @@ -8,7 +8,9 @@ import subprocess from component import Component from toolkit.frame import BlankFrame, scale from toolkit import checkOutput, rgbFromString, pickColor -from toolkit.ffmpeg import openPipe, closePipe, getAudioDuration, FfmpegVideo +from toolkit.ffmpeg import ( + openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound +) class Component(Component): @@ -112,6 +114,8 @@ class Component(Component): if not duration: return startPt = duration / 3 + if startPt + 3 > duration: + startPt += startPt - 3 command = [ self.core.FFMPEG_BIN, @@ -154,29 +158,43 @@ class Component(Component): hexcolor = QColor(*self.color).name() opacity = "{0:.1f}".format(self.opacity / 100) genericPreview = self.settings.value("pref_genericPreview") + if self.mode < 3: + filter_ = 'showwaves=r=%s:s=%sx%s:mode=%s:colors=%s@%s:scale=%s' % ( + self.settings.value("outputFrameRate"), + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + self.page.comboBox_mode.currentText().lower() + if self.mode != 3 else 'p2p', + hexcolor, opacity, amplitude, + ) + elif self.mode > 2: + filter_ = ( + 'showfreqs=s=%sx%s:mode=%s:colors=%s@%s' + ':ascale=%s:fscale=%s' % ( + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + 'line' if self.mode == 4 else 'bar', + hexcolor, opacity, amplitude, + 'log' if self.mono else 'lin' + ) + ) return [ '-filter_complex', '%s%s%s' - 'showwaves=r=30:s=%sx%s:mode=%s:colors=%s@%s:scale=%s%s%s [v1]; ' - '[v1] scale=%s:%s%s,setpts=2.0*PTS [v]' % ( - 'aevalsrc=sin(1*2*PI*t)*sin(880*2*PI*t),' - if preview and genericPreview else '[0:a] ', - 'compand=.3|.3:1|1:-90/-60|-60/-40|-40/-30|-20/-20:6:0:-90:0.2' - ',' if self.compress and not preview else ( - 'compand=gain=5,' if self.compress else '' - ), - 'aformat=channel_layouts=mono,' if self.mono else '', - self.settings.value("outputWidth"), - self.settings.value("outputHeight"), - str(self.page.comboBox_mode.currentText()).lower(), - hexcolor, opacity, amplitude, + '%s%s%s [v1]; ' + '[v1] scale=%s:%s%s [v]' % ( + exampleSound() if preview and genericPreview else '[0:a] ', + 'compand=gain=4,' if self.compress else '', + 'aformat=channel_layouts=mono,' + if self.mono and self.mode < 3 else '', + filter_, ', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=4:color=%s@%s' % ( hexcolor, opacity ) if self.mode < 2 else '', ', hflip' if self.mirror else'', w, h, - ', trim=duration=%s' % "{0:.3f}".format(startPt + 1) + ', trim=duration=%s' % "{0:.3f}".format(startPt + 3) if preview else '', ), '-map', '[v]', diff --git a/src/components/waveform.ui b/src/components/waveform.ui index 0e40380..5473f33 100644 --- a/src/components/waveform.ui +++ b/src/components/waveform.ui @@ -66,12 +66,17 @@ - P2p + Point - Point + Frequency Bar + + + + + Frequency Line @@ -180,12 +185,16 @@ - Wave Color + Color - + + + Qt::ImhNone + + @@ -244,10 +253,10 @@ % - 10 + 0 - 400 + 100 100 diff --git a/src/mainwindow.py b/src/mainwindow.py index a97081e..d9e95e2 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -581,7 +581,7 @@ class MainWindow(QtWidgets.QMainWindow): self.showMessage( msg=msg, detail=detail, - icon='Warning', + icon='Critical', ) def changeEncodingStatus(self, status): diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 5d424e0..db278c0 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -113,3 +113,46 @@ def formatTraceback(tb=None): import sys tb = sys.exc_info()[2] return 'Traceback:\n%s' % "\n".join(traceback.format_tb(tb)) + + +def connectWidget(widget, func): + if type(widget) == QtWidgets.QLineEdit: + widget.textChanged.connect(func) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.valueChanged.connect(func) + elif type(widget) == QtWidgets.QCheckBox: + widget.stateChanged.connect(func) + elif type(widget) == QtWidgets.QComboBox: + widget.currentIndexChanged.connect(func) + else: + return False + return True + + +def setWidgetValue(widget, val): + '''Generic setValue method for use with any typical QtWidget''' + if type(widget) == QtWidgets.QLineEdit: + widget.setText(val) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.setValue(val) + elif type(widget) == QtWidgets.QCheckBox: + widget.setChecked(val) + elif type(widget) == QtWidgets.QComboBox: + widget.setCurrentIndex(val) + else: + return False + return True + + +def getWidgetValue(widget): + if type(widget) == QtWidgets.QLineEdit: + return widget.text() + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + return widget.value() + elif type(widget) == QtWidgets.QCheckBox: + return widget.isChecked() + elif type(widget) == QtWidgets.QComboBox: + return widget.currentIndex() diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 4ea2863..3421049 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -37,7 +37,6 @@ class FfmpegVideo: self.frameNo = -1 self.currentFrame = 'None' self.map_ = None - self.debug = False if 'loopVideo' in kwargs and kwargs['loopVideo']: self.loopValue = '-1' @@ -48,8 +47,6 @@ class FfmpegVideo: kwargs['filter_'].insert(0, '-filter_complex') else: kwargs['filter_'] = None - if 'debug' in kwargs: - self.debug = True self.command = [ core.Core.FFMPEG_BIN, @@ -90,16 +87,15 @@ class FfmpegVideo: self.frameBuffer.task_done() def fillBuffer(self): - if self.debug: - print(" ".join([word for word in self.command])) - err = sys.__stdout__ - else: - err = subprocess.DEVNULL - - self.pipe = openPipe( - self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=err, bufsize=10**8 - ) + logFilename = os.path.join( + core.Core.dataDir, 'extra_%s.log' % str(self.component.compPos)) + with open(logFilename, 'w') as log: + log.write(" ".join(self.command) + '\n\n') + with open(logFilename, 'a') as log: + self.pipe = openPipe( + self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=log, bufsize=10**8 + ) while True: if self.parent.canceled: break @@ -111,10 +107,18 @@ class FfmpegVideo: self.frameBuffer.put((self.frameNo-1, self.lastFrame)) continue except AttributeError: - FfmpegVideo.threadError = ComponentError(self.component, 'video') + FfmpegVideo.threadError = ComponentError( + self.component, 'video', + "Video seemed playable but wasn't." + ) break - self.currentFrame = self.pipe.stdout.read(self.chunkSize) + try: + self.currentFrame = self.pipe.stdout.read(self.chunkSize) + except ValueError: + FfmpegVideo.threadError = ComponentError( + self.component, 'video') + if len(self.currentFrame) != 0: self.frameBuffer.put((self.frameNo, self.currentFrame)) self.lastFrame = self.currentFrame @@ -446,3 +450,10 @@ def readAudioFile(filename, videoWorker): completeAudioArray = completeAudioArrayCopy return (completeAudioArray, duration) + + +def exampleSound(): + return ( + 'aevalsrc=tan(random(1)*PI*t)*sin(random(0)*2*PI*t),' + 'apulsator=offset_l=0.5:offset_r=0.5,' + )