Przeglądaj źródła

remove bloated utils/script.py by restructuring modules + finish preview chooser gui + can now upload sermons by combining segments

Noah Vogt 1 rok temu
rodzic
commit
c7b5caffc9

+ 3 - 5
README.md

@@ -370,17 +370,15 @@ to switch to the scene with song 4.
 
 These are some issues and possible changes that will be addressed or at least considered by our future development efforts:
 
-- add more documentation, especially explaining the slide generation, but also the `PROMPT_INPUT` and the cd burning scripts
+- add more documentation, especially explaining the slide generation, but also the `PROMPT_INPUT` and the cd burning + sermon uploading scripts
 - add tests
 - use smarter multi slide splitter algorithm: either by pattern recognition like line matching or rhymes of the last word or by incorporating some sort of sub-song-structures in the body.
 - add warnings that indicate potential problems with rclone syncing
 - Use obs websocket connection, cleaner that transition hotkeys. Also to deprecate pyautogui.
 - add (semantic) versioning, maybe even display on program run as text
 - add not-yet-public streaming workflow scripts
-- make sermon uploads for more than one suitable segment possible
-- make combined sermon segments possible
-- make multiplaform ejecting of cd drives possible
-- split up `util/scripts.py` in more source files
+- for sermon segment generating: Check if file duration and type roughly match the target to avoid useless regenerating. Also, parallelization.
+- make multiplatform ejecting of cd drives possible
 
 ## Licensing
 

+ 1 - 1
audio/__init__.py

@@ -19,4 +19,4 @@ from .wave import (
     get_wave_duration_in_frames,
     get_index_line_as_frames,
 )
-from .custom import SermonSegment
+from .custom import SermonSegment, ChosenAudio, AudioSourceFileType

+ 13 - 0
audio/custom.py

@@ -14,6 +14,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from dataclasses import dataclass
+from enum import Enum
 
 
 @dataclass
@@ -22,3 +23,15 @@ class SermonSegment:
     end_frame: int
     source_cue_sheet: str
     source_marker: int
+
+
+@dataclass
+class ChosenAudio:
+    sheet_rel_path: str
+    wave_abs_path: str
+
+
+@dataclass
+class AudioSourceFileType(Enum):
+    WAVE = "wave"
+    CUESHEET = "cuesheet"

+ 1 - 1
audio/wave.py

@@ -29,7 +29,7 @@ def get_wave_duration_in_frames(file_name: str) -> int:
         QMessageBox.critical(
             None,
             "Error",
-            f"Error: Could not get duration of {file_name}",
+            f"Error: Could not get duration of '{file_name}'",
         )
         sys.exit(1)
 

+ 1 - 1
force_song.py

@@ -20,8 +20,8 @@ from sys import argv
 from utils import (
     error_msg,
     make_sure_file_exists,
-    switch_to_song,
 )
+from song_switcher import switch_to_song
 from input import validate_obs_song_scene_switcher_config
 import config as const
 

+ 2 - 2
input/__init__.py

@@ -33,8 +33,8 @@ from .validate_config import (
 from .slide_selection_iterator import slide_selection_iterator
 from .gui import (
     RadioButtonDialog,
-    InfoMsgBox,
-    SheetAndPreviewChooser,
     choose_cd_day,
     choose_sermon_day,
+    InfoMsgBox,
+    WaveAndSheetPreviewChooserGUI,
 )

+ 48 - 212
input/gui.py

@@ -43,8 +43,8 @@ from PyQt5.QtCore import (  # pylint: disable=no-name-in-module
     QTimer,
 )
 
-from audio import SermonSegment
-from utils import CustomException, get_wave_duration_in_secs, log
+from audio import AudioSourceFileType, ChosenAudio, get_wave_duration_in_secs
+from utils import CustomException, log
 import config as const
 
 
@@ -56,7 +56,8 @@ class LabelConstruct:
 
 @dataclass
 class CheckBoxConstruct:
-    rel_path: str
+    rel_sheet_path: str
+    abs_wave_path: str
     check_box: QCheckBox
 
 
@@ -128,9 +129,15 @@ class RadioButtonDialog(QDialog):  # pylint: disable=too-few-public-methods
             )
 
 
