Beispiel #1
0
 def __init__(self, idx, filename, dialog, on_probe_complete,
              on_extract_complete, decimate_fs):
     self.__idx = idx
     self.__filename = filename
     self.__dialog = dialog
     self.__in_progress_icon = None
     self.__stream_duration_micros = []
     self.__on_probe_complete = on_probe_complete
     self.__on_extract_complete = on_extract_complete
     self.__result = None
     self.__status = ExtractStatus.NEW
     self.executor = Executor(self.__filename,
                              self.__dialog.outputDir.text(),
                              decimate_fs=decimate_fs)
     self.executor.progress_handler = self.__handle_ffmpeg_process
     self.actionButton = None
     self.probeButton = None
     self.input = None
     self.audioStreams = None
     self.channelCount = None
     self.lfeChannelIndex = None
     self.ffmpegButton = None
     self.outputFilename = None
     self.ffmpegProgress = None
Beispiel #2
0
class ExtractAudioDialog(QDialog, Ui_extractAudioDialog):
    '''
    Allows user to load a signal, processing it if necessary.
    '''
    def __init__(self,
                 parent,
                 preferences,
                 signal_model,
                 default_signal=None,
                 is_remux=False):
        super(ExtractAudioDialog, self).__init__(parent)
        self.setupUi(self)
        for f in COMPRESS_FORMAT_OPTIONS:
            self.audioFormat.addItem(f)
        self.showProbeButton.setIcon(qta.icon('fa5s.info'))
        self.showRemuxCommand.setIcon(qta.icon('fa5s.info'))
        self.inputFilePicker.setIcon(qta.icon('fa5s.folder-open'))
        self.targetDirPicker.setIcon(qta.icon('fa5s.folder-open'))
        self.calculateGainAdjustment.setIcon(qta.icon('fa5s.sliders-h'))
        self.limitRange.setIcon(qta.icon('fa5s.cut'))
        self.statusBar = QStatusBar()
        self.statusBar.setSizeGripEnabled(False)
        self.boxLayout.addWidget(self.statusBar)
        self.__preferences = preferences
        self.__signal_model = signal_model
        self.__default_signal = default_signal
        self.__executor = None
        self.__sound = None
        self.__extracted = False
        self.__stream_duration_micros = []
        self.__is_remux = is_remux
        if self.__is_remux:
            self.setWindowTitle('Remux Audio')
        self.showRemuxCommand.setVisible(self.__is_remux)
        defaultOutputDir = self.__preferences.get(EXTRACTION_OUTPUT_DIR)
        if os.path.isdir(defaultOutputDir):
            self.targetDir.setText(defaultOutputDir)
        self.__reinit_fields()
        self.filterMapping.itemDoubleClicked.connect(self.show_mapping_dialog)
        self.inputDrop.callback = self.__handle_drop

    def __handle_drop(self, file):
        if file.startswith('file:/'):
            file = url2pathname(urlparse(file).path)
        if os.path.exists(file) and os.path.isfile(file):
            self.inputFile.setText(file)
            self.__probe_file()

    def show_remux_cmd(self):
        ''' Pops the ffmpeg command into a message box '''
        if self.__executor is not None and self.__executor.filter_complex_script_content is not None:
            msg_box = QMessageBox()
            font = QFont()
            font.setFamily("Consolas")
            font.setPointSize(8)
            msg_box.setFont(font)
            msg_box.setText(
                self.__executor.filter_complex_script_content.replace(
                    ';', ';\n'))
            msg_box.setIcon(QMessageBox.Information)
            msg_box.setWindowTitle('Remux Script')
            msg_box.exec()

    def show_mapping_dialog(self, item):
        ''' Shows the edit mapping dialog '''
        if len(self.__signal_model) > 0 or self.__default_signal is not None:
            channel_idx = self.filterMapping.indexFromItem(item).row()
            mapped_filter = self.__executor.channel_to_filter.get(
                channel_idx, None)
            EditMappingDialog(self, channel_idx, self.__signal_model,
                              self.__default_signal, mapped_filter,
                              self.filterMapping.count(),
                              self.map_filter_to_channel).exec()

    def map_filter_to_channel(self, channel_idx, signal):
        ''' updates the mapping of the given signal to the specified channel idx '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            self.__executor.map_filter_to_channel(channel_idx, signal)
            self.__display_command_info()

    def selectFile(self):
        self.__reinit_fields()
        dialog = QFileDialog(parent=self)
        dialog.setFileMode(QFileDialog.ExistingFile)
        dialog.setWindowTitle('Select Audio or Video File')
        if dialog.exec():
            selected = dialog.selectedFiles()
            if len(selected) > 0:
                self.inputFile.setText(selected[0])
                self.__probe_file()

    def __reinit_fields(self):
        '''
        Resets various fields and temporary state.
        '''
        if self.__sound is not None:
            if not self.__sound.isFinished():
                self.__sound.stop()
                self.__sound = None
        self.audioStreams.clear()
        self.videoStreams.clear()
        self.statusBar.clearMessage()
        self.__executor = None
        self.__extracted = False
        self.__stream_duration_micros = []
        self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.Ok).setText(
            'Remux' if self.__is_remux else 'Extract')
        if self.__is_remux:
            self.signalName.setVisible(False)
            self.signalNameLabel.setVisible(False)
            self.filterMapping.setVisible(True)
            self.filterMappingLabel.setVisible(True)
            self.includeOriginalAudio.setVisible(True)
            self.includeSubtitles.setVisible(True)
            self.gainOffset.setVisible(True)
            self.gainOffsetLabel.setVisible(True)
            self.gainOffset.setEnabled(False)
            self.gainOffsetLabel.setEnabled(False)
            self.calculateGainAdjustment.setVisible(True)
            self.calculateGainAdjustment.setEnabled(False)
            self.adjustRemuxedAudio.setVisible(True)
            self.remuxedAudioOffset.setVisible(True)
            self.adjustRemuxedAudio.setEnabled(False)
            self.remuxedAudioOffset.setEnabled(False)
        else:
            self.signalName.setText('')
            self.filterMapping.setVisible(False)
            self.filterMappingLabel.setVisible(False)
            self.includeOriginalAudio.setVisible(False)
            self.includeSubtitles.setVisible(False)
            self.gainOffset.setVisible(False)
            self.gainOffsetLabel.setVisible(False)
            self.calculateGainAdjustment.setVisible(False)
            self.adjustRemuxedAudio.setVisible(False)
            self.remuxedAudioOffset.setVisible(False)
        self.eacBitRate.setVisible(False)
        self.monoMix.setChecked(self.__preferences.get(EXTRACTION_MIX_MONO))
        self.bassManage.setChecked(False)
        self.decimateAudio.setChecked(
            self.__preferences.get(EXTRACTION_DECIMATE))
        self.includeOriginalAudio.setChecked(
            self.__preferences.get(EXTRACTION_INCLUDE_ORIGINAL))
        self.includeSubtitles.setChecked(
            self.__preferences.get(EXTRACTION_INCLUDE_SUBTITLES))
        if self.__preferences.get(EXTRACTION_COMPRESS):
            self.audioFormat.setCurrentText(COMPRESS_FORMAT_FLAC)
        else:
            self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE)
        self.monoMix.setEnabled(False)
        self.bassManage.setEnabled(False)
        self.decimateAudio.setEnabled(False)
        self.audioFormat.setEnabled(False)
        self.eacBitRate.setEnabled(False)
        self.includeOriginalAudio.setEnabled(False)
        self.includeSubtitles.setEnabled(False)
        self.inputFilePicker.setEnabled(True)
        self.audioStreams.setEnabled(False)
        self.videoStreams.setEnabled(False)
        self.channelCount.setEnabled(False)
        self.lfeChannelIndex.setEnabled(False)
        self.targetDirPicker.setEnabled(True)
        self.outputFilename.setEnabled(False)
        self.showProbeButton.setEnabled(False)
        self.filterMapping.setEnabled(False)
        self.filterMapping.clear()
        self.ffmpegCommandLine.clear()
        self.ffmpegCommandLine.setEnabled(False)
        self.ffmpegOutput.clear()
        self.ffmpegOutput.setEnabled(False)
        self.ffmpegProgress.setEnabled(False)
        self.ffmpegProgressLabel.setEnabled(False)
        self.ffmpegProgress.setValue(0)
        self.rangeFrom.setEnabled(False)
        self.rangeSeparatorLabel.setEnabled(False)
        self.rangeTo.setEnabled(False)
        self.limitRange.setEnabled(False)
        self.signalName.setEnabled(False)
        self.signalNameLabel.setEnabled(False)
        self.showRemuxCommand.setEnabled(False)

    def __probe_file(self):
        '''
        Probes the specified file using ffprobe in order to discover the audio streams.
        '''
        file_name = self.inputFile.text()
        self.__executor = Executor(
            file_name,
            self.targetDir.text(),
            mono_mix=self.monoMix.isChecked(),
            decimate_audio=self.decimateAudio.isChecked(),
            audio_format=self.audioFormat.currentText(),
            audio_bitrate=self.eacBitRate.value(),
            include_original=self.includeOriginalAudio.isChecked(),
            include_subtitles=self.includeSubtitles.isChecked(),
            signal_model=self.__signal_model if self.__is_remux else None,
            decimate_fs=self.__preferences.get(ANALYSIS_TARGET_FS),
            bm_fs=self.__preferences.get(BASS_MANAGEMENT_LPF_FS))
        self.__executor.progress_handler = self.__handle_ffmpeg_process
        from app import wait_cursor
        with wait_cursor(f"Probing {file_name}"):
            self.__executor.probe_file()
            self.showProbeButton.setEnabled(True)
        if self.__executor.has_audio():
            for a in self.__executor.audio_stream_data:
                text, duration_micros = parse_audio_stream(
                    self.__executor.probe, a)
                self.audioStreams.addItem(text)
                self.__stream_duration_micros.append(duration_micros)
            self.videoStreams.addItem('No Video')
            for a in self.__executor.video_stream_data:
                self.videoStreams.addItem(
                    parse_video_stream(self.__executor.probe, a))
            if self.__is_remux and self.videoStreams.count() > 1:
                if self.audioFormat.findText(COMPRESS_FORMAT_EAC3) == -1:
                    self.audioFormat.addItem(COMPRESS_FORMAT_EAC3)
                if self.__preferences.get(EXTRACTION_COMPRESS):
                    self.audioFormat.setCurrentText(COMPRESS_FORMAT_EAC3)
                    self.eacBitRate.setVisible(True)
                else:
                    self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE)
                    self.eacBitRate.setVisible(False)
                self.videoStreams.setCurrentIndex(1)
                self.adjustRemuxedAudio.setEnabled(True)
                self.remuxedAudioOffset.setEnabled(True)
                self.gainOffsetLabel.setEnabled(True)
                self.calculateGainAdjustment.setEnabled(True)
            self.audioStreams.setEnabled(True)
            self.videoStreams.setEnabled(True)
            self.channelCount.setEnabled(True)
            self.lfeChannelIndex.setEnabled(True)
            self.monoMix.setEnabled(True)
            self.bassManage.setEnabled(True)
            self.decimateAudio.setEnabled(True)
            self.audioFormat.setEnabled(True)
            self.eacBitRate.setEnabled(True)
            self.includeOriginalAudio.setEnabled(True)
            self.outputFilename.setEnabled(True)
            self.ffmpegCommandLine.setEnabled(True)
            self.filterMapping.setEnabled(True)
            self.limitRange.setEnabled(True)
            self.showRemuxCommand.setEnabled(True)
            self.__fit_options_to_selected()
        else:
            self.statusBar.showMessage(
                f"{file_name} contains no audio streams!")

    def onVideoStreamChange(self, idx):
        if idx == 0:
            eac_idx = self.audioFormat.findText(COMPRESS_FORMAT_EAC3)
            if eac_idx > -1:
                self.audioFormat.removeItem(eac_idx)
            if self.__preferences.get(EXTRACTION_COMPRESS):
                self.audioFormat.setCurrentText(COMPRESS_FORMAT_FLAC)
            else:
                self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE)
        else:
            if self.audioFormat.findText(COMPRESS_FORMAT_EAC3) == -1:
                self.audioFormat.addItem(COMPRESS_FORMAT_EAC3)
            if self.__preferences.get(EXTRACTION_COMPRESS):
                self.audioFormat.setCurrentText(COMPRESS_FORMAT_EAC3)
            else:
                self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE)
        self.updateFfmpegSpec()

    def updateFfmpegSpec(self):
        '''
        Creates a new ffmpeg command for the specified channel layout.
        '''
        if self.__executor is not None:
            self.__executor.update_spec(self.audioStreams.currentIndex(),
                                        self.videoStreams.currentIndex() - 1,
                                        self.monoMix.isChecked())

            self.__init_channel_count_fields(self.__executor.channel_count,
                                             lfe_index=self.__executor.lfe_idx)
            self.__fit_options_to_selected()
            self.__display_command_info()
            self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True)

    def __fit_options_to_selected(self):
        # if we have no video then the output cannot contain multiple streams
        if self.videoStreams.currentIndex() == 0:
            self.includeOriginalAudio.setChecked(False)
            self.includeOriginalAudio.setEnabled(False)
            self.includeSubtitles.setChecked(False)
            self.includeSubtitles.setEnabled(False)
        else:
            self.includeOriginalAudio.setEnabled(True)
            self.includeSubtitles.setEnabled(True)
        # don't allow mono mix option if the stream is mono
        if self.channelCount.value() == 1:
            self.monoMix.setChecked(False)
            self.monoMix.setEnabled(False)
            self.bassManage.setChecked(False)
            self.bassManage.setEnabled(False)
        else:
            self.monoMix.setEnabled(True)
            # only allow bass management if we have an LFE channel
            if self.__executor.lfe_idx == 0:
                self.bassManage.setChecked(False)
                self.bassManage.setEnabled(False)
            else:
                self.bassManage.setEnabled(True)

    def __display_command_info(self):
        self.outputFilename.setText(self.__executor.output_file_name)
        self.ffmpegCommandLine.setPlainText(self.__executor.ffmpeg_cli)
        self.filterMapping.clear()
        for channel_idx, signal in self.__executor.channel_to_filter.items():
            self.filterMapping.addItem(
                f"Channel {channel_idx + 1} -> {signal.name if signal else 'Passthrough'}"
            )

    def updateOutputFilename(self):
        '''
        Updates the output file name.
        '''
        if self.__executor is not None:
            self.__executor.output_file_name = self.outputFilename.text()
            self.__display_command_info()

    def overrideFfmpegSpec(self, _):
        if self.__executor is not None:
            self.__executor.override('custom', self.channelCount.value(),
                                     self.lfeChannelIndex.value())
            self.__fit_options_to_selected()
            self.__display_command_info()

    def toggle_decimate_audio(self):
        '''
        Reacts to the change in decimation.
        '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            self.__executor.decimate_audio = self.decimateAudio.isChecked()
            self.__display_command_info()

    def toggle_bass_manage(self):
        '''
        Reacts to the change in bass management.
        '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            self.__executor.bass_manage = self.bassManage.isChecked()
            self.__display_command_info()

    def change_audio_format(self, audio_format):
        '''
        Reacts to the change in audio format.
        '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            self.__executor.audio_format = audio_format
            if audio_format == COMPRESS_FORMAT_EAC3:
                self.eacBitRate.setVisible(True)
                self.__executor.audio_bitrate = self.eacBitRate.value()
            else:
                self.eacBitRate.setVisible(False)
            self.__display_command_info()

    def change_audio_bitrate(self, bitrate):
        ''' Allows the bitrate to be updated '''
        if self.__executor is not None:
            self.__executor.audio_bitrate = bitrate
            self.__display_command_info()

    def update_original_audio(self):
        '''
        Reacts to the change in original audio selection.
        '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            if self.includeOriginalAudio.isChecked():
                self.__executor.include_original_audio = True
                self.__executor.original_audio_offset = self.gainOffset.value()
                self.gainOffset.setEnabled(True)
                self.gainOffsetLabel.setEnabled(True)
            else:
                self.__executor.include_original_audio = False
                self.__executor.original_audio_offset = 0.0
                self.gainOffset.setEnabled(False)
                self.gainOffsetLabel.setEnabled(False)
            self.__display_command_info()

    def toggle_include_subtitles(self):
        '''
        Reacts to the change in subtitles selection.
        '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            self.__executor.include_subtitles = self.includeSubtitles.isChecked(
            )
            self.__display_command_info()

    def toggleMonoMix(self):
        '''
        Reacts to the change in mono vs multichannel target.
        '''
        if self.audioStreams.count() > 0 and self.__executor is not None:
            self.__executor.mono_mix = self.monoMix.isChecked()
            self.__display_command_info()

    def toggle_range(self):
        ''' toggles whether the range is enabled or not '''
        if self.limitRange.isChecked():
            self.limitRange.setText('Cut')
            if self.audioStreams.count() > 0:
                duration_ms = int(self.__stream_duration_micros[
                    self.audioStreams.currentIndex()] / 1000)
                if duration_ms > 1:
                    from model.report import block_signals
                    with block_signals(self.rangeFrom):
                        self.rangeFrom.setTimeRange(
                            QTime.fromMSecsSinceStartOfDay(0),
                            QTime.fromMSecsSinceStartOfDay(duration_ms - 1))
                        self.rangeFrom.setTime(
                            QTime.fromMSecsSinceStartOfDay(0))
                        self.rangeFrom.setEnabled(True)
                    self.rangeSeparatorLabel.setEnabled(True)
                    with block_signals(self.rangeTo):
                        self.rangeTo.setEnabled(True)
                        self.rangeTo.setTimeRange(
                            QTime.fromMSecsSinceStartOfDay(1),
                            QTime.fromMSecsSinceStartOfDay(duration_ms))
                        self.rangeTo.setTime(
                            QTime.fromMSecsSinceStartOfDay(duration_ms))
        else:
            self.limitRange.setText('Enable')
            self.rangeFrom.setEnabled(False)
            self.rangeSeparatorLabel.setEnabled(False)
            self.rangeTo.setEnabled(False)
            if self.__executor is not None:
                self.__executor.start_time_ms = 0
                self.__executor.end_time_ms = 0

    def update_start_time(self, time):
        ''' Reacts to start time changes '''
        self.__executor.start_time_ms = time.msecsSinceStartOfDay()
        self.__display_command_info()

    def update_end_time(self, time):
        ''' Reacts to end time changes '''
        msecs = time.msecsSinceStartOfDay()
        duration_ms = int(
            self.__stream_duration_micros[self.audioStreams.currentIndex()] /
            1000)
        self.__executor.end_time_ms = msecs if msecs != duration_ms else 0
        self.__display_command_info()

    def __init_channel_count_fields(self, channels, lfe_index=0):
        from model.report import block_signals
        with block_signals(self.lfeChannelIndex):
            self.lfeChannelIndex.setMaximum(channels)
            self.lfeChannelIndex.setValue(lfe_index)
        with block_signals(self.channelCount):
            self.channelCount.setMaximum(channels)
            self.channelCount.setValue(channels)

    def reject(self):
        '''
        Stops any sound that is playing and exits.
        '''
        if self.__sound is not None and not self.__sound.isFinished():
            self.__sound.stop()
            self.__sound = None
        QDialog.reject(self)

    def accept(self):
        '''
        Executes the ffmpeg command.
        '''
        if self.__extracted is False:
            self.__extract()
            if not self.__is_remux:
                self.signalName.setEnabled(True)
                self.signalNameLabel.setEnabled(True)
        else:
            if self.__create_signals():
                QDialog.accept(self)

    def __create_signals(self):
        '''
        Creates signals from the output file just created.
        :return: True if we created the signals.
        '''
        loader = AutoWavLoader(self.__preferences)
        output_file = self.__executor.get_output_path()
        if os.path.exists(output_file):
            from app import wait_cursor
            with wait_cursor(f"Creating signals for {output_file}"):
                logger.info(f"Creating signals for {output_file}")
                name_provider = lambda channel, channel_count: get_channel_name(
                    self.signalName.text(),
                    channel,
                    channel_count,
                    channel_layout_name=self.__executor.channel_layout_name)
                loader.load(output_file)
                signal = loader.auto_load(name_provider,
                                          self.decimateAudio.isChecked())
                self.__signal_model.add(signal)
            return True
        else:
            msg_box = QMessageBox()
            msg_box.setText(
                f"Extracted audio file does not exist at: \n\n {output_file}")
            msg_box.setIcon(QMessageBox.Critical)
            msg_box.setWindowTitle('Unexpected Error')
            msg_box.exec()
            return False

    def __extract(self):
        '''
        Triggers the ffmpeg command.
        '''
        if self.__executor is not None:
            logger.info(
                f"Extracting {self.outputFilename.text()} from {self.inputFile.text()}"
            )
            self.__executor.execute()

    def __handle_ffmpeg_process(self, key, value):
        '''
        Handles progress reports from ffmpeg in order to communicate status via the progress bar. Used as a slot
        connected to a signal emitted by the AudioExtractor.
        :param key: the key.
        :param value: the value.
        '''
        if key == SIGNAL_CONNECTED:
            self.__extract_started()
        elif key == 'out_time_ms':
            out_time_ms = int(value)
            if self.__executor.start_time_ms > 0 and self.__executor.end_time_ms > 0:
                total_micros = (self.__executor.end_time_ms -
                                self.__executor.start_time_ms) * 1000
            elif self.__executor.end_time_ms > 0:
                total_micros = self.__executor.end_time_ms * 1000
            elif self.__executor.start_time_ms > 0:
                total_micros = self.__stream_duration_micros[
                    self.audioStreams.currentIndex()] - (
                        self.__executor.start_time_ms * 1000)
            else:
                total_micros = self.__stream_duration_micros[
                    self.audioStreams.currentIndex()]
            logger.debug(
                f"{self.inputFile.text()} -- {key}={value} vs {total_micros}")
            if total_micros > 0:
                progress = (out_time_ms / total_micros) * 100.0
                self.ffmpegProgress.setValue(math.ceil(progress))
                self.ffmpegProgress.setTextVisible(True)
                self.ffmpegProgress.setFormat(f"{round(progress, 2):.2f}%")
        elif key == SIGNAL_ERROR:
            self.__extract_complete(value, False)
        elif key == SIGNAL_COMPLETE:
            self.__extract_complete(value, True)

    def __extract_started(self):
        '''
        Changes the UI to signal that extraction has started
        '''
        self.inputFilePicker.setEnabled(False)
        self.audioStreams.setEnabled(False)
        self.videoStreams.setEnabled(False)
        self.channelCount.setEnabled(False)
        self.lfeChannelIndex.setEnabled(False)
        self.monoMix.setEnabled(False)
        self.bassManage.setEnabled(False)
        self.decimateAudio.setEnabled(False)
        self.audioFormat.setEnabled(False)
        self.eacBitRate.setEnabled(False)
        self.includeOriginalAudio.setEnabled(False)
        self.includeSubtitles.setEnabled(False)
        self.targetDirPicker.setEnabled(False)
        self.outputFilename.setEnabled(False)
        self.filterMapping.setEnabled(False)
        self.gainOffset.setEnabled(False)
        self.ffmpegOutput.setEnabled(True)
        self.ffmpegProgress.setEnabled(True)
        self.ffmpegProgressLabel.setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
        palette = QPalette(self.ffmpegProgress.palette())
        palette.setColor(QPalette.Highlight, QColor(Qt.green))
        self.ffmpegProgress.setPalette(palette)

    def __extract_complete(self, result, success):
        '''
        triggered when the extraction thread completes.
        '''
        if self.__executor is not None:
            if success:
                logger.info(
                    f"Extraction complete for {self.outputFilename.text()}")
                self.ffmpegProgress.setValue(100)
                self.__extracted = True
                if not self.__is_remux:
                    self.signalName.setEnabled(True)
                    self.signalNameLabel.setEnabled(True)
                    self.signalName.setText(
                        Path(self.outputFilename.text()).resolve().stem)
                    self.buttonBox.button(
                        QDialogButtonBox.Ok).setText('Create Signals')
            else:
                logger.error(
                    f"Extraction failed for {self.outputFilename.text()}")
                palette = QPalette(self.ffmpegProgress.palette())
                palette.setColor(QPalette.Highlight, QColor(Qt.red))
                self.ffmpegProgress.setPalette(palette)
                self.statusBar.showMessage('Extraction failed', 5000)

            self.ffmpegOutput.setPlainText(result)
            self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True)
            audio = self.__preferences.get(EXTRACTION_NOTIFICATION_SOUND)
            if audio is not None:
                logger.debug(f"Playing {audio}")
                self.__sound = QSound(audio)
                self.__sound.play()

    def showProbeInDetail(self):
        '''
        shows a tree widget containing the contents of the probe to allow the raw probe info to be visible.
        '''
        if self.__executor is not None:
            ViewProbeDialog(self.inputFile.text(),
                            self.__executor.probe,
                            parent=self).exec()

    def setTargetDirectory(self):
        '''
        Sets the target directory based on the user selection.
        '''
        dialog = QFileDialog(parent=self)
        dialog.setFileMode(QFileDialog.DirectoryOnly)
        dialog.setWindowTitle(f"Select Output Directory")
        if dialog.exec():
            selected = dialog.selectedFiles()
            if len(selected) > 0:
                self.targetDir.setText(selected[0])
                if self.__executor is not None:
                    self.__executor.target_dir = selected[0]
                    self.__display_command_info()

    def override_filtered_gain_adjustment(self, val):
        ''' forces the gain adjustment to a specific value. '''
        if self.__executor is not None:
            self.__executor.filtered_audio_offset = val

    def calculate_gain_adjustment(self):
        '''
        Based on the filters applied, calculates the gain adjustment that is required to avoid clipping.
        '''
        filts = list(set(self.__executor.channel_to_filter.values()))
        if len(filts) > 1 or filts[0] is not None:
            from app import wait_cursor
            with wait_cursor():
                headroom = min([
                    min(
                        self.__calc_headroom(
                            x.filter_signal(filt=True, clip=False).samples),
                        0.0) for x in filts if x is not None
                ])
            self.remuxedAudioOffset.setValue(headroom)

    @staticmethod
    def __calc_headroom(samples):
        return 20 * math.log(1.0 / np.nanmax(np.abs(samples)), 10)
