CoolFace
Apppublic

LyndonCatan/audio-forensic-app

sourceHugging Faceupdated 8mo agoView on Hugging Face
1likes
audio_separator.py277 linesDownload Raw Back to scripts
1import sys2import os3import json4import subprocess5import shutil6import warnings7import numpy as np8from scipy.io import wavfile9 10import tempfile11 12# FORCE SILENCE13warnings.filterwarnings("ignore")14os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'15 16def convert_to_wav_if_needed(input_path, log_func):17    """18    Tries to read the file. If it fails, converts to WAV using FFmpeg.19    Returns (path_to_read, is_temp)20    """21    try:22        # Check if readable23        try:24            wavfile.read(input_path)25            return input_path, False26        except Exception:27            log_func(f"Direct read failed, attempting conversion for {input_path}")28            29            tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')30            tmp.close()31            output_path = tmp.name32            33            cmd = [34                "ffmpeg", "-y", 35                "-i", input_path, 36                "-ar", "44100", 37                 output_path38            ]39            # Suppress output40            subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)41            log_func(f"Converted to {output_path}")42            return output_path, True43    except Exception as e:44        log_func(f"Conversion failed: {str(e)}")45        return input_path, False46 47def separate_audio(input_path, output_dir, job_id, classification_path=None):48    debug_log = []49    50    def log(msg):51        debug_log.append(str(msg))52 53    converted_audio_path = None54    is_temp_file = False55 56    try:57        log(f"Start separation. Input: {input_path}, Job: {job_id}")58        input_path = os.path.abspath(input_path.strip('"'))59        output_dir = os.path.abspath(output_dir.strip('"'))60        61        # 0. Ensure Input is Valid WAV62        # Demucs might handle MP3, but since we had ID3 issues, let's normalize first.63        read_path, is_temp = convert_to_wav_if_needed(input_path, log)64        converted_audio_path = read_path65        is_temp_file = is_temp66        67        if classification_path:68            classification_path = os.path.abspath(classification_path.strip('"'))69        70        input_filename = os.path.basename(converted_audio_path)71        input_no_ext = os.path.splitext(input_filename)[0]72        # Demucs output folder is based on the input filename. 73        # If we converted to a temp file 'tmp123.wav', Demucs will output to 'htdemucs/tmp123'.74        # We need to map this back or rename.75        76        # actually, to preserve the job_id or original name context, we might want to check77        # but let's see what Demucs does.78 79        # 1. Run Demucs (In-process to bypass torchaudio.save issues)80        print(f"[Demucs] Loading model htdemucs...", file=sys.stderr)81        82        # Imports inside function to avoid heavy load if not needed83        import torch84        from demucs.pretrained import get_model85        from demucs.apply import apply_model86        import torchaudio.transforms as T87        88        # Load Model89        model = get_model("htdemucs")90        model.cpu()91        model.eval()92        93        # Load Audio via Scipy (safe)94        sr, audio_data = wavfile.read(read_path)95        96        # Convert to float32 and normalize to [-1, 1]97        if audio_data.dtype == np.int16:98             audio_data = audio_data.astype(np.float32) / 32768.099        elif audio_data.dtype == np.int32:100             audio_data = audio_data.astype(np.float32) / 2147483648.0101        elif audio_data.dtype == np.uint8:102             audio_data = (audio_data.astype(np.float32) - 128) / 128.0103             104        # Ensure shape (Channels, Samples)105        if len(audio_data.shape) == 1:106            audio_data = np.expand_dims(audio_data, axis=0) # (1, Samples)107        else:108            audio_data = audio_data.T # (Channels, Samples)109            110        # Demucs expects Stereo (2 channels). Mix/Duplicate if Mono.111        if audio_data.shape[0] == 1:112            audio_data = np.concatenate([audio_data, audio_data], axis=0)113            114        # Convert to Tensor115        wav = torch.tensor(audio_data)116        117        # Resample if needed (Demucs htdemucs is 44100Hz)118        if sr != model.samplerate:119            print(f"[Demucs] Resampling {sr} -> {model.samplerate}Hz", file=sys.stderr)120            resampler = T.Resample(sr, model.samplerate)121            wav = resampler(wav)122            123        # Normalization (Standard Demucs procedure)124        ref = wav.mean(0)125        wav = (wav - ref.mean()) / ref.std()126        127        # Separate128        print(f"[Demucs] Separating...", file=sys.stderr)129        # sources shape: (Sources, Channels, Samples)130        sources = apply_model(model, wav[None], device="cpu", shifts=1, split=True, overlap=0.25, progress=True)[0]131        132        # De-normalize133        sources = sources * ref.std() + ref.mean()134        135        print("[Demucs] Separation finished. Saving stems...", file=sys.stderr)136        137        # Save Stems manually using scipy138        stem_names = model.sources # ['drums', 'bass', 'other', 'vocals'] for htdemucs139        140        # Create output structure matching standard Demucs141        # htdemucs/filename_no_ext/142        demucs_folder_name = os.path.splitext(os.path.basename(read_path))[0]143        separated_folder = os.path.join(output_dir, "htdemucs", demucs_folder_name)144        os.makedirs(separated_folder, exist_ok=True)145        146        sources_np = sources.numpy()147        148        for i, name in enumerate(stem_names):149            stem_audio = sources_np[i] # (Channels, Samples)150            stem_audio = stem_audio.T # (Samples, Channels)151            152            out_file = os.path.join(separated_folder, f"{name}.wav")153            wavfile.write(out_file, model.samplerate, stem_audio)154            155        log(f"Demucs output saved to: {separated_folder}")156 157        # Populate final_stems158        final_stems = {}159        if os.path.exists(os.path.join(separated_folder, "vocals.wav")):160            final_stems["vocals"] = f"/separated_audio/htdemucs/{demucs_folder_name}/vocals.wav"161        if os.path.exists(os.path.join(separated_folder, "other.wav")):162            final_stems["background"] = f"/separated_audio/htdemucs/{demucs_folder_name}/other.wav"163 164 165        # 2. Forensic Event Masking (if classification provided)166        if classification_path and os.path.exists(classification_path):167            try:168                log("Starting forensic masking...")169                with open(classification_path, 'r') as f:170                    classification_data = json.load(f)171                172                log(f"Loaded classification data. Keys: {list(classification_data.keys())}")173                if "status" in classification_data and classification_data["status"] == "error":174                     log(f"Classification ERROR: {classification_data.get('message', 'No message')}")175 176                # Load original Audio (already converted/validated)177                sr, audio_data = wavfile.read(read_path)178                log(f"Loaded audio. Sample rate: {sr}, Shape: {audio_data.shape}")179                180                # Prepare empty containers (silence)181                stems_to_generate = {182                    "vocals": "Human Voice",183                    "background": "Musical Content",184                    "vehicles": "Vehicle Sound",185                    "footsteps": "Footsteps",186                    "animals": "Animal Signal",187                    "wind": "Atmospheric Wind",188                    "gunshots": "Gunshot / Explosion",189                    "screams": "Scream / Aggression",190                    "sirens": "Siren / Alarm",191                    "impact": "Impact / Breach"192                }193                194                # Check what we already have from Demucs195                has_demucs_vocals = "vocals" in final_stems196                has_demucs_background = "background" in final_stems197                198                if len(audio_data.shape) > 1:199                     generated_audio = { key: np.zeros_like(audio_data) for key in stems_to_generate }200                else:201                     generated_audio = { key: np.zeros_like(audio_data) for key in stems_to_generate }202 203                # Iterate events and fill segments204                events = classification_data.get("soundEvents", [])205                log(f"Found {len(events)} sound events.")206                207                CLIP_DURATION = 0.975208                209                count_generated = 0210                for event in events:211                    etype = event.get("type", "")212                    target_stem = None213                    for stem_key, trigger_word in stems_to_generate.items():214                        if trigger_word.lower() == etype.lower():215                            target_stem = stem_key216                            break217                    218                    if target_stem:219                        # Skip forensic generation if Demucs already provided it (higher quality)220                        if target_stem == "vocals" and has_demucs_vocals:221                            continue222                        if target_stem == "background" and has_demucs_background:223                            continue224 225                        start_time = float(event.get("time", 0))226                        end_time = start_time + CLIP_DURATION227                        start_idx = int(start_time * sr)228                        end_idx = int(end_time * sr)229                        start_idx = max(0, start_idx)230                        end_idx = min(len(audio_data), end_idx)231                        232                        if start_idx < end_idx:233                            generated_audio[target_stem][start_idx:end_idx] = audio_data[start_idx:end_idx]234                            count_generated += 1235 236                log(f"Processed {count_generated} event segments matches.")237 238                # Save generated stems239                gen_dir = os.path.join(output_dir, "generated", job_id)240                os.makedirs(gen_dir, exist_ok=True)241                242                for stem_key, audio_arr in generated_audio.items():243                    peak = np.max(np.abs(audio_arr))244                    log(f"Stem {stem_key} peak amplitude: {peak}")245                    246                    if peak > 0:247                        out_file = os.path.join(gen_dir, f"{stem_key}.wav")248                        wavfile.write(out_file, sr, audio_arr)249                        final_stems[stem_key] = f"/separated_audio/generated/{job_id}/{stem_key}.wav"250            251            except Exception as e:252                log(f"Masking Exception: {str(e)}")253 254        if not final_stems:255             log("No stems were generated.")256             return {"status": "error", "message": "Separation failed, no stems found.", "debug": debug_log}257 258        return {"status": "success", "stems": final_stems, "debug": debug_log}259    except Exception as e:260        return {"status": "error", "message": str(e), "debug": debug_log}261    finally:262        if is_temp_file and converted_audio_path and os.path.exists(converted_audio_path):263            try:264                os.unlink(converted_audio_path)265            except:266                pass267 268if __name__ == "__main__":269    # Ensure no other prints exist in this file!270    if len(sys.argv) > 3:271        # Check for optional 4th arg272        cls_path = sys.argv[4] if len(sys.argv) > 4 else None273        result = separate_audio(sys.argv[1], sys.argv[2], sys.argv[3], cls_path)274        sys.stdout.write(json.dumps(result))275    else:276        sys.stdout.write(json.dumps({"status": "error", "message": "Insufficient arguments"}))277    sys.stdout.flush()