-class SheetAndPreviewChooser(QDialog):  # pylint: disable=too-few-public-methods
+class WaveAndSheetPreviewChooserGUI(
+    QDialog
+):  # pylint: disable=too-few-public-methods
     def __init__(
-        self, base_dir: str, options: list[str], window_title: str
+        self,
+        base_dir: str,
+        options: list[str],
+        window_title: str,
+        source_type: AudioSourceFileType,
     ) -> None:
         super().__init__()
         self.base_dir = base_dir
@@ -144,213 +151,27 @@ class SheetAndPreviewChooser(QDialog):  # pylint: disable=too-few-public-methods
 
         self.player = QMediaPlayer()
 
-        self.chosen_sheets = []
+        self.chosen_audios = []
         self.position_labels = []
         for num, item in enumerate(options):
-            rel_wave_path = self.get_wav_relative_path_from_cue_sheet(item)
-
-            check_box = QCheckBox(item)
-            if num == 0:
+            if source_type is AudioSourceFileType.WAVE:
+                rel_wave_path = item
+            else:
+                rel_wave_path = self.get_wav_relative_path_from_cue_sheet(item)
+            abs_wave_path = path.join(base_dir, rel_wave_path)
+
+            if source_type is AudioSourceFileType.WAVE:
+                check_box = QCheckBox(
+                    f"Segment {(num+1):0{const.CD_RECORD_FILENAME_ZFILL}}"
+                )
+            else:
+                check_box = QCheckBox(item)
+            if num == 0 and source_type is AudioSourceFileType.CUESHEET:
                 check_box.setChecked(True)
             button_layout = QHBoxLayout()
