File size: 6,676 Bytes
36ba721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#!/usr/bin/env python
"""Machine-fault-detector: audio file in -> diagnosis (or transcript) -> spoken reply WAV.

  pip install -r requirements.txt
  python inference.py clip.wav                       # -> clip_reply.wav + printed text
  python inference.py clip.wav --out reply.wav --no-voice

Loads openai/whisper-large-v3 + this LoRA adapter, decodes like training (16 kHz mono,
<= 30 s chunks, peak -1 dBFS, greedy, suppress_tokens=[] because ':' and ';' are template
tokens). Per chunk:
  * a complete machine template -> "Sound check. This is a fan under high load. ..."
  * otherwise speech            -> "I heard: <transcript>"
  * noise / known hallucination -> silent
The reply is spoken with facebook/mms-tts-eng (CC-BY-NC-4.0). Research prototype: trained
on synthetic machine sounds only; do not use for maintenance or safety decisions.
"""

import argparse
import copy
import re
import sys
from math import gcd
from pathlib import Path

import numpy as np
import soundfile as sf
import torch
from scipy.signal import resample_poly

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from diagnosis import describe, parse_diagnosis  # noqa: E402
from tts_text import speakable, split_sentences  # noqa: E402

SR = 16000
REPO = "muruganmath/Machine-fault-detector"
# outputs that are noise, not speech (measured on the training project's clips)
NOT_SPEECH = re.compile(r"^\s*(?:(?:(?:machine|engine)(?:\s+sounds?)?)[\s.,;:]*)+$", re.I)
HALLUCINATIONS = {"you", "so", "the", "okay", "ok", "bye", "uh", "um", "hmm", "oh", "thank you",
                  "thanks", "thank you very much", "thanks for watching", "thank you for watching"}


def load_audio(path: str) -> np.ndarray:
    x, sr = sf.read(path, dtype="float32", always_2d=True)
    x = x.mean(axis=1)
    if sr != SR:
        g = gcd(sr, SR)
        x = resample_poly(x, SR // g, sr // g).astype(np.float32)
    return x


def chunks(x: np.ndarray, max_s: float = 30.0):
    n = int(max_s * SR)
    for i in range(0, len(x), n):
        c = x[i:i + n]
        if c.size >= SR and 20 * np.log10(max(float(np.sqrt(np.mean(c ** 2))), 1e-10)) >= -60.0:
            yield i / SR, c                       # same rejection as training: < 1 s or silent


class Detector:
    def __init__(self, repo: str = REPO, base: str = "openai/whisper-large-v3", device: str | None = None):
        from peft import PeftModel
        from transformers import WhisperForConditionalGeneration, WhisperProcessor

        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
        self.dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
        self.proc = WhisperProcessor.from_pretrained(base)
        model = WhisperForConditionalGeneration.from_pretrained(base, dtype=self.dtype, attn_implementation="sdpa")
        self.model = PeftModel.from_pretrained(model, repo).merge_and_unload().to(self.device).eval()
        gc = copy.deepcopy(self.model.generation_config)
        gc.suppress_tokens, gc.language, gc.task, gc.forced_decoder_ids = [], "en", "transcribe", None
        self.gen_config = gc

    @torch.inference_mode()
    def decode(self, chunk: np.ndarray) -> str:
        x = chunk * (10 ** (-1.0 / 20) / max(float(np.abs(chunk).max()), 1e-9))        # peak -1 dBFS
        feats = self.proc.feature_extractor([x], sampling_rate=SR, return_tensors="pt").input_features
        out = self.model.generate(input_features=feats.to(self.device, self.dtype), generation_config=self.gen_config,
                                  max_new_tokens=128, num_beams=1)
        return self.proc.tokenizer.batch_decode(out, skip_special_tokens=True)[0].strip()


def reply_for(text: str) -> tuple[str, str | None]:
    """(kind, reply text or None)."""
    d = parse_diagnosis(text)
    if d is not None:
        return "machine", describe(d)
    key = re.sub(r"\s+", " ", re.sub(r"[^a-z0-9' ]", " ", text.lower())).strip()
    if not re.search(r"[A-Za-z0-9]", text) or NOT_SPEECH.match(text) or key in HALLUCINATIONS:
        return "noise", None
    return "speech", f"I heard: {text}"


class Voice:
    def __init__(self, repo: str = "facebook/mms-tts-eng", device: str | None = None):
        from transformers import AutoTokenizer, VitsModel
        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
        self.model = VitsModel.from_pretrained(repo).to(self.device).eval()
        self.tok = AutoTokenizer.from_pretrained(repo)
        self.sr = int(self.model.config.sampling_rate)

    @torch.inference_mode()
    def speak(self, text: str) -> np.ndarray:
        parts, gap = [], np.zeros(int(0.12 * self.sr), np.float32)
        for s in split_sentences(speakable(text)):
            ids = self.tok(s, return_tensors="pt").to(self.device)
            if ids["input_ids"].shape[-1] == 0:
                continue
            torch.manual_seed(0)                   # VITS is stochastic; keep replies reproducible
            parts += [self.model(**ids).waveform[0].float().cpu().numpy(), gap]
        if not parts:
            return np.zeros(0, np.float32)
        y = np.concatenate(parts[:-1])
        return (y * min(1.0, 0.9 / max(float(np.abs(y).max()), 1e-9))).astype(np.float32)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("audio")
    ap.add_argument("--out", help="reply WAV (default: <audio>_reply.wav next to the input)")
    ap.add_argument("--repo", default=REPO, help="adapter repo id or local folder")
    ap.add_argument("--base", default="openai/whisper-large-v3", help="base model repo id or local folder")
    ap.add_argument("--tts", default="facebook/mms-tts-eng", help="TTS repo id or local folder")
    ap.add_argument("--no-voice", action="store_true", help="print the text only")
    args = ap.parse_args()

    det = Detector(args.repo, args.base)
    replies = []
    for t0, c in chunks(load_audio(args.audio)):
        text = det.decode(c)
        kind, reply = reply_for(text)
        print(f"[{t0:6.1f}s] {kind:7s} model: {text}")
        print(f"{'':16}reply: {reply or '(silent)'}")
        if reply:
            replies.append(reply)
    if args.no_voice or not replies:
        return
    voice = Voice(args.tts)
    gap = np.zeros(int(0.4 * voice.sr), np.float32)
    wav = np.concatenate([np.concatenate([voice.speak(r), gap]) for r in replies])[: -gap.size]
    out = args.out or str(Path(args.audio).with_name(Path(args.audio).stem + "_reply.wav"))
    sf.write(out, wav, voice.sr, subtype="PCM_16")
    print(f"voice reply: {out} ({wav.size / voice.sr:.1f} s)")


if __name__ == "__main__":
    main()