Beispiel #3
0
class ExtractCandidate:
    def __init__(self, idx, filename, dialog, on_probe_complete,
                 on_extract_complete, decimate_fs):
        self.__idx = idx
        self.__filename = filename
        self.__dialog = dialog
        self.__in_progress_icon = None
        self.__stream_duration_micros = []
        self.__on_probe_complete = on_probe_complete
        self.__on_extract_complete = on_extract_complete
        self.__result = None
        self.__status = ExtractStatus.NEW
        self.executor = Executor(self.__filename,
                                 self.__dialog.outputDir.text(),
                                 decimate_fs=decimate_fs)
        self.executor.progress_handler = self.__handle_ffmpeg_process
        self.actionButton = None
        self.probeButton = None
        self.input = None
        self.audioStreams = None
        self.channelCount = None
        self.lfeChannelIndex = None
        self.ffmpegButton = None
        self.outputFilename = None
        self.ffmpegProgress = None

    def render(self):
        dialog = self.__dialog
        self.actionButton = QtWidgets.QToolButton(
            dialog.resultsScrollAreaContents)
        self.actionButton.setAttribute(Qt.WA_DeleteOnClose)
        self.actionButton.setObjectName(f"actionButton{self.__idx}")
        self.status = ExtractStatus.NEW
        self.actionButton.clicked.connect(self.toggle)
        dialog.resultsLayout.addWidget(self.actionButton,
                                       self.__idx + 1,
                                       0,
                                       1,
                                       1,
                                       alignment=QtCore.Qt.AlignTop)
        self.input = QtWidgets.QLineEdit(dialog.resultsScrollAreaContents)
        self.input.setAttribute(Qt.WA_DeleteOnClose)
        self.input.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft
                                | QtCore.Qt.AlignTop)
        self.input.setObjectName(f"input{self.__idx}")
        self.input.setText(self.__filename)
        self.input.setCursorPosition(0)
        self.input.setReadOnly(True)
        dialog.resultsLayout.addWidget(self.input, self.__idx + 1, 1, 1, 1)
        self.probeButton = QtWidgets.QToolButton(
            dialog.resultsScrollAreaContents)
        self.probeButton.setAttribute(Qt.WA_DeleteOnClose)
        # self.probeButton.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
        self.probeButton.setObjectName(f"probeButton{self.__idx}")
        self.probeButton.setIcon(qta.icon('fa5s.info'))
        self.probeButton.setEnabled(False)
        self.probeButton.clicked.connect(self.show_probe_detail)
        dialog.resultsLayout.addWidget(self.probeButton, self.__idx + 1, 2, 1,
                                       1)
        self.audioStreams = QtWidgets.QComboBox(
            dialog.resultsScrollAreaContents)
        self.audioStreams.setAttribute(Qt.WA_DeleteOnClose)
        # self.audioStreams.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
        self.audioStreams.setObjectName(f"streams{self.__idx}")
        self.audioStreams.setEnabled(False)
        self.audioStreams.currentIndexChanged['int'].connect(
            self.recalc_ffmpeg_cmd)
        dialog.resultsLayout.addWidget(self.audioStreams, self.__idx + 1, 3, 1,
                                       1)
        self.channelCount = QtWidgets.QSpinBox(
            dialog.resultsScrollAreaContents)
        self.channelCount.setAttribute(Qt.WA_DeleteOnClose)
        self.channelCount.setAlignment(QtCore.Qt.AlignLeading
                                       | QtCore.Qt.AlignLeft
                                       | QtCore.Qt.AlignTop)
        self.channelCount.setObjectName(f"channels{self.__idx}")
        self.channelCount.setEnabled(False)
        self.channelCount.valueChanged['int'].connect(self.override_ffmpeg_cmd)
        dialog.resultsLayout.addWidget(self.channelCount, self.__idx + 1, 4, 1,
                                       1)
        self.lfeChannelIndex = QtWidgets.QSpinBox(
            dialog.resultsScrollAreaContents)
        self.lfeChannelIndex.setAttribute(Qt.WA_DeleteOnClose)
        self.lfeChannelIndex.setAlignment(QtCore.Qt.AlignLeading
                                          | QtCore.Qt.AlignLeft
                                          | QtCore.Qt.AlignTop)
        self.lfeChannelIndex.setObjectName(f"lfeChannel{self.__idx}")
        self.lfeChannelIndex.setEnabled(False)
        self.lfeChannelIndex.valueChanged['int'].connect(
            self.override_ffmpeg_cmd)
        dialog.resultsLayout.addWidget(self.lfeChannelIndex, self.__idx + 1, 5,
                                       1, 1)
        self.outputFilename = QtWidgets.QLineEdit(
            dialog.resultsScrollAreaContents)
        self.outputFilename.setAttribute(Qt.WA_DeleteOnClose)
        self.outputFilename.setAlignment(QtCore.Qt.AlignLeading
                                         | QtCore.Qt.AlignLeft
                                         | QtCore.Qt.AlignTop)
        self.outputFilename.setObjectName(f"output{self.__idx}")
        self.outputFilename.setEnabled(False)
        self.outputFilename.textChanged.connect(self.override_output_filename)
        dialog.resultsLayout.addWidget(self.outputFilename, self.__idx + 1, 6,
                                       1, 1)
        self.ffmpegButton = QtWidgets.QToolButton(
            dialog.resultsScrollAreaContents)
        self.ffmpegButton.setAttribute(Qt.WA_DeleteOnClose)
        # self.ffmpegButton.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
        self.ffmpegButton.setObjectName(f"ffmpegButton{self.__idx}")
        self.ffmpegButton.setIcon(qta.icon('fa5s.info'))
        self.ffmpegButton.setEnabled(False)
        self.ffmpegButton.clicked.connect(self.show_ffmpeg_cmd)
        dialog.resultsLayout.addWidget(self.ffmpegButton, self.__idx + 1, 7, 1,
                                       1)
        self.ffmpegProgress = QtWidgets.QProgressBar(
            dialog.resultsScrollAreaContents)
        self.ffmpegProgress.setAttribute(Qt.WA_DeleteOnClose)
        self.ffmpegProgress.setAlignment(QtCore.Qt.AlignLeading
                                         | QtCore.Qt.AlignLeft
                                         | QtCore.Qt.AlignTop)
        self.ffmpegProgress.setProperty(f"value", 0)
        self.ffmpegProgress.setObjectName(f"progress{self.__idx}")
        self.ffmpegProgress.setEnabled(False)
        dialog.resultsLayout.addWidget(self.ffmpegProgress, self.__idx + 1, 8,
                                       1, 1)

    def remove(self):
        logger.debug(f"Closing widgets for {self.executor.file}")
        self.actionButton.close()
        self.probeButton.close()
        self.input.close()
        self.audioStreams.close()
        self.channelCount.close()
        self.lfeChannelIndex.close()
        self.ffmpegButton.close()
        self.outputFilename.close()
        self.ffmpegProgress.close()
        logger.debug(f"Closed widgets for {self.executor.file}")

    @property
    def status(self):
        return self.__status

    @status.setter
    def status(self, status):
        self.__status = status
        do_stop = False
        if status == ExtractStatus.NEW:
            self.actionButton.setIcon(qta.icon('fa5s.check', color='green'))
        elif status == ExtractStatus.IN_PROGRESS:
            self.actionButton.blockSignals(True)
            self.__in_progress_icon = StoppableSpin(self.actionButton,
                                                    self.__filename)
            self.actionButton.setIcon(
                qta.icon('fa5s.spinner',
                         color='blue',
                         animation=self.__in_progress_icon))
            self.probeButton.setEnabled(False)
            self.input.setEnabled(False)
            self.audioStreams.setEnabled(False)
            self.channelCount.setEnabled(False)
            self.lfeChannelIndex.setEnabled(False)
            self.outputFilename.setEnabled(False)
        elif status == ExtractStatus.EXCLUDED:
            self.actionButton.setIcon(qta.icon('fa5s.times', color='green'))
        elif status == ExtractStatus.PROBED:
            self.actionButton.blockSignals(False)
            self.actionButton.setIcon(qta.icon('fa5s.check', color='green'))
            self.probeButton.setEnabled(True)
            self.input.setEnabled(False)
            self.audioStreams.setEnabled(True)
            self.channelCount.setEnabled(True)
            self.lfeChannelIndex.setEnabled(True)
            self.outputFilename.setEnabled(True)
            self.ffmpegButton.setEnabled(True)
            do_stop = True
        elif status == ExtractStatus.FAILED:
            self.actionButton.setIcon(
                qta.icon('fa5s.exclamation-triangle', color='red'))
            self.actionButton.blockSignals(True)
            self.probeButton.setEnabled(False)
            self.input.setEnabled(False)
            self.audioStreams.setEnabled(False)
            self.channelCount.setEnabled(False)
            self.lfeChannelIndex.setEnabled(False)
            self.outputFilename.setEnabled(False)
            self.ffmpegProgress.setEnabled(False)
            do_stop = True
        elif status == ExtractStatus.CANCELLED:
            self.actionButton.blockSignals(True)
            self.actionButton.setIcon(qta.icon('fa5s.ban', color='green'))
            self.probeButton.setEnabled(False)
            self.input.setEnabled(False)
            self.audioStreams.setEnabled(False)
            self.channelCount.setEnabled(False)
            self.lfeChannelIndex.setEnabled(False)
            self.outputFilename.setEnabled(False)
            self.ffmpegProgress.setEnabled(False)
            do_stop = True
        elif status == ExtractStatus.COMPLETE:
            self.actionButton.blockSignals(True)
            self.actionButton.setIcon(qta.icon('fa5s.check', color='green'))
            self.probeButton.setEnabled(False)
            self.input.setEnabled(False)
            self.audioStreams.setEnabled(False)
            self.channelCount.setEnabled(False)
            self.lfeChannelIndex.setEnabled(False)
            self.outputFilename.setEnabled(False)
            self.ffmpegProgress.setEnabled(False)
            do_stop = True

        if do_stop is True:
            stop_spinner(self.__in_progress_icon, self.actionButton)
            self.__in_progress_icon = None

    def toggle(self):
        '''
        toggles whether this candidate should be excluded from the batch.
        '''
        if self.status < ExtractStatus.CANCELLED:
            if self.status == ExtractStatus.EXCLUDED:
                self.executor.enable()
                if self.executor.probe is not None:
                    self.status = ExtractStatus.PROBED
                else:
                    self.status = ExtractStatus.NEW
            else:
                self.status = ExtractStatus.EXCLUDED
                self.executor.cancel()

    def probe(self):
        '''
        Schedules a ProbeJob with the global thread pool.
        '''
        QThreadPool.globalInstance().start(ProbeJob(self))

    def __handle_ffmpeg_process(self, key, value):
        '''
        Handles progress reports from ffmpeg in order to communicate status via the progress bar. Used as a slot
        connected to a signal emitted by the AudioExtractor.
        :param key: the key.
        :param value: the value.
        '''
        if key == SIGNAL_CONNECTED:
            pass
        elif key == SIGNAL_CANCELLED:
            self.status = ExtractStatus.CANCELLED
            self.__result = value
            self.__on_extract_complete(self.__idx)
        elif key == 'out_time_ms':
            if self.status != ExtractStatus.IN_PROGRESS:
                logger.debug(f"Extraction started for {self}")
                self.status = ExtractStatus.IN_PROGRESS
            out_time_ms = int(value)
            total_micros = self.__stream_duration_micros[
                self.audioStreams.currentIndex()]
            # logger.debug(f"{self.input.text()} -- {key}={value} vs {total_micros}")
            if total_micros > 0:
                progress = (out_time_ms / total_micros) * 100.0
                self.ffmpegProgress.setValue(math.ceil(progress))
                self.ffmpegProgress.setTextVisible(True)
                self.ffmpegProgress.setFormat(f"{round(progress, 2):.2f}%")
        elif key == SIGNAL_ERROR:
            self.status = ExtractStatus.FAILED
            self.__result = value
            self.__on_extract_complete(self.__idx)
        elif key == SIGNAL_COMPLETE:
            self.ffmpegProgress.setValue(100)
            self.status = ExtractStatus.COMPLETE
            self.__result = value
            self.__on_extract_complete(self.__idx)

    def extract(self):
        '''
        Triggers the extraction.
        '''
        self.executor.execute()

    def probe_start(self):
        '''
        Updates the UI when the probe starts.
        '''
        self.status = ExtractStatus.IN_PROGRESS

    def probe_failed(self):
        '''
        Updates the UI when an ff cmd fails.
        '''
        self.status = ExtractStatus.FAILED
        self.__on_probe_complete(self.__idx)

    def probe_complete(self):
        '''
        Updates the UI when the probe completes.
        '''
        if self.executor.has_audio():
            self.status = ExtractStatus.PROBED
            for a in self.executor.audio_stream_data:
                text, duration_micros = parse_audio_stream(
                    self.executor.probe, a)
                self.audioStreams.addItem(text)
                self.__stream_duration_micros.append(duration_micros)
            self.audioStreams.setCurrentIndex(0)
            self.__on_probe_complete(self.__idx)
        else:
            self.probe_failed()
            self.actionButton.setToolTip('No audio streams')

    def recalc_ffmpeg_cmd(self):
        '''
        Calculates an ffmpeg cmd for the selected options.
        :return:
        '''
        self.executor.update_spec(self.audioStreams.currentIndex(), -1,
                                  self.__dialog.monoMix.isChecked())
        self.lfeChannelIndex.setMaximum(self.executor.channel_count)
        self.lfeChannelIndex.setValue(self.executor.lfe_idx)
        self.channelCount.setMaximum(self.executor.channel_count)
        self.channelCount.setValue(self.executor.channel_count)

    def override_ffmpeg_cmd(self):
        '''
        overrides the ffmpeg command implied by the stream.
        '''
        self.executor.override('custom', self.channelCount.value(),
                               self.lfeChannelIndex.value())
        self.outputFilename.setText(self.executor.output_file_name)
        self.outputFilename.setCursorPosition(0)

    def override_output_filename(self):
        '''
        overrides the output file name from the default.
        '''
        self.executor.output_file_name = self.outputFilename.text()

    def show_probe_detail(self):
        '''
        shows a tree widget containing the contents of the probe to allow the raw probe info to be visible.
        '''
        ViewProbeDialog(self.__filename,
                        self.executor.probe,
                        parent=self.__dialog).show()

    def show_ffmpeg_cmd(self):
        '''
        Pops up a message box containing the command or the result.
        '''
        msg_box = FFMpegDetailsDialog(self.executor.file, self.__dialog)
        if self.__result is None:
            msg_box.message.setText(f"Command")
            msg_box.details.setPlainText(self.executor.ffmpeg_cli)
        else:
            msg_box.message.setText(f"Result")
            msg_box.details.setPlainText(self.__result)
        msg_box.show()

    def __repr__(self):
        return self.__filename