-            check_box_construct = CheckBoxConstruct(item, check_box)
-            self.check_buttons.append(check_box_construct)
-            button_layout.addWidget(check_box)
-
-            play_button = self.get_player_button("SP_MediaPlay")
-            play_button.setToolTip("Play CD Preview")
-            play_button.clicked.connect(
-                lambda _, x=rel_wave_path: self.play_audio(x)
+            check_box_construct = CheckBoxConstruct(
+                item, abs_wave_path, check_box
             )
-
-            pause_button = self.get_player_button("SP_MediaPause")
-            pause_button.setToolTip("Pause CD Preview")
-            pause_button.clicked.connect(
-                lambda _, x=rel_wave_path: self.pause_player(x)
-            )
-
-            stop_button = self.get_player_button("SP_MediaStop")
-            stop_button.setToolTip("Stop CD Preview")
-            stop_button.clicked.connect(
-                lambda _, x=rel_wave_path: self.stop_player(x)
-            )
-
-            seek_bwd_button = self.get_player_button("SP_MediaSeekBackward")
-            seek_bwd_button.setToolTip("Seek 10 seconds backwards")
-            seek_bwd_button.clicked.connect(
-                lambda _, x=rel_wave_path: self.seek_bwd_10secs(x)
-            )
-
-            seek_fwd_button = self.get_player_button("SP_MediaSeekForward")
-            seek_fwd_button.setToolTip("Seek 10 seconds forwards")
-            seek_fwd_button.clicked.connect(
-                lambda _, x=rel_wave_path: self.seek_fwd_10secs(x)
-            )
-
-            button_layout.addWidget(play_button)
-            button_layout.addWidget(pause_button)
-            button_layout.addWidget(stop_button)
-            button_layout.addWidget(seek_bwd_button)
-            button_layout.addWidget(seek_fwd_button)
-
-            secs = get_wave_duration_in_secs(path.join(base_dir, rel_wave_path))
-            mins = secs // 60
-            secs %= 60
-            time_label = QLabel(f"00:00 / {mins:02}:{secs:02}")
-            label_construct = LabelConstruct(rel_wave_path, time_label)
-            self.position_labels.append(label_construct)
-            button_layout.addWidget(time_label)
-            timer = QTimer(self)
-            timer.timeout.connect(self.update_position)
-            timer.start(1000)
-
-            scroll_area_layout.addLayout(button_layout)
-
-        ok_button = QPushButton("OK")
-        ok_button.clicked.connect(self.accept)
-        self.master_layout.addWidget(ok_button)
-
-    def play_audio(self, rel_path: str) -> None:
-        if self.player.state() == QMediaPlayer.PausedState:  # pyright: ignore
-            media = self.player.media()
-            if media.isNull():
-                return
-            if path.split(media.canonicalUrl().toString())[1] == rel_path:
-                self.player.play()
-        else:
-            url = QUrl.fromLocalFile(path.join(self.base_dir, rel_path))
-            content = QMediaContent(url)
-            self.player.setMedia(content)
-            self.player.play()
-
-    def update_position(self) -> None:
-        media = self.player.media()
-        if media.isNull():
-            return
-        playing_path = path.split(media.canonicalUrl().toString())[1]
-        for label_construct in self.position_labels:
-            if label_construct.rel_path == playing_path:
-                old_text = label_construct.label.text()
-                old_text = old_text[old_text.find(" / ") :]
-                secs = self.player.position() // 1000
-                mins = secs // 60
-                secs %= 60
-                label_construct.label.setText(f"{mins:02}:{secs:02}{old_text}")
-
-    def stop_player(self, rel_path: str) -> None:
-        media = self.player.media()
-        if media.isNull():
-            return
-        if path.split(media.canonicalUrl().toString())[1] == rel_path:
-            self.player.stop()
-            self.update_position()
-
-    def seek_by(self, rel_path: str, seek_by_milis) -> None:
-        media = self.player.media()
-        if media.isNull():
-            return
-        if path.split(media.canonicalUrl().toString())[1] == rel_path:
-            position = self.player.position()
-            self.player.setPosition(position + seek_by_milis)
-            self.update_position()
-
-    def seek_fwd_10secs(self, rel_path: str) -> None:
-        self.seek_by(rel_path, 10000)
-
-    def seek_bwd_10secs(self, rel_path: str) -> None:
-        self.seek_by(rel_path, -10000)
-
-    def pause_player(self, rel_path: str) -> None:
-        media = self.player.media()
-        if media.isNull():
-            return
-        if path.split(media.canonicalUrl().toString())[1] == rel_path:
-            self.player.pause()
-
-    def get_scroll_area_layout(self):
-        scroll_area = QScrollArea()
-        scroll_area.setWidgetResizable(True)
-        self.master_layout.addWidget(scroll_area)
-
-        scroll_content = QWidget()
-        scroll_area.setWidget(scroll_content)
-        scroll_area_layout = QVBoxLayout(scroll_content)
-        return scroll_area_layout
-
-    def get_player_button(self, icon_name: str) -> QPushButton:
-        stop_button = QPushButton("")
-        stop_button.setMinimumSize(QSize(40, 40))
-        stop_button.setMaximumSize(QSize(40, 40))
-        pixmapi = getattr(QStyle, icon_name)
-        icon = self.style().standardIcon(pixmapi)  # pyright: ignore
-        stop_button.setIcon(icon)
-        return stop_button
-
-    def accept(self) -> None:
-        for check_box_construct in self.check_buttons:
-            if check_box_construct.check_box.isChecked():
-                self.chosen_sheets.append(check_box_construct.rel_path)
-        super().accept()
-
-    def get_wav_relative_path_from_cue_sheet(
-        self, sheet_relative_path: str
-    ) -> str:
-        full_path = path.join(self.base_dir, sheet_relative_path)
-
-        try:
-            with open(
-                full_path,
-                mode="r",
-                encoding="utf-8-sig",
-            ) as cachefile_reader:
-                cachefile_content = cachefile_reader.readlines()
-            first_line = cachefile_content[0].strip()
-            if not match(r"^FILE \".+\" WAVE$", first_line):
-                raise CustomException("invalid first cue sheet line")
-            full_path_found = first_line[first_line.find('"') + 1 :]
-            full_path_found = full_path_found[: full_path_found.rfind('"')]
-            return path.split(full_path_found)[1]
-        except (
-            FileNotFoundError,
-            PermissionError,
-            IOError,
-            IndexError,
-            CustomException,
-        ) as error:
-            QMessageBox.critical(
-                self,
-                "Error",
-                f"Could not parse cue sheet: '{full_path}', Reason: {error}",
-            )
-            sys.exit(1)
-
-
-class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
-    def __init__(
-        self, base_dir: str, options: list[SermonSegment], window_title: str
-    ) -> None:
-        super().__init__()
-        self.base_dir = base_dir
-        self.setWindowTitle(window_title)
-
-        self.master_layout = QVBoxLayout(self)
-
-        scroll_area_layout = self.get_scroll_area_layout()
-
-        self.check_buttons = []
-
-        self.player = QMediaPlayer()
-
-        self.chosen_sheets = []
-        self.position_labels = []
-        for num, item in enumerate(options):
-            rel_wave_path = self.get_wav_relative_path_from_cue_sheet(item)
-
-            check_box = QCheckBox(item)
-            if num == 0:
-                check_box.setChecked(True)
-            button_layout = QHBoxLayout()
-            check_box_construct = CheckBoxConstruct(item, check_box)
             self.check_buttons.append(check_box_construct)
             button_layout.addWidget(check_box)
 
@@ -390,10 +211,7 @@ class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
             button_layout.addWidget(seek_bwd_button)
             button_layout.addWidget(seek_fwd_button)
 
-            secs = get_wave_duration_in_secs(path.join(base_dir, rel_wave_path))
-            mins = secs // 60
-            secs %= 60
-            time_label = QLabel(f"00:00 / {mins:02}:{secs:02}")
+            time_label = self.get_initial_time_label(abs_wave_path)
             label_construct = LabelConstruct(rel_wave_path, time_label)
             self.position_labels.append(label_construct)
             button_layout.addWidget(time_label)
@@ -407,6 +225,13 @@ class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
         ok_button.clicked.connect(self.accept)
         self.master_layout.addWidget(ok_button)
 
+    def get_initial_time_label(self, abs_wave_path):
+        secs = get_wave_duration_in_secs(abs_wave_path)
+        mins = secs // 60
+        secs %= 60
+        time_label = QLabel(f"00:00 / {mins:02}:{secs:02}")
+        return time_label
+
     def play_audio(self, rel_path: str) -> None:
         if self.player.state() == QMediaPlayer.PausedState:  # pyright: ignore
             media = self.player.media()
@@ -486,7 +311,12 @@ class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
     def accept(self) -> None:
         for check_box_construct in self.check_buttons:
             if check_box_construct.check_box.isChecked():
-                self.chosen_sheets.append(check_box_construct.rel_path)
+                self.chosen_audios.append(
+                    ChosenAudio(
+                        check_box_construct.rel_sheet_path,
+                        check_box_construct.abs_wave_path,
+                    )
+                )
         super().accept()
 
     def get_wav_relative_path_from_cue_sheet(
@@ -521,12 +351,14 @@ class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
             )
             sys.exit(1)
 
+
 @dataclass
 class ArchiveTypeStrings:
     archive_type_plural: str
     action_to_choose: str
     action_ing_form: str
 
+
 def choose_cd_day() -> list[str]:
     strings = ArchiveTypeStrings("CD's", "CD day to Burn", "Burning CD for day")
     return choose_archive_day(strings)
@@ -567,3 +399,7 @@ def choose_archive_day(strings: ArchiveTypeStrings) -> list[str]:
         f"Failed to access directory: {const.CD_RECORD_OUTPUT_BASEDIR}.",
         "",
     ]
