mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-authored-by: Hanzo Dev <[email protected]>
187 lines
5.8 KiB
Python
187 lines
5.8 KiB
Python
"""Audio enhancement tool for noise reduction and cleanup.
|
|
|
|
Provides noise reduction, normalization, and EQ via FFmpeg audio
|
|
filters. Optional pedalboard integration for higher-quality
|
|
processing when available.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
ToolResult,
|
|
ToolStability,
|
|
ToolTier,
|
|
)
|
|
|
|
|
|
PRESETS = {
|
|
"clean_speech": {
|
|
"description": "Noise gate + highpass + compressor + limiter for clean dialogue",
|
|
"af": (
|
|
"highpass=f=80,"
|
|
"lowpass=f=13000,"
|
|
"agate=threshold=0.01:ratio=2:attack=5:release=50,"
|
|
"acompressor=threshold=-20dB:ratio=3:attack=5:release=100,"
|
|
"loudnorm=I=-16:LRA=11:TP=-1.5"
|
|
),
|
|
},
|
|
"noise_reduce": {
|
|
"description": "Aggressive noise reduction for noisy environments",
|
|
"af": (
|
|
"afftdn=nf=-25:nt=w,"
|
|
"highpass=f=100,"
|
|
"loudnorm=I=-16:LRA=11:TP=-1.5"
|
|
),
|
|
},
|
|
"normalize_only": {
|
|
"description": "Loudness normalization without other processing",
|
|
"af": "loudnorm=I=-16:LRA=11:TP=-1.5",
|
|
},
|
|
"podcast": {
|
|
"description": "Podcast-style processing: de-ess, compress, normalize",
|
|
"af": (
|
|
"highpass=f=80,"
|
|
"acompressor=threshold=-18dB:ratio=4:attack=5:release=100:makeup=2,"
|
|
"loudnorm=I=-16:LRA=7:TP=-1.5"
|
|
),
|
|
},
|
|
"broadcast": {
|
|
"description": "Broadcast-standard processing with tight dynamics",
|
|
"af": (
|
|
"highpass=f=80,"
|
|
"lowpass=f=15000,"
|
|
"acompressor=threshold=-24dB:ratio=4:attack=5:release=80:makeup=3,"
|
|
"alimiter=limit=0.95:attack=1:release=10,"
|
|
"loudnorm=I=-24:LRA=7:TP=-2"
|
|
),
|
|
},
|
|
"voice_clarity": {
|
|
"description": "Boost vocal presence with EQ and light compression",
|
|
"af": (
|
|
"highpass=f=80,"
|
|
"equalizer=f=200:t=q:w=1.5:g=-3,"
|
|
"equalizer=f=3000:t=q:w=1.0:g=3,"
|
|
"equalizer=f=5000:t=q:w=1.5:g=2,"
|
|
"acompressor=threshold=-20dB:ratio=2.5:attack=10:release=100,"
|
|
"loudnorm=I=-16:LRA=11:TP=-1.5"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
class AudioEnhance(BaseTool):
|
|
name = "audio_enhance"
|
|
version = "0.1.0"
|
|
tier = ToolTier.CORE
|
|
capability = "audio_processing"
|
|
provider = "ffmpeg"
|
|
stability = ToolStability.EXPERIMENTAL
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.DETERMINISTIC
|
|
|
|
dependencies = ["cmd:ffmpeg"]
|
|
install_instructions = "Install FFmpeg: https://ffmpeg.org/download.html"
|
|
agent_skills = ["ffmpeg", "elevenlabs"]
|
|
|
|
capabilities = [
|
|
"noise_reduction",
|
|
"normalization",
|
|
"compression",
|
|
"eq",
|
|
"speech_cleanup",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["input_path"],
|
|
"properties": {
|
|
"input_path": {"type": "string"},
|
|
"output_path": {"type": "string"},
|
|
"preset": {
|
|
"type": "string",
|
|
"enum": list(PRESETS.keys()),
|
|
"default": "clean_speech",
|
|
},
|
|
"custom_af": {
|
|
"type": "string",
|
|
"description": "Custom FFmpeg audio filter string",
|
|
},
|
|
"audio_codec": {"type": "string", "default": "aac"},
|
|
"audio_bitrate": {"type": "string", "default": "192k"},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=500)
|
|
idempotency_key_fields = ["input_path", "preset", "custom_af"]
|
|
side_effects = ["writes enhanced audio/video to output_path"]
|
|
user_visible_verification = [
|
|
"Listen to enhanced audio and compare with original",
|
|
"Verify speech is clear without artifacts or pumping",
|
|
]
|
|
|
|
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
|
input_path = Path(inputs["input_path"])
|
|
if not input_path.exists():
|
|
return ToolResult(success=False, error=f"Input not found: {input_path}")
|
|
|
|
output_path = Path(
|
|
inputs.get("output_path", str(input_path.with_stem(f"{input_path.stem}_enhanced")))
|
|
)
|
|
audio_codec = inputs.get("audio_codec", "aac")
|
|
audio_bitrate = inputs.get("audio_bitrate", "192k")
|
|
|
|
af = inputs.get("custom_af")
|
|
if not af:
|
|
preset_name = inputs.get("preset", "clean_speech")
|
|
preset = PRESETS.get(preset_name)
|
|
if not preset:
|
|
return ToolResult(success=False, error=f"Unknown preset: {preset_name}")
|
|
af = preset["af"]
|
|
|
|
start = time.time()
|
|
|
|
# Determine if input is video or audio-only
|
|
is_video = input_path.suffix.lower() in {".mp4", ".mkv", ".avi", ".mov", ".webm"}
|
|
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-i", str(input_path),
|
|
"-af", af,
|
|
]
|
|
if is_video:
|
|
cmd.extend(["-c:v", "copy"])
|
|
cmd.extend(["-c:a", audio_codec, "-b:a", audio_bitrate])
|
|
cmd.append(str(output_path))
|
|
|
|
try:
|
|
self.run_command(cmd)
|
|
except Exception as e:
|
|
return ToolResult(success=False, error=f"FFmpeg failed: {e}")
|
|
|
|
elapsed = time.time() - start
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"input": str(input_path),
|
|
"output": str(output_path),
|
|
"preset": inputs.get("preset"),
|
|
"filter": af,
|
|
},
|
|
artifacts=[str(output_path)],
|
|
duration_seconds=round(elapsed, 2),
|
|
)
|
|
|
|
@staticmethod
|
|
def list_presets() -> dict[str, str]:
|
|
"""Return available presets and their descriptions."""
|
|
return {name: p["description"] for name, p in PRESETS.items()}
|