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
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)
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
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!")