+
+
+# class SheetAndPreviewChooser(WaveAndSheetPreviewChooserGUI):
+#     def __init__(self, )

+ 2 - 2
next_song.py

@@ -15,11 +15,11 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from utils import (
+from song_switcher import (
     SongDirection,
     cycle_to_song_direction,
-    make_sure_file_exists,
 )
+from utils import make_sure_file_exists
 from input import validate_obs_song_scene_switcher_config
 import config as const
 

+ 2 - 2
previous_song.py

@@ -15,11 +15,11 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from utils import (
+from song_switcher import (
     SongDirection,
     cycle_to_song_direction,
-    make_sure_file_exists,
 )
+from utils import make_sure_file_exists
 from input import validate_obs_song_scene_switcher_config
 import config as const
 

+ 1 - 1
recording/__init__.py

@@ -15,7 +15,7 @@
 
 from .sermon import (
     SermonSegment,
-    make_sermon_segment_mp3,
+    make_sermon_mp3,
     prepare_audio_files_for_segment_chooser,
     upload_sermon_for_day,
 )

+ 17 - 30
recording/cd.py

@@ -14,7 +14,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import sys
-from os import path
+from os import path, listdir
 from shlex import split
 from subprocess import Popen
 
@@ -32,10 +32,11 @@ from input import (
     InfoMsgBox,
     RadioButtonDialog,
     validate_cd_record_config,
-    SheetAndPreviewChooser,
+    WaveAndSheetPreviewChooserGUI,
 )
-from utils import expand_dir, log, get_yyyy_mm_dd_date
+from utils import expand_dir, log, make_sure_file_exists
 from os_agnostic import get_cd_drives, eject_drive
+from audio import AudioSourceFileType
 from .verify import (
     is_legal_sheet_filename,
     get_padded_cd_num_from_sheet_filename,
@@ -172,15 +173,22 @@ def burn_cds_of_day(yyyy_mm_dd: str) -> None:
             )
         else:
             app = QApplication([])
-            dialog = SheetAndPreviewChooser(
-                target_dir, cue_sheets, f"Preview CD's for {yyyy_mm_dd}"
+            dialog = WaveAndSheetPreviewChooserGUI(
+                target_dir,
+                cue_sheets,
+                f"Preview CD's for {yyyy_mm_dd}",
+                AudioSourceFileType.CUESHEET,
             )
             if dialog.exec_() == QDialog.Accepted:
-                if not dialog.chosen_sheets:
+                if not dialog.chosen_audios:
                     sys.exit(0)
-                log(f"Burning CD's from sheets: {dialog.chosen_sheets}")
-                num_of_chosen_sheets = len(dialog.chosen_sheets)
-                for num, sheet in enumerate(dialog.chosen_sheets):
+                chosen_sheets = []
+                for chosen_audio in dialog.chosen_audios:
+                    chosen_sheets.append(chosen_audio.sheet_rel_path)
+
+                log(f"Burning CD's from sheets: {chosen_sheets}")
+                num_of_chosen_sheets = len(dialog.chosen_audios)
+                for num, sheet in enumerate(chosen_sheets):
                     del app  # pyright: ignore
                     last_cd_to_burn = num == num_of_chosen_sheets
                     burn_and_eject_cd(
@@ -208,27 +216,6 @@ def exit_as_no_cds_found(target_dir):
     sys.exit(1)
 
 
-def create_cachfile_for_song(song) -> None:
-    log("writing song {} to cachefile...".format(song))
-    try:
-        with open(
-            const.NEXTSONG_CACHE_FILE, mode="w", encoding="utf-8-sig"
-        ) as file_writer:
-            file_writer.write(get_yyyy_mm_dd_date() + "\n")
-            file_writer.write(str(song) + "\n")
-    except (FileNotFoundError, PermissionError, IOError) as error:
-        app = QApplication
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            "Failed to write to cachefile '{}'. Reason: {}".format(
-                const.NEXTSONG_CACHE_FILE, error
-            ),
-        )
-        del app
-        sys.exit(1)
-
-
 def mark_end_of_recording(cachefile_content: list) -> None:
     cachefile = expand_dir(const.CD_RECORD_CACHEFILE)
 

+ 125 - 61
recording/sermon.py

@@ -19,22 +19,24 @@ from shlex import split
 from re import match
 from subprocess import Popen
 import ftplib
-from shutil import copy2
 
 from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
     QApplication,
     QMessageBox,
     QInputDialog,
+    QDialog,
 )
 
-from utils import CustomException
+from utils import CustomException, expand_dir, log
 from input import InfoMsgBox
 from audio import (
     get_ffmpeg_timestamp_from_frame,
     SermonSegment,
     get_wave_duration_in_frames,
     get_index_line_as_frames,
+    AudioSourceFileType,
 )
+from input import WaveAndSheetPreviewChooserGUI
 import config as const
 from .verify import (
     get_padded_cd_num_from_sheet_filename,
@@ -72,24 +74,26 @@ def get_full_wav_path(segment: SermonSegment) -> str:
         sys.exit(1)
 
 
-def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
+def get_audio_rel_path_from_segment(segment: SermonSegment) -> str:
     splitted_sheet_path = path.split(segment.source_cue_sheet)
     yyyy_mm_dd = path.split(splitted_sheet_path[0])[1]
     cd_num = get_padded_cd_num_from_sheet_filename(splitted_sheet_path[1])
-    mp3_path = path.join(
-        splitted_sheet_path[0],
-        f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}",
-    )
-    return mp3_path
+    return f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}"
 
 
-def make_sermon_segment_mp3(segment: SermonSegment) -> str:
-    full_wav_path = get_full_wav_path(segment)
+def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
+    base_path = path.split(segment.source_cue_sheet)[0]
+    return path.join(
+        base_path,
+        get_audio_rel_path_from_segment(segment),
+    )
 
-    mp3_path = f"{get_audio_base_path_from_segment(segment)}.mp3"
+
+def make_sermon_mp3(source_audio: str, target_audio: str) -> None:
+    log("Generating final mp3...")
     cmd = "ffmpeg -y -i {} -acodec libmp3lame {}".format(
-        full_wav_path,
-        mp3_path,
+        source_audio,
+        target_audio,
     )
     process = Popen(split(cmd))
     _ = process.communicate()[0]  # wait for subprocess to end
@@ -102,39 +106,37 @@ def make_sermon_segment_mp3(segment: SermonSegment) -> str:
         )
         del app
 
