refactor: extract audio buffer operations to domain layer

- Add abogen/domain/audio_buffer.py with core audio operations:
  - create_silence(): create silence audio buffer
  - mix_audio(): mix source into target buffer with auto-resize
  - normalize_audio(): normalize to prevent clipping
  - ensure_buffer_size(): extend buffer to minimum size
  - concatenate_audio(): join multiple audio buffers
  - audio_duration(): calculate duration from samples
  - samples_for_duration(): calculate samples from duration
  - SAMPLE_RATE constant (24000)

- Update abogen/pyqt/conversion.py:
  - Import and use create_silence for chapter silence
  - Use mix_audio for subtitle file mixing
  - Use normalize_audio for clipping prevention
  - Use create_silence for padding in subtitle processing

- Update abogen/webui/conversion_runner.py:
  - Import and use create_silence in append_silence
  - Replace np.zeros with domain function

- Add tests/test_audio_buffer.py with comprehensive unit tests
This commit is contained in:
Artem Akymenko
2026-07-15 20:02:33 +03:00
parent 7fef9c1d93
commit 0d46076bf6
4 changed files with 494 additions and 36 deletions
+165
View File
@@ -0,0 +1,165 @@
"""Audio buffer operations for audiobook generation.
This module provides core audio buffer manipulation functions including:
- Silence generation
- Audio mixing
- Audio normalization
- Audio buffer resizing
"""
from __future__ import annotations
from typing import Optional
import numpy as np
# Standard sample rate used throughout the application
SAMPLE_RATE = 24000
def create_silence(duration_seconds: float) -> np.ndarray:
"""Create a silence audio buffer.
Args:
duration_seconds: Duration of silence in seconds.
Returns:
Numpy array of float32 zeros with length = duration_seconds * SAMPLE_RATE.
Returns empty array if duration is <= 0.
"""
if duration_seconds <= 0:
return np.array([], dtype="float32")
samples = int(round(duration_seconds * SAMPLE_RATE))
if samples <= 0:
return np.array([], dtype="float32")
return np.zeros(samples, dtype="float32")
def mix_audio(
target: np.ndarray,
source: np.ndarray,
start_sample: int,
end_sample: Optional[int] = None,
) -> None:
"""Mix source audio into target buffer at specified position.
This performs additive mixing (target += source). The target buffer
is extended if necessary to accommodate the source audio.
Args:
target: The target audio buffer to mix into (modified in-place).
source: The source audio buffer to mix.
start_sample: Starting sample index in target buffer.
end_sample: Optional end sample index. If None, calculated from source length.
"""
if source.size == 0:
return
if end_sample is None:
end_sample = start_sample + len(source)
# Extend target buffer if needed
if end_sample > len(target):
new_length = end_sample
target = np.concatenate([
target,
np.zeros(new_length - len(target), dtype="float32")
])
# Perform the mix (additive)
target[start_sample:end_sample] += source
def normalize_audio(
audio: np.ndarray,
target_peak: float = 1.0,
) -> np.ndarray:
"""Normalize audio buffer to prevent clipping.
If the audio exceeds the target peak (default 1.0), it is scaled down
proportionally to prevent distortion.
Args:
audio: Input audio buffer.
target_peak: Target maximum amplitude (default 1.0).
Returns:
Normalized audio buffer (new array, original is not modified).
"""
if audio.size == 0:
return audio.copy()
max_amplitude = float(np.abs(audio).max())
if max_amplitude <= target_peak:
return audio.copy()
# Scale down to prevent clipping
scale_factor = target_peak / max_amplitude
return (audio * scale_factor).astype("float32")
def ensure_buffer_size(
buffer: np.ndarray,
min_samples: int,
) -> np.ndarray:
"""Ensure audio buffer is at least min_samples long.
If buffer is shorter, it is extended with zeros.
Args:
buffer: Input audio buffer.
min_samples: Minimum required length in samples.
Returns:
Buffer of at least min_samples length (new array if extended).
"""
if len(buffer) >= min_samples:
return buffer
new_buffer = np.zeros(min_samples, dtype="float32")
new_buffer[:len(buffer)] = buffer
return new_buffer
def concatenate_audio(*buffers: np.ndarray) -> np.ndarray:
"""Concatenate multiple audio buffers.
Args:
*buffers: Audio buffers to concatenate.
Returns:
Single concatenated audio buffer.
"""
non_empty = [b for b in buffers if b.size > 0]
if not non_empty:
return np.array([], dtype="float32")
return np.concatenate(non_empty)
def audio_duration(audio: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float:
"""Calculate duration of audio buffer in seconds.
Args:
audio: Audio buffer.
sample_rate: Sample rate in Hz (default SAMPLE_RATE).
Returns:
Duration in seconds.
"""
return len(audio) / sample_rate
def samples_for_duration(duration_seconds: float, sample_rate: int = SAMPLE_RATE) -> int:
"""Calculate number of samples for a given duration.
Args:
duration_seconds: Duration in seconds.
sample_rate: Sample rate in Hz (default SAMPLE_RATE).
Returns:
Number of samples (rounded to nearest integer).
"""
return int(round(duration_seconds * sample_rate))
+15 -33
View File
@@ -29,6 +29,12 @@ from abogen.domain.output_paths import (
sanitize_output_stem,
)
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
from abogen.domain.audio_buffer import (
create_silence,
mix_audio,
normalize_audio,
SAMPLE_RATE,
)
import abogen.hf_tracker as hf_tracker
import static_ffmpeg
import threading # for efficient waiting
@@ -1285,10 +1291,7 @@ class ConversionThread(QThread):
# Add silence between chapters for merged output (except after the last chapter)
if merge_chapters_at_end and chapter_idx < total_chapters:
silence_samples = int(
self.silence_duration * 24000
) # Silence duration at 24,000 Hz
silence_audio = np.zeros(silence_samples, dtype="float32")
silence_audio = create_silence(self.silence_duration)
silence_bytes = silence_audio.tobytes()
if merged_out_file:
@@ -1596,9 +1599,8 @@ class ConversionThread(QThread):
max_end_time = max(
(end for _, end, _ in subtitles if end is not None), default=0
)
audio_buffer = np.zeros(
int(max_end_time * rate) + rate, dtype="float32"
)
buffer_samples = int(max_end_time * rate) + rate
audio_buffer = np.zeros(buffer_samples, dtype="float32")
# Process each subtitle and mix into buffer
self.etr_start_time = time.time()
@@ -1799,33 +1801,14 @@ class ConversionThread(QThread):
# Pad or trim to subtitle duration
target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples:
full_audio = np.concatenate(
[
full_audio,
np.zeros(
target_samples - len(full_audio), dtype="float32"
),
]
)
padding_duration = (target_samples - len(full_audio)) / rate
full_audio = np.concatenate([full_audio, create_silence(padding_duration)])
elif len(full_audio) > target_samples:
full_audio = full_audio[:target_samples]
# Mix audio into buffer at the correct position (handles overlaps)
start_sample = int(start_time * rate)
end_sample = start_sample + len(full_audio)
if end_sample > len(audio_buffer):
# Extend buffer if needed
audio_buffer = np.concatenate(
[
audio_buffer,
np.zeros(
end_sample - len(audio_buffer), dtype="float32"
),
]
)
# Mix (add) the audio - this handles overlaps by combining them
audio_buffer[start_sample:end_sample] += full_audio
mix_audio(audio_buffer, full_audio, start_sample)
# Write subtitle
if subtitle_file:
@@ -1860,12 +1843,11 @@ class ConversionThread(QThread):
self.progress_updated.emit(percent, etr_str)
# Normalize audio buffer to prevent clipping from mixed overlaps
max_amplitude = np.abs(audio_buffer).max()
if max_amplitude > 1.0:
if np.abs(audio_buffer).max() > 1.0:
self.log_updated.emit(
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
f"\n -> Normalizing audio (peak: {np.abs(audio_buffer).max():.2f})"
)
audio_buffer = audio_buffer / max_amplitude
audio_buffer = normalize_audio(audio_buffer)
# Write the complete audio buffer
self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey"))
+7 -3
View File
@@ -116,6 +116,11 @@ from abogen.domain.audio_helpers import (
to_float32 as _to_float32,
apply_m4b_chapters_with_mutagen as _apply_m4b_chapters_with_mutagen,
)
from abogen.domain.audio_buffer import (
create_silence as _create_silence,
normalize_audio as _normalize_audio,
SAMPLE_RATE,
)
from .service import Job, JobStatus
@@ -640,10 +645,9 @@ def run_conversion_job(job: Job) -> None:
nonlocal current_time
if duration_seconds <= 0:
return
samples = int(round(duration_seconds * SAMPLE_RATE))
if samples <= 0:
silence = _create_silence(duration_seconds)
if silence.size == 0:
return
silence = np.zeros(samples, dtype="float32")
if include_in_chapter and chapter_sink:
chapter_sink.write(silence)
if audio_sink: