Per-segment audio effects DSP preset selector (closes #67, rebased from #68) (#109)

* Add per-segment audio effects DSP preset selector to dub pipeline

* Add shape assertions to podcast, warm, and bright preset tests

* Fix raw preset semantics, add preset validation, update docs, remove duplicate sys.path

* Narrow OOM catch to model.generate only in dub_generate

* Preserve original OOM exception context in dub_generate

* Bind effect_preset to _gen via explicit parameter to avoid loop capture

* Catch RuntimeError instead of torch.mps.MPSError for MPS OOM

---------

Co-authored-by: 4shil <166588383+4shil@users.noreply.github.com>
This commit is contained in:
Palash Debnath
2026-05-20 13:40:16 +05:30
committed by GitHub
co-authored by 4shil
parent 424ad76032
commit 1edd35cfd0
11 changed files with 311 additions and 18 deletions
+37 -10
View File
@@ -13,7 +13,7 @@ from core.config import DUB_DIR, VOICES_DIR
from core.tasks import task_manager from core.tasks import task_manager
from schemas.requests import DubRequest from schemas.requests import DubRequest
from services.model_manager import get_model, _gpu_pool from services.model_manager import get_model, _gpu_pool
from services.audio_dsp import apply_mastering, normalize_audio from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
from services.audio_io import atomic_save_wav, _safe_torchaudio_save from services.audio_io import atomic_save_wav, _safe_torchaudio_save
from services.rvc import apply_rvc, is_enabled as rvc_is_enabled from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
from services.incremental import segment_fingerprint from services.incremental import segment_fingerprint
@@ -108,7 +108,7 @@ async def dub_generate(job_id: str, req: DubRequest):
sync_scores.append(1.0) sync_scores.append(1.0)
continue continue
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id=None): def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
ref_audio = None ref_audio = None
ref_text = None ref_text = None
used_seed = None used_seed = None
@@ -161,7 +161,21 @@ async def dub_generate(job_id: str, req: DubRequest):
speed=spd, denoise=True, postprocess_output=True, speed=spd, denoise=True, postprocess_output=True,
) )
audio_out = audios[0] audio_out = audios[0]
mastered_audio = apply_mastering(audio_out, sample_rate=_model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000) sr = _model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000
# Apply per-segment DSP effect preset (default: broadcast)
seg_effect_preset = effect_preset or "broadcast"
if seg_effect_preset == "raw":
return audio_out
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
mastered_audio = apply_effects_chain(
mastered_audio,
sample_rate=sr,
chain=effect_chain,
)
return normalize_audio(mastered_audio, target_dBFS=-2.0) return normalize_audio(mastered_audio, target_dBFS=-2.0)
except Exception as e: except Exception as e:
is_oom = ( is_oom = (
@@ -169,7 +183,6 @@ async def dub_generate(job_id: str, req: DubRequest):
or "out of memory" in str(e).lower() or "out of memory" in str(e).lower()
or "CUDA error" in str(e) or "CUDA error" in str(e)
) )
# Always try to reclaim VRAM regardless of error type.
import gc import gc
gc.collect() gc.collect()
if torch.cuda.is_available(): if torch.cuda.is_available():
@@ -178,9 +191,8 @@ async def dub_generate(job_id: str, req: DubRequest):
torch.mps.empty_cache() torch.mps.empty_cache()
if not is_oom: if not is_oom:
raise # Non-OOM — propagate the real error, don't mask it. raise
# OOM recovery: retry once with reduced steps (less VRAM).
retry_steps = min(nstep, 8) retry_steps = min(nstep, 8)
logger.warning( logger.warning(
"OOM on segment (nstep=%d), retrying with %d steps after cache flush", "OOM on segment (nstep=%d), retrying with %d steps after cache flush",
@@ -195,7 +207,20 @@ async def dub_generate(job_id: str, req: DubRequest):
speed=spd, denoise=True, postprocess_output=True, speed=spd, denoise=True, postprocess_output=True,
) )
audio_out = audios[0] audio_out = audios[0]
mastered_audio = apply_mastering(audio_out, sample_rate=_model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000) sr = _model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000
seg_effect_preset = effect_preset or "broadcast"
if seg_effect_preset == "raw":
return audio_out
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
mastered_audio = apply_effects_chain(
mastered_audio,
sample_rate=sr,
chain=effect_chain,
)
return normalize_audio(mastered_audio, target_dBFS=-2.0) return normalize_audio(mastered_audio, target_dBFS=-2.0)
except Exception as retry_err: except Exception as retry_err:
raise RuntimeError( raise RuntimeError(
@@ -204,13 +229,13 @@ async def dub_generate(job_id: str, req: DubRequest):
f"Try the Flush button in the header to free VRAM, " f"Try the Flush button in the header to free VRAM, "
f"or switch to CPU in Settings. " f"or switch to CPU in Settings. "
f"Underlying error: {retry_err}" f"Underlying error: {retry_err}"
) ) from retry_err
seg_instruct = seg.instruct or req.instruct
seg_profile = seg.profile_id or None seg_profile = seg.profile_id or None
seg_speed = seg.speed if hasattr(seg, 'speed') and seg.speed is not None else req.speed seg_speed = seg.speed if hasattr(seg, 'speed') and seg.speed is not None else req.speed
seg_lang = seg.target_lang if getattr(seg, 'target_lang', None) else req.language seg_lang = seg.target_lang if getattr(seg, 'target_lang', None) else req.language
seg_instruct = seg.instruct or req.instruct
# Phase 4.2 — if the segment carries a free-form direction, parse it # Phase 4.2 — if the segment carries a free-form direction, parse it
# and append the taxonomy instruct (e.g. "urgent, surprised") on top # and append the taxonomy instruct (e.g. "urgent, surprised") on top
# of whatever instruct was already set. Also apply the director's # of whatever instruct was already set. Also apply the director's
@@ -240,10 +265,11 @@ async def dub_generate(job_id: str, req: DubRequest):
# flag to restore num_step=req.num_step quality. # flag to restore num_step=req.num_step quality.
_num_step = 8 if req.preview else req.num_step _num_step = 8 if req.preview else req.num_step
_t_tts_0 = time.perf_counter() _t_tts_0 = time.perf_counter()
seg_effect_preset = getattr(seg, "effect_preset", None) or "broadcast"
audio_tensor = await loop.run_in_executor( audio_tensor = await loop.run_in_executor(
_gpu_pool, _gen, _gpu_pool, _gen,
seg.text, seg_lang, seg_instruct, seg_duration, seg.text, seg_lang, seg_instruct, seg_duration,
_num_step, req.guidance_scale, seg_speed, seg_profile, _num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
) )
_t_tts += time.perf_counter() - _t_tts_0 _t_tts += time.perf_counter() - _t_tts_0
@@ -277,6 +303,7 @@ async def dub_generate(job_id: str, req: DubRequest):
"instruct": getattr(seg, "instruct", None), "instruct": getattr(seg, "instruct", None),
"speed": getattr(seg, "speed", None), "speed": getattr(seg, "speed", None),
"direction": getattr(seg, "direction", None), "direction": getattr(seg, "direction", None),
"effect_preset": getattr(seg, "effect_preset", None),
}) })
except Exception as e: except Exception as e:
logger.debug("seg fingerprint skipped for %s: %s", seg_id, e) logger.debug("seg fingerprint skipped for %s: %s", seg_id, e)
+12
View File
@@ -23,6 +23,8 @@ from pydantic import BaseModel
from api.dependencies import require_loopback from api.dependencies import require_loopback
from core import prefs from core import prefs
from services import tts_backend, asr_backend, llm_backend, translation_engines from services import tts_backend, asr_backend, llm_backend, translation_engines
from services.audio_dsp import list_effect_presets
from api.schemas import EffectPresetsResponse
router = APIRouter() router = APIRouter()
@@ -66,6 +68,16 @@ def list_llm_backends():
return {"active": llm_backend.active_backend_id(), "backends": llm_backend.list_backends()} return {"active": llm_backend.active_backend_id(), "backends": llm_backend.list_backends()}
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
def list_effects_presets():
"""Return available DSP effect presets for the dub pipeline.
Each preset is a named chain of audio effects (EQ, compressor, reverb, etc.)
that can be applied to generated TTS audio on a per-segment basis.
"""
return {"presets": list_effect_presets()}
@router.get("/engines/translation") @router.get("/engines/translation")
def list_translation_engines(): def list_translation_engines():
"""Translation engines with per-engine pip-package availability. """Translation engines with per-engine pip-package availability.
+28 -3
View File
@@ -25,8 +25,9 @@ def _run_inference(
model, text, language, ref_audio_path, ref_text, instruct, duration, model, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, t_shift, denoise, num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature, postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, class_temperature, used_seed, effect_preset="broadcast",
): ):
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
import torch import torch
try: try:
if used_seed is not None: if used_seed is not None:
@@ -47,7 +48,30 @@ def _run_inference(
) )
audio_out = audios[0] audio_out = audios[0]
mastered_audio = apply_mastering(audio_out, sample_rate=model.sampling_rate if hasattr(model, 'sampling_rate') else 24000) sr = model.sampling_rate if hasattr(model, 'sampling_rate') else 24000
# Apply DSP effect preset
_effect_preset = effect_preset or "broadcast"
# Validate preset ID
from services.audio_dsp import EFFECT_PRESETS
if _effect_preset not in EFFECT_PRESETS:
raise ValueError(
f"Unknown effect preset: {_effect_preset!r}. "
f"Valid: {list(EFFECT_PRESETS.keys())}"
)
if _effect_preset == "raw":
# Raw: skip all DSP — return raw model output
return audio_out
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
_chain = get_effect_chain(_effect_preset)
if _chain:
mastered_audio = apply_effects_chain(
mastered_audio, sample_rate=sr, chain=_chain,
)
return normalize_audio(mastered_audio, target_dBFS=-2.0) return normalize_audio(mastered_audio, target_dBFS=-2.0)
except ValueError as e: except ValueError as e:
@@ -85,6 +109,7 @@ async def generate_speech(
class_temperature: Optional[float] = Form(None), class_temperature: Optional[float] = Form(None),
profile_id: Optional[str] = Form(None), profile_id: Optional[str] = Form(None),
seed: Optional[int] = Form(None), seed: Optional[int] = Form(None),
effect_preset: str = Form("broadcast"),
): ):
_model = await get_model() _model = await get_model()
@@ -138,7 +163,7 @@ async def generate_speech(
_model, text, language, ref_audio_path, ref_text, instruct, duration, _model, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, t_shift, denoise, num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature, postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, class_temperature, used_seed, effect_preset,
) )
gen_time = round(time.time() - start_time, 2) gen_time = round(time.time() - start_time, 2)
+17
View File
@@ -147,3 +147,20 @@ class ModelEntry(BaseModel):
supported: bool = True supported: bool = True
size_on_disk: int | None = None size_on_disk: int | None = None
nb_files: int | None = None nb_files: int | None = None
# ── Effect presets ─────────────────────────────────────────────────────
class EffectPresetEntry(BaseModel):
"""One DSP effect preset."""
model_config = ConfigDict(extra="allow")
id: str
label: str
icon: str
description: str
class EffectPresetsResponse(BaseModel):
"""GET /engines/effects/presets"""
presets: list[EffectPresetEntry] = Field(default_factory=list)
+14 -1
View File
@@ -1,6 +1,8 @@
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from typing import List, Optional from typing import List, Optional
from services.audio_dsp import EFFECT_PRESETS
class ExportRequest(BaseModel): class ExportRequest(BaseModel):
source_filename: str source_filename: str
destination_path: str destination_path: str
@@ -23,6 +25,17 @@ class DubSegment(BaseModel):
speed: Optional[float] = None speed: Optional[float] = None
gain: Optional[float] = None # Per-segment volume (0.0 - 2.0, default 1.0) gain: Optional[float] = None # Per-segment volume (0.0 - 2.0, default 1.0)
target_lang: Optional[str] = None # Per-segment language override (ISO code) target_lang: Optional[str] = None # Per-segment language override (ISO code)
effect_preset: str = "broadcast" # NEW: DSP preset id (default: broadcast)
@field_validator("effect_preset")
@classmethod
def validate_effect_preset(cls, v: str) -> str:
if v not in EFFECT_PRESETS:
raise ValueError(
f"Unknown effect preset: {v!r}. "
f"Valid: {list(EFFECT_PRESETS.keys())}"
)
return v
class DubRequest(BaseModel): class DubRequest(BaseModel):
segments: List[DubSegment] segments: List[DubSegment]
+15 -2
View File
@@ -42,7 +42,7 @@ class SegmentSpec:
__slots__ = ( __slots__ = (
"index", "text", "language", "instruct", "speed", "duration", "index", "text", "language", "instruct", "speed", "duration",
"num_step", "guidance_scale", "profile_id", "num_step", "guidance_scale", "profile_id",
"ref_audio", "ref_text", "start", "end", "ref_audio", "ref_text", "start", "end", "effect_preset",
) )
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -93,7 +93,7 @@ async def generate_segments_batched(
List of (segment_index, audio_tensor, sample_rate) tuples, List of (segment_index, audio_tensor, sample_rate) tuples,
ordered by segment_index. ordered by segment_index.
""" """
from services.audio_dsp import apply_mastering, normalize_audio from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
sr = getattr(model, "sampling_rate", 24000) sr = getattr(model, "sampling_rate", 24000)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -144,7 +144,20 @@ async def generate_segments_batched(
postprocess_output=True, postprocess_output=True,
) )
audio_out = audios[0] audio_out = audios[0]
# Apply per-segment DSP effect preset (default: broadcast)
seg_effect_preset = getattr(s, "effect_preset", None) or "broadcast"
if seg_effect_preset == "raw":
# Raw: skip all DSP — return raw model output
return audio_out
mastered = apply_mastering(audio_out, sample_rate=sr) mastered = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
mastered = apply_effects_chain(
mastered, sample_rate=sr, chain=effect_chain
)
return normalize_audio(mastered, target_dBFS=-2.0) return normalize_audio(mastered, target_dBFS=-2.0)
audio = await loop.run_in_executor(gpu_pool, _gen_one) audio = await loop.run_in_executor(gpu_pool, _gen_one)
+4 -1
View File
@@ -20,7 +20,7 @@ import hashlib
import json import json
_GEN_INPUT_FIELDS = ("text", "target_lang", "profile_id", "instruct", "speed", "direction") _GEN_INPUT_FIELDS = ("text", "target_lang", "profile_id", "instruct", "speed", "direction", "effect_preset")
def segment_fingerprint(seg: dict) -> str: def segment_fingerprint(seg: dict) -> str:
@@ -29,6 +29,9 @@ def segment_fingerprint(seg: dict) -> str:
Any change to `_GEN_INPUT_FIELDS` flips the hash and the segment becomes Any change to `_GEN_INPUT_FIELDS` flips the hash and the segment becomes
a re-gen candidate. Changes to position / selection state / lip-sync a re-gen candidate. Changes to position / selection state / lip-sync
badge don't trigger regen, which is what we want. badge don't trigger regen, which is what we want.
Currently includes: text, target_lang, profile_id, instruct, speed,
direction, effect_preset.
""" """
payload = {k: (seg.get(k) if seg.get(k) is not None else "") for k in _GEN_INPUT_FIELDS} payload = {k: (seg.get(k) if seg.get(k) is not None else "") for k in _GEN_INPUT_FIELDS}
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
+17
View File
@@ -98,3 +98,20 @@ export async function getJob(id: string): Promise<unknown> {
export async function getJobEvents(id: string, afterSeq: number = 0): Promise<unknown> { export async function getJobEvents(id: string, afterSeq: number = 0): Promise<unknown> {
return apiJson(`/jobs/${id}/events?after_seq=${afterSeq}`); return apiJson(`/jobs/${id}/events?after_seq=${afterSeq}`);
} }
// ── Effect presets ──────────────────────────────────────────────────────
export interface EffectPreset {
id: string;
label: string;
icon: string;
description: string;
}
export interface EffectPresetsResponse {
presets: EffectPreset[];
}
export async function fetchEffectPresets(): Promise<EffectPresetsResponse> {
return apiJson<EffectPresetsResponse>('/engines/effects/presets');
}
+12
View File
@@ -153,6 +153,18 @@ export interface DubHistoryResponse {
jobs: DubJobMeta[]; jobs: DubJobMeta[];
} }
export interface DubSegment {
start: number;
end: number;
text: string;
instruct?: string;
profile_id?: string;
speed?: number;
gain?: number;
target_lang?: string;
effect_preset?: string;
}
export interface DubTranslateResponse { export interface DubTranslateResponse {
segments: { id: string; text: string; text_original?: string; rate_ratio?: number; rate_error?: string }[]; segments: { id: string; text: string; text_original?: string; rate_ratio?: number; rate_error?: string }[];
} }
+16 -1
View File
@@ -15,6 +15,7 @@
* history restore explicitly rehydrates the relevant fields. * history restore explicitly rehydrates the relevant fields.
*/ */
import type { StateCreator } from 'zustand'; import type { StateCreator } from 'zustand';
import type { EffectPreset } from '../api/engines';
export type DubStep = export type DubStep =
| 'idle' | 'idle'
@@ -84,6 +85,12 @@ export interface DubSlice {
source_count: number; source_count: number;
}>; }>;
// ── Effect Presets ────────────────────────────────────────────────────
segmentEffectPresets: Record<string, string>;
setSegmentEffectPreset: (segId: string, presetId: string) => void;
availableEffectPresets: EffectPreset[];
setAvailableEffectPresets: (presets: EffectPreset[]) => void;
// ── Setters (React-style; accept value or updater fn) ───────────────── // ── Setters (React-style; accept value or updater fn) ─────────────────
setDubJobId: (v: Updater<string | null>) => void; setDubJobId: (v: Updater<string | null>) => void;
setDubStep: (v: Updater<DubStep>) => void; setDubStep: (v: Updater<DubStep>) => void;
@@ -115,7 +122,8 @@ const INITIAL: Omit<DubSlice,
| 'setDubProgress' | 'setDubError' | 'setIsTranslating' | 'setDubSegments' | 'setDubProgress' | 'setDubError' | 'setIsTranslating' | 'setDubSegments'
| 'setDubTranscript' | 'setDubFilename' | 'setDubDuration' | 'setDubTracks' | 'setDubTranscript' | 'setDubFilename' | 'setDubDuration' | 'setDubTracks'
| 'setDubLang' | 'setDubLangCode' | 'setDubInstruct' | 'setPreserveBg' | 'setDubLang' | 'setDubLangCode' | 'setDubInstruct' | 'setPreserveBg'
| 'setDefaultTrack' | 'setExportTracks' | 'setPreviewSegIds' | 'setSpeakerClones' | 'resetDubState' | 'setDefaultTrack' | 'setExportTracks' | 'setPreviewSegIds' | 'setSpeakerClones'
| 'setSegmentEffectPreset' | 'setAvailableEffectPresets' | 'resetDubState'
> = { > = {
dubJobId: null, dubJobId: null,
dubStep: 'idle', dubStep: 'idle',
@@ -137,6 +145,8 @@ const INITIAL: Omit<DubSlice,
exportTracks: { original: true }, exportTracks: { original: true },
previewSegIds: [], previewSegIds: [],
speakerClones: {}, speakerClones: {},
segmentEffectPresets: {},
availableEffectPresets: [],
}; };
export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, get) => ({ export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, get) => ({
@@ -162,6 +172,11 @@ export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, ge
setExportTracks: (v) => set((s) => ({ exportTracks: resolve(v, s.exportTracks) })), setExportTracks: (v) => set((s) => ({ exportTracks: resolve(v, s.exportTracks) })),
setPreviewSegIds:(v) => set((s) => ({ previewSegIds:resolve(v, s.previewSegIds) })), setPreviewSegIds:(v) => set((s) => ({ previewSegIds:resolve(v, s.previewSegIds) })),
setSpeakerClones:(v) => set((s) => ({ speakerClones: resolve(v, s.speakerClones) })), setSpeakerClones:(v) => set((s) => ({ speakerClones: resolve(v, s.speakerClones) })),
setSegmentEffectPreset: (segId, presetId) =>
set((s) => ({
segmentEffectPresets: { ...s.segmentEffectPresets, [segId]: presetId },
})),
setAvailableEffectPresets: (presets) => set({ availableEffectPresets: presets }),
resetDubState: () => { resetDubState: () => {
// Touch `get` so strict-mode double-invocation of the initializer doesn't // Touch `get` so strict-mode double-invocation of the initializer doesn't
+139
View File
@@ -0,0 +1,139 @@
"""
Tests for the audio effects chain DSP pipeline.
Pure functions — no GPU or model loading required.
Tests run in seconds on any machine.
Note: sys.path for backend imports is handled by tests/conftest.py.
"""
import pytest
import torch
import math
pedalboard = pytest.importorskip("pedalboard")
from services.audio_dsp import (
apply_effects_chain,
get_effect_chain,
list_effect_presets,
EFFECT_PRESETS,
)
def _make_test_audio(duration_s=1.0, sample_rate=24000) -> torch.Tensor:
"""Create a test audio tensor with a simple sine wave."""
t = torch.linspace(0, duration_s, int(duration_s * sample_rate))
return torch.sin(2 * math.pi * 440 * t).unsqueeze(0) # 440 Hz sine, mono
class TestListEffectPresets:
def test_returns_all_presets(self):
presets = list_effect_presets()
assert len(presets) == 6
ids = [p["id"] for p in presets]
assert "broadcast" in ids
assert "cinematic" in ids
assert "podcast" in ids
assert "raw" in ids
assert "warm" in ids
assert "bright" in ids
def test_preset_has_required_fields(self):
for preset in list_effect_presets():
assert "id" in preset
assert "label" in preset
assert "icon" in preset
assert "description" in preset
class TestGetEffectChain:
def test_broadcast_returns_chain(self):
chain = get_effect_chain("broadcast")
assert len(chain) > 0
types = [fx["type"] for fx in chain]
assert "highpass" in types
assert "compressor" in types
assert "limiter" in types
def test_cinematic_has_reverb(self):
chain = get_effect_chain("cinematic")
types = [fx["type"] for fx in chain]
assert "reverb" in types
def test_raw_returns_empty(self):
chain = get_effect_chain("raw")
assert chain == []
def test_unknown_returns_empty(self):
chain = get_effect_chain("nonexistent")
assert chain == []
class TestApplyEffectsChain:
def test_raw_preset_returns_unmodified(self):
audio = _make_test_audio()
result = apply_effects_chain(audio, sample_rate=24000, chain=[])
assert torch.equal(audio, result)
def test_broadcast_preset_returns_tensor(self):
audio = _make_test_audio()
chain = get_effect_chain("broadcast")
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert isinstance(result, torch.Tensor)
assert result.shape == audio.shape
def test_cinematic_preset_returns_tensor(self):
audio = _make_test_audio()
chain = get_effect_chain("cinematic")
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert isinstance(result, torch.Tensor)
assert result.shape == audio.shape
def test_podcast_preset_returns_tensor(self):
audio = _make_test_audio()
chain = get_effect_chain("podcast")
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert isinstance(result, torch.Tensor)
assert result.shape == audio.shape
def test_warm_preset_returns_tensor(self):
audio = _make_test_audio()
chain = get_effect_chain("warm")
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert isinstance(result, torch.Tensor)
assert result.shape == audio.shape
def test_bright_preset_returns_tensor(self):
audio = _make_test_audio()
chain = get_effect_chain("bright")
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert isinstance(result, torch.Tensor)
assert result.shape == audio.shape
def test_all_presets_produce_output(self):
"""Smoke test: every preset processes without error."""
audio = _make_test_audio()
for preset_id in EFFECT_PRESETS:
chain = get_effect_chain(preset_id)
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert isinstance(result, torch.Tensor)
assert result.shape[0] == 1 # mono
def test_clipping_prevention(self):
"""Output should not exceed [-1.0, 1.0] range after limiter presets."""
audio = _make_test_audio()
for preset_id in ("broadcast", "podcast", "bright"):
chain = get_effect_chain(preset_id)
result = apply_effects_chain(audio, sample_rate=24000, chain=chain)
assert result.abs().max() <= 1.0
def test_different_presets_produce_different_output(self):
"""Different presets should produce audibly different output."""
audio = _make_test_audio(duration_s=2.0)
results = {}
for preset_id in ("broadcast", "cinematic", "raw"):
chain = get_effect_chain(preset_id)
results[preset_id] = apply_effects_chain(audio, sample_rate=24000, chain=chain)
# broadcast and cinematic should differ from raw (unprocessed)
assert not torch.equal(results["broadcast"], results["raw"])
assert not torch.equal(results["cinematic"], results["raw"])