-    return mp3_path
+
+def generate_wav_for_segment(segment: SermonSegment) -> None:
+    cmd = (
+        f"ffmpeg -y -i {get_full_wav_path(segment)} -ss "
+        + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
+        + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
+        + f"-acodec copy {get_audio_base_path_from_segment(segment)}.wav"
+    )
+    process = Popen(split(cmd))
+    _ = process.communicate()[0]  # wait for subprocess to end
+    if process.returncode not in [255, 0]:
+        app = QApplication([])
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "ffmpeg terminated with " + f"exit code {process.returncode}",
+        )
+        del app
+        sys.exit(1)
 
 
 def prepare_audio_files_for_segment_chooser(
     segments: list[SermonSegment],
 ) -> None:
     for segment in segments:
-        # TODO: check if file duration and type roughly match the target to
-        # avoid useless regenerating. Also, parallelization.
-        cmd = (
-            f"ffmpeg -y -i {get_full_wav_path(segment)} -ss "
-            + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
-            + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
-            + f"-acodec copy {get_audio_base_path_from_segment(segment)}.wav"
-        )
-        process = Popen(split(cmd))
-        _ = process.communicate()[0]  # wait for subprocess to end
-        if process.returncode not in [255, 0]:
-            app = QApplication([])
-            InfoMsgBox(
-                QMessageBox.Critical,
-                "Error",
-                "ffmpeg terminated with " + f"exit code {process.returncode}",
-            )
-            del app
-            sys.exit(1)
+        generate_wav_for_segment(segment)
 
 
 def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
     try:
         segments = []
