Improve chaptered audio generation by outputting directly as m4b instead of converting from wav

This commit is contained in:
Deniz Şafak
2025-05-09 01:29:14 +03:00
parent 375a185aef
commit cab0a7631f
2 changed files with 131 additions and 144 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
# v1.0.7 (pre-release) # v1.0.7 (pre-release)
- Ignore chapter markers and single newlines when calculating text length. - Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`.
- Added `.opus` support as output format for generated audio files. - Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation.
- Added `.opus` as output format for generated audio files.
- Added "Playing..." indicator for "Preview" button in the voice mixer. - Added "Playing..." indicator for "Preview" button in the voice mixer.
# v1.0.6 # v1.0.6
+128 -142
View File
@@ -502,12 +502,8 @@ class ConversionThread(QThread):
# Concatenate chapter audio and save # Concatenate chapter audio and save
chapter_audio = self.np.concatenate(chapter_audio_segments) chapter_audio = self.np.concatenate(chapter_audio_segments)
# If output format is m4b, save chapter as wav # Determine chapter extension (.wav for m4b output)
chapter_ext = self.output_format chapter_ext = 'wav' if self.output_format == 'm4b' else self.output_format
chapter_format = self.output_format
if self.output_format == "m4b":
chapter_ext = "wav"
chapter_format = "wav"
chapter_out_path = os.path.join( chapter_out_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{chapter_ext}" chapters_out_dir, f"{chapter_filename}.{chapter_ext}"
) )
@@ -517,6 +513,7 @@ class ConversionThread(QThread):
[ [
"ffmpeg", "ffmpeg",
"-y", "-y",
"-thread_queue_size", "1024", # Increased thread_queue_size for chapter opus
"-f", "-f",
"f32le", "f32le",
"-ar", "-ar",
@@ -541,7 +538,7 @@ class ConversionThread(QThread):
chapter_out_path, chapter_out_path,
chapter_audio, chapter_audio,
24000, 24000,
format=chapter_format, format='wav' if self.output_format == 'm4b' else self.output_format,
) )
# Generate .srt subtitle file for chapter if not Disabled # Generate .srt subtitle file for chapter if not Disabled
@@ -582,70 +579,75 @@ class ConversionThread(QThread):
or not self.save_chapters_separately or not self.save_chapters_separately
or getattr(self, "merge_chapters_at_end", True) or getattr(self, "merge_chapters_at_end", True)
) )
m4b_picked = False # Ensure m4b_picked is always defined
intended_output_format = self.output_format # Store the original choice
if audio_segments and merge_chapters: if audio_segments and merge_chapters:
self.log_updated.emit("\nFinalizing audio file...\n") self.log_updated.emit("\nFinalizing audio file...\n")
audio = self.np.concatenate(audio_segments) audio_data_np = self.np.concatenate(audio_segments)
out_dir = parent_dir out_dir = parent_dir
# Use the same suffix as above base_filepath_no_ext = os.path.join(out_dir, f"{base_name}{suffix}")
out_filename = f"{base_name}{suffix}.{self.output_format}"
out_path = os.path.join(out_dir, out_filename)
srt_path = os.path.splitext(out_path)[0] + ".srt"
# in case the user picked m4b format, we need to change the output format to wav final_out_path = None
if self.output_format == "m4b":
out_path = os.path.splitext(out_path)[0] + ".wav" if intended_output_format == "m4b":
self.output_format = "wav" final_out_path = self._generate_m4b_with_chapters(audio_data_np, chapters_time, base_filepath_no_ext)
m4b_picked = True elif intended_output_format == "opus":
if self.output_format == "opus":
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
proc = subprocess.Popen( opus_out_path = f"{base_filepath_no_ext}.opus"
[ ffmpeg_cmd_opus = [
"ffmpeg", "ffmpeg", "-y",
"-y", "-thread_queue_size", "1024", # Increased thread_queue_size
"-f", "-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:",
"f32le", "-c:a", "libopus", "-b:a", "24000", # Original bitrate
"-ar", opus_out_path,
"24000", ]
"-ac", try:
"1", print(f"Executing FFMPEG for Opus: {' '.join(ffmpeg_cmd_opus)}")
"-i", process = subprocess.Popen(ffmpeg_cmd_opus, stdin=subprocess.PIPE)
"pipe:", process.stdin.write(audio_data_np.astype("float32").tobytes())
"-c:a", process.stdin.close()
"libopus", if process.wait() == 0:
"-b:a", final_out_path = opus_out_path
"24000", else:
out_path, self.log_updated.emit(("Opus conversion failed.", "red"))
], final_out_path = None
stdin=subprocess.PIPE, except Exception as e_opus:
) self.log_updated.emit((f"Error during Opus conversion: {str(e_opus)}", "red"))
proc.stdin.write(audio.astype("float32").tobytes()) final_out_path = None
proc.stdin.close() else: # For other formats like wav
proc.wait() standard_out_path = f"{base_filepath_no_ext}.{intended_output_format}"
else: try:
sf.write(out_path, audio, 24000, format=self.output_format) sf.write(standard_out_path, audio_data_np, 24000, format=intended_output_format)
if m4b_picked: final_out_path = standard_out_path
out_path = self._generate_m4b_with_chapters(out_path, chapters_time) except Exception as e_sf:
self.log_updated.emit((f"Failed to write audio file {standard_out_path}: {str(e_sf)}", "red"))
final_out_path = None
if self.subtitle_mode != "Disabled": # Subtitle and final message logic
with open( if final_out_path:
srt_path, "w", encoding="utf-8", errors="replace" if self.subtitle_mode != "Disabled":
) as srt_file: srt_path = os.path.splitext(final_out_path)[0] + ".srt"
for i, (start, end, text) in enumerate(subtitle_entries, 1): with open(srt_path, "w", encoding="utf-8", errors="replace") as srt_file:
srt_file.write( for i, (start, end, text) in enumerate(subtitle_entries, 1):
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" srt_file.write(
) f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
self.conversion_finished.emit( )
( self.conversion_finished.emit(
f"Audiobook saved to: {out_path}\n\nSubtitle saved to: {srt_path}", (
"green", f"Audiobook saved to: {final_out_path}\n\nSubtitle saved to: {srt_path}",
), "green",
out_path, ),
) final_out_path,
)
else:
self.conversion_finished.emit(
(f"Audiobook saved to: {final_out_path}", "green"), final_out_path
)
else: else:
self.conversion_finished.emit( self.log_updated.emit(("Audio generation failed (final_out_path was not set).", "red"))
(f"Audiobook saved to: {out_path}", "green"), out_path self.conversion_finished.emit(("Audio generation failed.", "red"), None)
)
elif audio_segments and not merge_chapters: elif audio_segments and not merge_chapters:
self.conversion_finished.emit( self.conversion_finished.emit(
( (
@@ -668,96 +670,80 @@ class ConversionThread(QThread):
self.waiting_for_user_input = False self.waiting_for_user_input = False
self._chapter_options_event.set() self._chapter_options_event.set()
def _generate_m4b_with_chapters(self, out_path, chapters_time): def _generate_m4b_with_chapters(self, audio_data_np, chapters_time, base_filepath_no_ext):
# If there is only one chapter, skip adding chapters and just return the wav file path final_wav_path = f"{base_filepath_no_ext}.wav"
if not chapters_time or len(chapters_time) <= 1: if not chapters_time or len(chapters_time) <= 1:
self.log_updated.emit( self.log_updated.emit(
( (
"File contains only one chapter or no chapters were detected. The audio will be saved as a standard .wav file instead.\n", "File contains only one chapter or no chapters were detected. Audio will be saved as a standard .wav file instead.\n",
"red", "red",
) )
) )
return out_path try:
# generate chapters.txt in the same folder as the output file sf.write(final_wav_path, audio_data_np, 24000, format="wav")
chapters_info_path = os.path.splitext(out_path)[0] + "_chapters.txt" return final_wav_path
with open(chapters_info_path, "w", encoding="utf-8") as f: except Exception as e_wav_single:
f.write(";FFMETADATA1\n") # required header for ffmpeg self.log_updated.emit((f"Failed to save single/no chapter audio as WAV: {str(e_wav_single)}", "red"))
for chapter in chapters_time: return None
f.write(f"[CHAPTER]\n")
f.write(f"TIMEBASE=1/10\n")
# use 10th of second for start/end time
f.write(f"START={int(chapter['start']*10)}\n")
f.write(f"END={int(chapter['end']*10)}\n")
f.write(f"title={chapter['chapter']}\n\n")
# call ffmpeg to merge the chapters into the output file
# ffmpeg installed on first call to add_paths()
static_ffmpeg.add_paths()
out_path_m4b = os.path.splitext(out_path)[0] + ".m4b"
# ffmpeg -i input.m4b -i ch.txt -map 0:a -map_chapters 1 output.m4b
ffmpeg_cmd = [
"ffmpeg",
"-i",
out_path,
"-i",
chapters_info_path,
"-map",
"0:a",
"-map_chapters",
"1",
out_path_m4b,
]
self.log_updated.emit("Adding chapters to the audio file...\n") output_m4b_path = f"{base_filepath_no_ext}.m4b"
chapters_info_path = f"{base_filepath_no_ext}_chapters.txt"
# Define kwargs for Popen try:
default_encoding = sys.getdefaultencoding() # Get default encoding with open(chapters_info_path, "w", encoding="utf-8") as f:
kwargs = { f.write(";FFMETADATA1\n")
"stdout": subprocess.PIPE, for chapter in chapters_time:
"stderr": subprocess.PIPE, # Capture stderr separately f.write(f"[CHAPTER]\n")
"universal_newlines": True, f.write(f"TIMEBASE=1/1000\n") # Using milliseconds for precision
"encoding": default_encoding, f.write(f"START={int(chapter['start']*1000)}\n")
"errors": "replace", f.write(f"END={int(chapter['end']*1000)}\n")
} f.write(f"title={chapter['chapter']}\n\n")
# Add Windows-specific settings static_ffmpeg.add_paths()
if platform.system() == "Windows": ffmpeg_cmd = [
startupinfo = subprocess.STARTUPINFO() "ffmpeg", "-y",
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW "-thread_queue_size", "1024", # Increased thread_queue_size
startupinfo.wShowWindow = subprocess.SW_HIDE "-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0",
kwargs.update( "-i", chapters_info_path,
{ "-map", "0:a",
"startupinfo": startupinfo, "-map_metadata", "1",
"creationflags": subprocess.CREATE_NO_WINDOW, "-map_chapters", "1",
} "-c:a", "aac", "-b:a", "128k", # Explicitly AAC with a common bitrate
) output_m4b_path,
]
# Use Popen instead of run self.log_updated.emit(f"Generating audio with chapters...\n")
process = subprocess.Popen(ffmpeg_cmd, **kwargs) print(f"Executing FFMPEG for M4B: {' '.join(ffmpeg_cmd)}")
stdout, stderr = process.communicate() # Get stdout and stderr
# Check for errors in the ffmpeg command process = subprocess.Popen(ffmpeg_cmd, stdin=subprocess.PIPE)
if process.returncode != 0: process.stdin.write(audio_data_np.astype("float32").tobytes())
self.log_updated.emit((f"FFmpeg error (stderr):\n{stderr}", "red")) process.stdin.close()
if stdout: # Log stdout as well if it exists return_code = process.wait()
self.log_updated.emit((f"FFmpeg output (stdout):\n{stdout}", "orange"))
# Log error but continue with original file if return_code == 0:
self.log_updated.emit( return output_m4b_path
("Falling back to the original audio file without chapters\n", "orange") else:
) self.log_updated.emit(
# Clean up chapters file even on error (f"FFmpeg failed to create M4B (return code {return_code}). Falling back to WAV.\n", "red")
)
sf.write(final_wav_path, audio_data_np, 24000, format="wav")
return final_wav_path
except Exception as e:
self.log_updated.emit((f"Error during M4B generation: {str(e)}. Falling back to WAV.\n", "red"))
try:
sf.write(final_wav_path, audio_data_np, 24000, format="wav")
return final_wav_path
except Exception as e_wav_fallback:
self.log_updated.emit((f"Critical error: Failed to save fallback WAV: {str(e_wav_fallback)}\n", "red"))
return None
finally:
if os.path.exists(chapters_info_path): if os.path.exists(chapters_info_path):
os.remove(chapters_info_path) try:
return out_path os.remove(chapters_info_path)
else: except Exception as e_clean:
self.log_updated.emit( self.log_updated.emit((f"Warning: Could not delete temporary chapter file {chapters_info_path}: {e_clean}", "orange"))
("Successfully added chapters to the audio file.\n", "green")
)
# delete the chapters path and original (wav) file
os.remove(chapters_info_path)
os.remove(out_path)
# the new file is in m4b format - use that for messages
return out_path_m4b
def _srt_time(self, t): def _srt_time(self, t):
"""Helper function to format time for SRT files""" """Helper function to format time for SRT files"""