File size: 3,631 Bytes
99df98c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
"""Sentence-level audio segmentation using Silero VAD.

Splits a long audio clip into per-sentence WAVs based on natural speech
boundaries. Tuned for sentences (not word-level chunks) by using a longer
silence-duration threshold to merge intra-sentence pauses but split between
sentences.

Usage:
    python scripts/segment_audio.py INPUT_WAV OUTPUT_DIR

Outputs:
    OUTPUT_DIR/seg_001.wav, seg_002.wav, ...
    OUTPUT_DIR/manifest.json    (start, end, duration, filename per segment)
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

import numpy as np
import soundfile as sf
import torch

SAMPLE_RATE = 16000

# Tuned for sentence-level splits (not word-level).
VAD_THRESHOLD = 0.5             # speech probability threshold
MIN_SPEECH_MS = 300             # ignore blips shorter than this
MIN_SILENCE_MS = 500            # silence > this = sentence boundary
SPEECH_PAD_MS = 200             # pad each segment to avoid clipping word edges


def segment(input_wav: Path, out_dir: Path) -> dict:
    out_dir.mkdir(parents=True, exist_ok=True)

    # Load audio
    audio, sr = sf.read(str(input_wav), dtype="float32")
    if sr != SAMPLE_RATE:
        raise ValueError(f"Expected {SAMPLE_RATE} Hz, got {sr}. Re-export with ffmpeg -ar 16000.")
    if audio.ndim > 1:
        audio = audio.mean(axis=1)
    audio_t = torch.from_numpy(audio)

    # Load Silero VAD (cached from prior Phase 1 setup)
    model, utils = torch.hub.load(
        repo_or_dir="snakers4/silero-vad",
        model="silero_vad",
        force_reload=False,
        trust_repo=True,
    )
    get_speech_timestamps = utils[0]

    timestamps = get_speech_timestamps(
        audio_t, model,
        sampling_rate=SAMPLE_RATE,
        threshold=VAD_THRESHOLD,
        min_speech_duration_ms=MIN_SPEECH_MS,
        min_silence_duration_ms=MIN_SILENCE_MS,
        speech_pad_ms=SPEECH_PAD_MS,
    )

    # Write each segment as its own WAV + build manifest
    manifest = []
    for i, ts in enumerate(timestamps, start=1):
        start_s = ts["start"] / SAMPLE_RATE
        end_s = ts["end"] / SAMPLE_RATE
        clip = audio[ts["start"]:ts["end"]]
        fname = f"seg_{i:03d}.wav"
        out_path = out_dir / fname
        sf.write(str(out_path), clip, SAMPLE_RATE, subtype="PCM_16")
        manifest.append({
            "filename": fname,
            "index": i,
            "start_seconds": round(start_s, 3),
            "end_seconds": round(end_s, 3),
            "duration_seconds": round(end_s - start_s, 3),
        })

    manifest_data = {
        "source": str(input_wav),
        "sample_rate": SAMPLE_RATE,
        "vad_settings": {
            "threshold": VAD_THRESHOLD,
            "min_speech_ms": MIN_SPEECH_MS,
            "min_silence_ms": MIN_SILENCE_MS,
            "speech_pad_ms": SPEECH_PAD_MS,
        },
        "n_segments": len(manifest),
        "segments": manifest,
    }
    manifest_path = out_dir / "manifest.json"
    manifest_path.write_text(json.dumps(manifest_data, indent=2), encoding="utf-8")

    return manifest_data


if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: segment_audio.py INPUT_WAV OUTPUT_DIR")
        sys.exit(1)
    in_wav = Path(sys.argv[1])
    out_dir = Path(sys.argv[2])
    if not in_wav.exists():
        print(f"Input not found: {in_wav}")
        sys.exit(1)

    result = segment(in_wav, out_dir)
    print(f"Wrote {result['n_segments']} segments to {out_dir}")
    for s in result["segments"]:
        print(f"  {s['filename']}  {s['start_seconds']:6.2f}s -> {s['end_seconds']:6.2f}s  ({s['duration_seconds']:.2f}s)")