-        base_frames = 0
-        max_frames = 0
 
         day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
         files = sorted(listdir(day_dir))
@@ -142,7 +144,7 @@ def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
         for file in files:
             if is_legal_sheet_filename(file):
                 cue_sheets.append(file)
-        for sheet_num, sheet in enumerate(cue_sheets):
+        for sheet in cue_sheets:
             with open(
                 path.join(day_dir, sheet),
                 mode="r",
@@ -206,13 +208,13 @@ def get_segments_with_suitable_time(
             segment.end_frame - segment.start_frame
             >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
         ):
-            # if segment.end_frame - segment.start_frame >= 90000:  # 75 * 60 * 20
             suitable_segments.append(segment)
     return suitable_segments
 
 
-def upload_sermon_segment(segment: SermonSegment) -> None:
+def upload_sermon_audiofile(audiofile: str) -> None:
     try:
+        ext = ".mp3"
         session = ftplib.FTP_TLS(
             const.SERMON_UPLOAD_FTP_HOSTNAME,
             const.SERMON_UPLOAD_FTP_USER,
@@ -229,13 +231,13 @@ def upload_sermon_segment(segment: SermonSegment) -> None:
         wanted_filename, accepted_dialog = QInputDialog.getText(
             None,
             "Input Dialog",
-            "Enter the filename for the Sermon (the .mp3 can be omitted):",
+            f"Enter the filename for the Sermon (the {ext} can be omitted):",
         )
         del app
-        if not wanted_filename.endswith(".mp3"):
-            wanted_filename = wanted_filename + ".mp3"
+        if not wanted_filename.endswith(ext):
+            wanted_filename = wanted_filename + ext
 
-        if not accepted_dialog or wanted_filename == ".mp3":
+        if not accepted_dialog or wanted_filename == ext:
             session.quit()
             sys.exit(0)
         if wanted_filename in disallowed_filenames:
@@ -245,15 +247,17 @@ def upload_sermon_segment(segment: SermonSegment) -> None:
             session.quit()
             sys.exit(1)
 
-        orig_mp3 = make_sermon_segment_mp3(segment)
-        mp3_final_path = path.join(path.split(orig_mp3)[0], wanted_filename)
-        copy2(orig_mp3, mp3_final_path)
+        mp3_final_path = path.join(path.split(audiofile)[0], wanted_filename)
+        print(mp3_final_path)
+        make_sermon_mp3(audiofile, mp3_final_path)
 
         with open(mp3_final_path, "rb") as file:
             session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
         session.quit()
         InfoMsgBox(
-            QMessageBox.Information, "Success", "Sermon uploaded successfully."
+            QMessageBox.Information,
+            "Success",
+            f"Sermon '{mp3_final_path}' uploaded successfully.",
         )
     except (
         *ftplib.all_errors,
@@ -280,21 +284,81 @@ def upload_sermon_for_day(yyyy_mm_dd: str):
         sys.exit(1)
 
     suitable_segments = get_segments_with_suitable_time(segments)
-    # TODO: remove
-    # for segment in suitable_segments:
-    #     print(f"start {segment.start_frame}")
-    #     print(f"end {segment.end_frame}")
-    #     print(f"sheet {segment.source_cue_sheet}")
-    #     print(f"marker {segment.source_marker}")
-
-    if not suitable_segments:
-        # TODO: choose
+    if len(suitable_segments) == 1:
+        generate_wav_for_segment(suitable_segments[0])
+        target_path = (
+            f"{get_audio_base_path_from_segment(suitable_segments[0])}.wav"
+        )
+        upload_sermon_audiofile(target_path)
+    else:
         prepare_audio_files_for_segment_chooser(segments)
+        rel_wave_paths = []
+        for segment in segments:
+            rel_wave_paths.append(
+                f"{get_audio_rel_path_from_segment(segment)}.wav"
+            )
+
+        app = QApplication([])
+        target_dir = path.join(
+            expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
+        )
+        dialog = WaveAndSheetPreviewChooserGUI(
+            target_dir,
+            rel_wave_paths,
+            f"Preview CD's for {yyyy_mm_dd}",
+            AudioSourceFileType.WAVE,
+        )
+        if dialog.exec_() == QDialog.Accepted:
+            if not dialog.chosen_audios:
+                sys.exit(0)
+            chosen_wave_paths = []
+            for chosen_audio in dialog.chosen_audios:
+                chosen_wave_paths.append(chosen_audio.wave_abs_path)
+
+            del app  # pyright: ignore
+
+            merge_wave_files(target_dir, chosen_wave_paths)
+            upload_sermon_audiofile(path.join(target_dir, "merged.wav"))
+
+
+def merge_wave_files(target_dir: str, wave_paths: list[str]) -> None:
+    concat_file_path = path.join(target_dir, "concat.txt")
+    log(f"Merging into mp3 file from wave files: {wave_paths}")
+    create_concat_file(concat_file_path, wave_paths)
+    merge_files_with_ffmpeg(concat_file_path, target_dir)
+
+
+def create_concat_file(file_path: str, wave_paths: list[str]) -> None:
+    try:
+        with open(file_path, mode="w+", encoding="utf-8") as writer:
+            for wave_path in wave_paths:
+                if not "'" in wave_path:
+                    writer.write(f"file '{wave_path}'\n")
+                else:
+                    writer.write(f'file "{wave_path}"\n')
+    except (FileNotFoundError, PermissionError, IOError) as error:
+        app = QApplication
         InfoMsgBox(
-            QMessageBox.Critical, "Error", "Error: no suitable segment found"
+            QMessageBox.Critical,
+            "Error",
+            f"Failed to write to '{file_path}'. Reason: {error}",
         )
-    elif len(suitable_segments) == 1:
-        upload_sermon_segment(suitable_segments[0])
-    else:
-        # TODO: choose
-        pass
+        del app
+        sys.exit(1)
+
+
+def merge_files_with_ffmpeg(concat_file_path, target_dir) -> None:
+    cmd = "ffmpeg -y -f concat -safe 0 -i {} -acodec copy {}".format(
+        concat_file_path,
+        path.join(target_dir, "merged.wav"),
+    )
+    process = Popen(split(cmd))
+    _ = process.communicate()[0]  # wait for subprocess to end
+    if process.returncode not in [255, 0]:
+        app = QApplication([])
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "ffmpeg terminated with " + f"exit code {process.returncode}",
+        )
+        del app

+ 16 - 0
song_switcher/__init__.py

@@ -0,0 +1,16 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from .switch import SongDirection, cycle_to_song_direction, switch_to_song

+ 8 - 19
utils/audio.py → song_switcher/obs.py

@@ -13,26 +13,15 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-import sys
 
-from soundfile import SoundFile, LibsndfileError  # pyright: ignore
-from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
-    QMessageBox,
-)
+from time import sleep
 
+from pyautogui import keyDown, keyUp
 
-def get_wave_duration_in_frames(file_name: str) -> int:
-    try:
-        wav = SoundFile(file_name)
-        return int(wav.frames / wav.samplerate * 75)
-    except LibsndfileError:
-        QMessageBox.critical(
-            None,
-            "Error",
-            f"Error: Could not get duration of {file_name}",
-        )
-        sys.exit(1)
 
-
-def get_wave_duration_in_secs(file_name: str) -> int:
-    return int(get_wave_duration_in_frames(file_name) / 75)
+def safe_send_hotkey(hotkey: list, sleep_time=0.1) -> None:
+    for key in hotkey:
+        keyDown(key)
+    sleep(sleep_time)
+    for key in hotkey:
+        keyUp(key)

+ 2 - 32
utils/scripts.py → song_switcher/switch.py

@@ -14,43 +14,21 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import sys
-from os import path, listdir
-from time import sleep
 from re import match
 from enum import Enum
-from dataclasses import dataclass
 
-from pyautogui import keyDown, keyUp
 from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
     QApplication,
     QMessageBox,
 )
