yum-archive/yapBox

A black box for your yapping

git clone https://git.yummers.dev/yum-archive/yapBox

yumStatically link curation TUI89e9376

master
23.0 KiB655 linesraw
1from datetime import datetime
2from pydub import AudioSegment
3
4import gradio as gr
5import math
6import numpy as np
7import os
8import pyaudio
9import subprocess
10import sys
11import time
12import typing
13import vad
14import wave
15
16class Logger:
17    def __init__(self, filename):
18        self.terminal = sys.stdout
19        self.log = open(filename, "w")
20
21    def write(self, message):
22        self.terminal.write(message)
23        self.log.write(message)
24
25    def flush(self):
26        self.terminal.flush()
27        self.log.flush()
28
29    def isatty(self):
30        return False
31
32class AudioStream():
33    FORMAT = pyaudio.paInt16
34    # Size of each frame (audio sample), in bytes. If you change FORMAT, make
35    # sure this stays up to date!
36    FRAME_SZ = 2
37    # Frames per second.
38    FPS = 16000
39    CHANNELS = 1
40    def __init__(self):
41        pass
42
43    def getSamples(self) -> bytes:
44        raise NotImplementedError("getSamples is not implemented!")
45
46class MicStream(AudioStream):
47    CHUNK_SZ = 1024
48
49    def __init__(self, which_mic: str, fps: int = AudioStream.FPS):
50        self.p = pyaudio.PyAudio()
51        self.stream = None
52        self.sample_rate = None
53        # Each time pyaudio gives us audio data, it's in the form of a chunk of
54        # samples. We keep these in a list to keep the audio callback as light
55        # as possible. Whenever downstream layers want data, we collapse the
56        # list into a single array of data (a bytes object).
57        self.chunks = []
58        # If set, incoming frames are simply discarded.
59        self.paused = False
60        self.fps = fps
61
62        print(f"Finding mic {which_mic}", file=sys.stderr)
63
64        got_match = False
65        device_index = -1
66        if not got_match:
67            info = self.p.get_host_api_info_by_index(0)
68            numdevices = info.get('deviceCount')
69            for i in range(0, numdevices):
70                if (self.p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0:
71                    device_name = self.p.get_device_info_by_host_api_device_index(0, i).get('name')
72                    if which_mic in device_name:
73                        print(f"Got matching mic: {device_name}",
74                                file=sys.stderr)
75                        device_index = i
76                        got_match = True
77                        break
78        if not got_match:
79            raise KeyError(f"Mic {which_mic} not found")
80
81        info = self.p.get_device_info_by_host_api_device_index(0, device_index)
82        print(f"Found mic {which_mic}: {info['name']}", file=sys.stderr)
83        self.sample_rate = int(info['defaultSampleRate'])
84        print(f"Mic sample rate: {self.sample_rate}", file=sys.stderr)
85
86        self.stream = self.p.open(
87                rate=self.sample_rate,
88                channels=self.CHANNELS,
89                format=self.FORMAT,
90                input=True,
91                frames_per_buffer=MicStream.CHUNK_SZ,
92                input_device_index=device_index,
93                stream_callback=self.onAudioFramesAvailable)
94
95        self.stream.start_stream()
96
97        AudioStream.__init__(self)
98
99    def pause(self, state: bool = True):
100        self.paused = state
101
102    def dumpMicDevices(self):
103        info = self.p.get_host_api_info_by_index(0)
104        numdevices = info.get('deviceCount')
105
106        for i in range(0, numdevices):
107            if (self.p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0:
108                device_name = self.p.get_device_info_by_host_api_device_index(0, i).get('name')
109                print("Input Device id ", i, " - ", device_name)
110
111    def getMicDevices() -> typing.List[str]:
112        p = pyaudio.PyAudio()
113        info = p.get_host_api_info_by_index(0)
114        numdevices = info.get('deviceCount')
115
116        result = []
117        for i in range(0, numdevices):
118            if (p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0:
119                device_name = p.get_device_info_by_host_api_device_index(0, i).get('name')
120                result.append(device_name)
121        return result
122
123    def onAudioFramesAvailable(self,
124            frames,
125            frame_count,
126            time_info,
127            status_flags):
128        if self.paused:
129            # Don't literally pause, just start returning silence. This allows
130            # the `min_segment_age_s` check to work while paused.
131            n_frames = int(frame_count * self.fps /
132                    float(self.sample_rate))
133            self.chunks.append(np.zeros(n_frames,
134                dtype=np.int16).tobytes())
135            return (frames, pyaudio.paContinue)
136
137        decimated = b''
138        # In pyaudio, a `frame` is a single sample of audio data.
139        frame_len = self.FRAME_SZ
140        next_frame = 0.0
141        # The mic probably has a higher sample rate than Whisper wants, so
142        # decrease the sample rate by dropping samples. Note that this
143        # algorithm only works if the mic's rate is higher than whisper's
144        # expected rate.
145        keep_every = float(self.sample_rate) / self.fps
146        for i in range(frame_count):
147            if i >= next_frame:
148                decimated += frames[i*frame_len:(i+1)*frame_len]
149                next_frame += keep_every
150        self.chunks.append(decimated)
151
152        return (frames, pyaudio.paContinue)
153
154    # Get audio data and the corresponding timestamp.
155    def getSamples(self) -> bytes:
156        chunks = self.chunks
157        self.chunks = []
158        result = b''.join(chunks)
159        return result
160
161class DiskStream(AudioStream):
162    def __init__(self, path: str):
163        fmt = None
164        if path.endswith(".mp3"):
165            fmt = "mp3"
166        elif path.endswith(".wav"):
167            fmt = "wav"
168        else:
169            raise NotImplementedError(f"Requested file type {path} " + \
170                    "is not supported")
171        print(f"Loading audio data", file=sys.stderr)
172        audio = AudioSegment.from_file(path, format=fmt)
173        audio = audio.set_channels(1)
174        audio = audio.set_frame_rate(16000)
175        frames = np.array(audio.get_array_of_samples())
176        frames = np.int16(frames).tobytes()
177
178        self.frames = frames
179        self.fps = 16000
180
181    def getSamples(self) -> bytes:
182        frames = self.frames
183        self.frames = b''
184        return frames
185
186        if len(frames) < nframes:
187            frames += np.zeros(nframes - len(frames), dtype=np.int16).tobytes()
188
189        return frames
190
191class AudioCollector:
192    def __init__(self, stream: AudioStream):
193        self.stream = stream
194        self.frames = b''
195        # Note: by design, this is the only spot where we anchor our timestamps
196        # against the real world. This is done to make it possible to profile
197        # test cases which read from disk (at much faster than real speed) in
198        # the same way that we profile real-time data.
199        self.wall_ts = time.time()
200
201    def getAudio(self) -> bytes:
202        frames = self.stream.getSamples()
203        if frames:
204            self.frames += frames
205        return self.frames
206
207    def dropAudioPrefix(self, dur_s: float) -> bytes:
208        n_bytes = int(dur_s * self.stream.fps) * self.stream.FRAME_SZ
209        n_bytes = min(n_bytes, len(self.frames))
210        cut_portion = self.frames[:n_bytes]
211        self.frames = self.frames[n_bytes:]
212        self.wall_ts += float(n_bytes / self.stream.FRAME_SZ) / self.stream.fps
213        return cut_portion
214
215    def dropAudioPrefixByFrames(self, dur_frames: int) -> bytes:
216        n_bytes = dur_frames * self.stream.FRAME_SZ
217        n_bytes = min(n_bytes, len(self.frames))
218        cut_portion = self.frames[:n_bytes]
219        self.frames = self.frames[n_bytes:]
220        self.wall_ts += float(n_bytes / self.stream.FRAME_SZ) / self.stream.fps
221        return cut_portion
222
223    def keepLast(self, dur_s: float) -> bytes:
224        drop_len = max(0, self.duration() - dur_s)
225        return self.dropAudioPrefix(drop_len)
226
227    def dropAudio(self):
228        self.wall_ts += self.duration()
229        cut_portion = self.frames
230        self.frames = b''
231        return cut_portion
232
233    def duration(self):
234        return len(self.frames) / (self.stream.fps * self.stream.FRAME_SZ)
235
236    def begin(self):
237        return self.wall_ts
238
239    def now(self):
240        return self.begin() + self.duration()
241
242class AudioCollectorFilter:
243    def __init__(self, parent: AudioCollector):
244        self.parent = parent
245        self.stream = self.parent.stream
246
247    def getAudio(self) -> bytes:
248        return self.parent.getAudio()
249    def dropAudioPrefix(self, dur_s: float):
250        return self.parent.dropAudioPrefix(dur_s)
251    def dropAudioPrefixByFrames(self, dur_frames: int):
252        return self.parent.dropAudioPrefixByFrames(dur_frames)
253    def keepLast(self, dur_s):
254        return self.parent.keepLast(dur_s)
255    def dropAudio(self):
256        return self.parent.dropAudio()
257    def duration(self):
258        return self.parent.duration()
259    def begin(self):
260        return self.parent.begin()
261    def now(self):
262        return self.parent.now()
263
264class NormalizingAudioCollector(AudioCollectorFilter):
265    def __init__(self, parent: AudioCollector):
266        AudioCollectorFilter.__init__(self, parent)
267
268    def getAudio(self) -> bytes:
269        audio = self.parent.getAudio()
270
271        audio = AudioSegment(audio, sample_width=self.stream.FRAME_SZ,
272                frame_rate=self.stream.fps, channels=self.stream.CHANNELS)
273        audio = audio.normalize()
274
275        frames = np.array(audio.get_array_of_samples())
276        frames = np.int16(frames).tobytes()
277
278        return frames
279
280class CompressingAudioCollector(AudioCollectorFilter):
281    def __init__(self, parent: AudioCollector):
282        AudioCollectorFilter.__init__(self, parent)
283
284    def getAudio(self) -> bytes:
285        audio = self.parent.getAudio()
286
287        audio = AudioSegment(audio,
288                sample_width=self.stream.FRAME_SZ,
289                frame_rate=self.stream.fps,
290                channels=self.stream.CHANNELS)
291        # subtle compression has a slight positive effect on my benchmark
292        audio = audio.compress_dynamic_range(threshold=-10, ratio=2.0)
293
294        frames = np.array(audio.get_array_of_samples())
295        frames = np.int16(frames).tobytes()
296
297        return frames
298
299class AudioSegmenter:
300    def __init__(self,
301            min_silence_ms=250,
302            max_speech_s=5,
303            stream: AudioStream = None):
304        self.vad_options = vad.VadOptions(
305                min_silence_duration_ms=min_silence_ms,
306                max_speech_duration_s=max_speech_s)
307        self.stream = stream
308        pass
309
310    def segmentAudio(self, audio: bytes):
311        audio = np.frombuffer(audio,
312                dtype=np.int16).flatten().astype(np.float32) / 32768.0
313        return vad.get_speech_timestamps(audio, vad_options=self.vad_options)
314
315    # Returns the stable cutoff (if any) and whether there are any segments.
316    def getStableCutoff(self, audio: bytes) -> typing.Tuple[int, bool]:
317        min_delta_frames = int((self.vad_options.min_silence_duration_ms *
318                self.stream.fps) / 1000)
319        cutoff = None
320
321        last_end = None
322        segments = self.segmentAudio(audio)
323
324        for i in range(len(segments)):
325            s = segments[i]
326            #print(f"s: {s}")
327            #print(f"last_end: {last_end}")
328
329            if last_end:
330                delta_frames = s['start'] - last_end
331                #print(f"delta frames: {delta_frames}")
332                if delta_frames > min_delta_frames:
333                    cutoff = s['start']
334            else:
335                last_end = s['end']
336
337            if i == len(segments) - 1:
338                now = int(len(audio) / self.stream.FRAME_SZ)
339                delta_frames = now - s['end']
340                if delta_frames > min_delta_frames:
341                    cutoff = now - int(min_delta_frames / 2)
342
343        return (cutoff, len(segments) > 0)
344
345def install_in_venv(pkgs: typing.List[str]) -> bool:
346    pkgs_str = " ".join(pkgs)
347    print(f"Installing {pkgs_str}")
348    pip_proc = subprocess.Popen(
349            f"Resources/Python/python.exe -m pip install {pkgs_str} --no-warn-script-location".split(),
350            stdout=subprocess.PIPE,
351            stderr=subprocess.PIPE)
352    pip_stdout, pip_stderr = pip_proc.communicate()
353    pip_stdout = pip_stdout.decode("utf-8")
354    pip_stderr = pip_stderr.decode("utf-8")
355    print(pip_stdout, file=sys.stderr)
356    print(pip_stderr, file=sys.stderr)
357    if pip_proc.returncode != 0:
358        print(f"`pip install {pkgs_str}` exited with {pip_proc.returncode}",
359                file=sys.stderr)
360        return False
361    return True
362
363def saveAudio(audio: bytes, path: str, stream: AudioStream):
364    with wave.open(path, 'wb') as wf:
365        print(f"Saving audio to {path}", file=sys.stderr)
366        wf.setnchannels(stream.CHANNELS)
367        wf.setsampwidth(stream.FRAME_SZ)
368        wf.setframerate(stream.fps)
369        wf.writeframes(audio)
370
371def concatenateWavFiles(output_path):
372    # List all .wav files in the CWD
373    wav_files = [f for f in os.listdir('.') if f.endswith('.wav')]
374
375    # Initialize parameters for wave file
376    params = None
377
378    # Open the output file
379    with wave.open(output_path, 'wb') as output_wav:
380        for wav_file in wav_files:
381            if os.path.abspath(wav_file) == os.path.abspath(output_path):
382                print(f"Skip adding output file ({wav_file}) to itself")
383                continue
384            print(f"Processing {wav_file}")
385            with wave.open(wav_file, 'rb') as input_wav:
386                # Check if parameters are the same for each file
387                if params is None:
388                    params = input_wav.getparams()
389                    output_wav.setparams(params)
390
391                # Read and write frames
392                frames = input_wav.readframes(input_wav.getnframes())
393                output_wav.writeframes(frames)
394
395class AppControl:
396    run = True
397app_ctrl = AppControl()
398
399def recordAudio(
400        mic_device: str,
401        min_volume: float = -1.3,
402        max_volume: float = -0.8
403        ):
404    app_ctrl.run = True
405
406    stream = MicStream(mic_device)
407    stream_hd = MicStream(mic_device, fps=44100)
408
409    collector = AudioCollector(stream)
410    #collector = NormalizingAudioCollector(collector)
411    collector = CompressingAudioCollector(collector)
412
413    collector_hd = AudioCollector(stream_hd)
414    #collector_hd = NormalizingAudioCollector(collector_hd)
415    collector_hd = CompressingAudioCollector(collector_hd)
416
417    min_silence_ms = 250
418    max_speech_s = 30
419    segmenter = AudioSegmenter(
420            min_silence_ms=min_silence_ms,
421            max_speech_s=max_speech_s,
422            stream=stream)
423
424    while app_ctrl.run:
425        audio = collector.getAudio()
426        collector_hd.getAudio()
427        stable_cutoff, has_audio = segmenter.getStableCutoff(audio)
428
429        #print(f"has audio: {has_audio}")
430        #print(f"stable cutoff: {stable_cutoff}")
431
432        if has_audio and stable_cutoff:
433            commit_audio = collector.dropAudioPrefixByFrames(stable_cutoff)
434            print(f"stable cutoff: {stable_cutoff}")
435            hd_cutoff = int(math.floor(stable_cutoff * stream_hd.fps /
436                stream.fps))
437            print(f"hd cutoff: {hd_cutoff}")
438            commit_audio_hd = collector_hd.dropAudioPrefixByFrames(hd_cutoff)
439            print(f"hd audio len: {len(commit_audio_hd)}")
440
441            # Calculate naive measure of volume
442            audio_v = AudioSegment(commit_audio_hd,
443                    sample_width=stream_hd.FRAME_SZ,
444                    frame_rate=stream_hd.fps,
445                    channels=stream_hd.CHANNELS)
446            audio_v = np.array(audio_v.get_array_of_samples())
447            audio_v = np.int16(audio_v)
448            audio_v = np.sqrt(np.mean(np.square(audio_v)))
449            audio_v /= np.sqrt(len(commit_audio_hd) / stream_hd.FRAME_SZ)
450            audio_v = math.log(audio_v, 10)
451            print(f"volume: {audio_v}")
452            # cutoff is a fine-tuned value based on volumes seen while in vr
453            # (index mic)
454            if audio_v < min_volume or audio_v > max_volume:
455                # Discard sample
456                print("Discarding too-quiet/too-loud segment")
457                collector.keepLast(1.0)
458                collector_hd.keepLast(1.0)
459                continue
460
461
462            ts = datetime.fromtimestamp(time.time())
463            filename = str(ts.strftime('%Y_%m_%d__%H-%M-%S')) + ".wav"
464            saveAudio(commit_audio_hd, filename, stream_hd)
465
466        if not has_audio:
467            #print("VAD detects no audio, skip transcription", file=sys.stderr)
468            collector.keepLast(1.0)
469            collector_hd.keepLast(1.0)
470    print("Stopped recording")
471
472class Segment:
473    def __init__(self,
474            transcript: str,
475            start_ts: float,
476            end_ts: float,
477            wall_ts: float,
478            avg_logprob: float,
479            no_speech_prob: float,
480            compression_ratio: float):
481        self.transcript = transcript
482        # start_ts, end_ts are timestamps in seconds relative to `wall_ts`.
483        self.start_ts = start_ts
484        self.end_ts = end_ts
485        # wall_ts is the time.time() at which the oldest audio sample leading
486        # to this transcript was collected.
487        self.wall_ts = wall_ts
488        self.avg_logprob = avg_logprob
489        self.no_speech_prob = no_speech_prob
490        self.compression_ratio = compression_ratio
491
492    def __str__(self):
493        ts = f"(ts: {self.start_ts}-{self.end_ts}) "
494
495        wall_ts_start = datetime.utcfromtimestamp(self.start_ts + self.wall_ts).strftime('%H:%M:%S')
496        wall_ts_end = datetime.utcfromtimestamp(self.end_ts + self.wall_ts).strftime('%H:%M:%S')
497        wall_ts = f"(wall ts: {wall_ts_start}-{wall_ts_end}) "
498
499        no_speech = f"(no_speech: {self.no_speech_prob}) "
500        avg_logprob = f"(avg_logprob: {self.avg_logprob}) "
501        return f"{self.transcript} " + ts + wall_ts + no_speech + avg_logprob
502
503def pipInstall(pkgs: typing.List[str]) -> bool:
504    pkgs_str = " ".join(pkgs)
505    print(f"Installing {pkgs_str}")
506    env = os.environ.copy()
507    # cwd is set at top of __main__. We set PATH to ensure that installed
508    # Python packages have access to any binaries that come with them.
509    env["PATH"] = os.getcwd() + "/Python/Scripts;" + env['PATH']
510    pip_proc = subprocess.Popen(
511            f"./Python/python.exe -m pip install {pkgs_str} --no-warn-script-location".split(),
512            stdout=subprocess.PIPE,
513            stderr=subprocess.PIPE,
514            env=env)
515    pip_stdout, pip_stderr = pip_proc.communicate()
516    pip_stdout = pip_stdout.decode("utf-8")
517    pip_stderr = pip_stderr.decode("utf-8")
518    print(pip_stdout, file=sys.stderr)
519    print(pip_stderr, file=sys.stderr)
520    if pip_proc.returncode != 0:
521        print(f"`pip install {pkgs_str}` exited with {pip_proc.returncode}",
522                file=sys.stderr)
523        return False
524    return True
525
526class Whisper:
527    def __init__(self,
528            collector: AudioCollector):
529        self.collector = collector
530
531        import torch
532        from transformers import pipeline
533
534        whisper_model = "openai/whisper-large-v2"
535        print(f"Loading pipeline for {whisper_model}...")
536        self.pipe = pipeline(
537                "automatic-speech-recognition",
538                model="distil-whisper/distil-large-v2",
539                torch_dtype=torch.float16,
540                device="cuda",
541                )
542        print(f"Done.")
543
544    def transcribe(self, frames: bytes = None) -> typing.List[Segment]:
545        if frames is None:
546            frames = self.collector.getAudio()
547        # Convert from signed 16-bit int [-32768, 32767] to signed 32-bit float on
548        # [-1, 1].
549        audio = np.frombuffer(frames,
550                dtype=np.int16).flatten().astype(np.float32) / 32768.0
551
552        t0 = time.time()
553        res = self.pipe(
554                audio,
555                chunk_length_s=30,
556                batch_size=1)
557
558        result = [Segment(res["text"],
559            0,
560            0,
561            self.collector.begin(),
562            0,
563            0,
564            0)]
565
566        t1 = time.time()
567        print(f"Transcription latency (s): {t1 - t0}: {result[0].transcript}")
568        return result
569
570def getOutput() -> str:
571    sys.stdout.flush()
572    with open("output.log", "r") as f:
573        return f.read()
574
575def stopApp():
576    print("Requesting app stop")
577    app_ctrl.run = False
578
579def transcribeAudio(concatenated_path: str):
580    # Step 1: Install Whisper requirements
581    print("Installing Whisper dependencies, this will take several minutes")
582    with open("whisper_requirements.txt", "r") as file:
583        requirements = file.read().splitlines()
584    if not pipInstall(requirements):
585        return
586
587    # Step 2: Iterate over .wav files in the current working directory
588    print("Loading Whisper model, this will take several minutes")
589    whisper = Whisper(None)
590    for wav_file in os.listdir('.'):
591        if wav_file.endswith('.wav'):
592            if wav_file.endswith(os.path.basename(concatenated_path)):
593                print("Skipping concatenated file")
594                continue
595
596            # Step 3: Transcription pipeline
597            # TODO parameterize high fidelity framerate
598            print(f"Transcribing {wav_file}")
599            disk_stream = DiskStream(wav_file)
600            collector = CompressingAudioCollector(AudioCollector(disk_stream))
601            whisper.collector = collector
602
603            transcript_filename = wav_file.replace('.wav', '.txt')
604            if os.path.exists(transcript_filename):
605                print(f"Transcript already exists - skipping")
606                continue
607
608            # Transcribe the audio
609            segments = whisper.transcribe()
610
611            # Step 4: Save transcriptions
612            with open(transcript_filename, 'w') as txt_file:
613                for segment in segments:
614                    txt_file.write(segment.transcript + '\n')
615            print(f"Transcript generated at {transcript_filename}")
616
617if __name__ == "__main__":
618    abspath = os.path.abspath(__file__)
619    dname = os.path.dirname(abspath)
620    os.chdir(dname)
621
622    sys.stdout = Logger("output.log")
623
624    print(f"Set cwd to {os.getcwd()}", file=sys.stderr)
625
626    with gr.Blocks() as demo:
627        mic_choices = MicStream.getMicDevices()
628        mic_device = gr.Dropdown(choices=mic_choices, label="Microphone")
629        min_volume = gr.Number(label="Minimum volume", value=-1.3)
630        max_volume = gr.Number(label="Maximum volume", value=-0.8)
631        record_audio = gr.Button("Record audio")
632        stop_recording = gr.Button("Stop recording")
633        transcribe_audio = gr.Button("Transcribe audio")
634        concatenated_path = gr.Text(label="Combined audio filename", value="combined.wav")
635        min_length = gr.Number(label="Minimum length (seconds)", value=3.0)
636        concatenate_audio = gr.Button("Combine audio files")
637
638        dbg_output = gr.Text(label="Output")
639
640        record_audio.click(recordAudio, [mic_device, min_volume, max_volume],
641                dbg_output)
642        stop_recording.click(stopApp, [], dbg_output)
643
644        transcribe_audio.click(transcribeAudio, [concatenated_path], dbg_output)
645
646        concatenate_audio.click(concatenateWavFiles, [concatenated_path],
647                dbg_output)
648
649        demo.load(getOutput, None, dbg_output, every=0.5)
650    demo.launch()
651    sys.exit(0)
652
653    concatenateWavFiles("concatenated.wav")
654    sys.exit(0)
655