Beispiel #4
0
 def __probe_file(self):
     '''
     Probes the specified file using ffprobe in order to discover the audio streams.
     '''
     file_name = self.inputFile.text()
     self.__executor = Executor(
         file_name,
         self.targetDir.text(),
         mono_mix=self.monoMix.isChecked(),
         decimate_audio=self.decimateAudio.isChecked(),
         audio_format=self.audioFormat.currentText(),
         audio_bitrate=self.eacBitRate.value(),
         include_original=self.includeOriginalAudio.isChecked(),
         include_subtitles=self.includeSubtitles.isChecked(),
         signal_model=self.__signal_model if self.__is_remux else None,
         decimate_fs=self.__preferences.get(ANALYSIS_TARGET_FS),
         bm_fs=self.__preferences.get(BASS_MANAGEMENT_LPF_FS))
     self.__executor.progress_handler = self.__handle_ffmpeg_process
     from app import wait_cursor
     with wait_cursor(f"Probing {file_name}"):
         self.__executor.probe_file()
         self.showProbeButton.setEnabled(True)
     if self.__executor.has_audio():
         for a in self.__executor.audio_stream_data:
             text, duration_micros = parse_audio_stream(
                 self.__executor.probe, a)
             self.audioStreams.addItem(text)
             self.__stream_duration_micros.append(duration_micros)
         self.videoStreams.addItem('No Video')
         for a in self.__executor.video_stream_data:
             self.videoStreams.addItem(
                 parse_video_stream(self.__executor.probe, a))
         if self.__is_remux and self.videoStreams.count() > 1:
             if self.audioFormat.findText(COMPRESS_FORMAT_EAC3) == -1:
                 self.audioFormat.addItem(COMPRESS_FORMAT_EAC3)
             if self.__preferences.get(EXTRACTION_COMPRESS):
                 self.audioFormat.setCurrentText(COMPRESS_FORMAT_EAC3)
                 self.eacBitRate.setVisible(True)
             else:
                 self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE)
                 self.eacBitRate.setVisible(False)
             self.videoStreams.setCurrentIndex(1)
             self.adjustRemuxedAudio.setEnabled(True)
             self.remuxedAudioOffset.setEnabled(True)
             self.gainOffsetLabel.setEnabled(True)
             self.calculateGainAdjustment.setEnabled(True)
         self.audioStreams.setEnabled(True)
         self.videoStreams.setEnabled(True)
         self.channelCount.setEnabled(True)
         self.lfeChannelIndex.setEnabled(True)
         self.monoMix.setEnabled(True)
         self.bassManage.setEnabled(True)
         self.decimateAudio.setEnabled(True)
         self.audioFormat.setEnabled(True)
         self.eacBitRate.setEnabled(True)
         self.includeOriginalAudio.setEnabled(True)
         self.outputFilename.setEnabled(True)
         self.ffmpegCommandLine.setEnabled(True)
         self.filterMapping.setEnabled(True)
         self.limitRange.setEnabled(True)
         self.showRemuxCommand.setEnabled(True)
         self.__fit_options_to_selected()
     else:
         self.statusBar.showMessage(
             f"{file_name} contains no audio streams!")