-from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
-    QDialog,
-)
 from utils import (
     log,
-    error_msg,
     get_yyyy_mm_dd_date,
     expand_dir,
 )
-from input import RadioButtonDialog, get_cachefile_content, InfoMsgBox
+from input import get_cachefile_content, InfoMsgBox
 import config as const
-
-
-def make_sure_file_exists(cachefile: str) -> None:
-    if not path.isfile(cachefile):
-        try:
-            with open(
-                cachefile, mode="w+", encoding="utf-8-sig"
-            ) as file_creator:
-                file_creator.write("")
-        except (FileNotFoundError, PermissionError, IOError) as error:
-            error_msg(
-                "Failed to create file in '{}'. Reason: {}".format(
-                    cachefile, error
-                )
-            )
+from .obs import safe_send_hotkey
 
 
 class SongDirection(Enum):
@@ -111,11 +89,3 @@ def create_cachfile_for_song(song) -> None:
         )
         del app
         sys.exit(1)
-
-
-def safe_send_hotkey(hotkey: list, sleep_time=0.1) -> None:
-    for key in hotkey:
-        keyDown(key)
-    sleep(sleep_time)
-    for key in hotkey:
-        keyUp(key)

+ 1 - 8
utils/__init__.py

@@ -13,7 +13,6 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from .audio import get_wave_duration_in_secs, get_wave_duration_in_frames
 from .log import error_msg, warn, log, CustomException
 from .strings import (
     get_songtext_by_structure,
@@ -23,11 +22,5 @@ from .strings import (
 from .img import get_empty_image
 from .create_min_obs_subdirs import create_min_obs_subdirs
 from .clear_obs_slides_dir import clear_obs_slides_dir
-from .path import expand_dir
+from .path import expand_dir, make_sure_file_exists
 from .date import get_yyyy_mm_dd_date, get_unix_milis
-from .scripts import (
-    make_sure_file_exists,
-    switch_to_song,
-    cycle_to_song_direction,
-    SongDirection,
-)

+ 20 - 4
utils/path.py

@@ -13,11 +13,27 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-import os
+from os import path
+from .log import error_msg
 
 
 def expand_dir(directory: str) -> str:
-    expanded_user_dir = os.path.expanduser(directory)
-    expanded_user_and_env_vars_dir = os.path.expandvars(expanded_user_dir)
-    abs_path = os.path.abspath(expanded_user_and_env_vars_dir)
+    expanded_user_dir = path.expanduser(directory)
+    expanded_user_and_env_vars_dir = path.expandvars(expanded_user_dir)
+    abs_path = path.abspath(expanded_user_and_env_vars_dir)
     return abs_path
+
+
+def make_sure_file_exists(filename: str) -> None:
+    if not path.isfile(filename):
+        try:
+            with open(
+                filename, mode="w+", encoding="utf-8-sig"
+            ) as file_creator:
+                file_creator.write("")
+        except (FileNotFoundError, PermissionError, IOError) as error:
+            error_msg(
+                "Failed to create file in '{}'. Reason: {}".format(
+                    filename, error
+                )
+            )