call-qa-processing / ml-services /scripts /transcribe_channels.py
aniketqxp's picture
feat: add processing service runtime
f1ef7e2
Raw
History Blame Contribute Delete
5.38 kB
"""
Phase 1 transcriber: PER-CHANNEL transcription (+ Phase 0 confidence capture).
Rationale
---------
The mono-mix approach blends both speakers, then *guesses* who spoke using RMS
energy. That guess is the dominant error source: ~200 correctly-transcribed words
land in the wrong speaker bucket on bad calls, each counted as a deletion+insertion.
The channels are physically separate microphone tracks (ch1 = agent, ch2 =
customer). So instead of mixing and guessing, we transcribe EACH channel
independently. The channel identity IS the speaker label -- attribution stops
being a heuristic and becomes a structural property of the data.
The original reason we avoided this (isolated channels are 53-84% silence ->
timestamp drift) is handled by faster-whisper's built-in Silero VAD
(vad_filter=True), which removes silence before transcription and remaps the
returned timestamps back onto the original recording timeline.
Phase 0 instrumentation
-----------------------
Every word carries its decoding confidence (`prob`) plus its parent segment's
`no_speech_prob` (ns) and `avg_logprob` (alp). These are captured but NOT used to
filter here -- storing them lets us experiment with confidence thresholds in
post-processing (e.g. dropping Whisper's silence-hallucinations) WITHOUT
re-transcribing. That is the whole point of instrumenting first.
The INITIAL_PROMPT is kept identical to run_batch.py so that "per-channel" is the
ONLY variable changing versus the mono-mix baseline -- a clean controlled A/B.
"""
import numpy as np
import soundfile as sf
SR = 16000
INITIAL_PROMPT = (
"Banking and customer service call between an agent and a customer. "
"Topics include accounts, transfers, payments, balances, and account numbers."
)
def _load_mono(path):
a, sr = sf.read(path, dtype="float32")
if a.ndim > 1:
a = a.mean(axis=1)
return a, sr
def transcribe_channel(model, audio, speaker, decode=None):
"""
Transcribe one isolated channel. Returns (words, segments_meta).
words: list of {word, start, end, prob, ns, alp}
segments_meta: list of {start, end, text, avg_logprob, no_speech_prob,
compression_ratio, n_words} -- the Phase 0 record.
decode: optional dict of overrides passed to model.transcribe(). Keys like
vad_filter, beam_size, condition_on_previous_text, no_speech_threshold,
etc. A nested "vad_parameters" dict is MERGED onto the defaults so you
can tweak just threshold/min_silence without restating the whole dict.
Defaults reproduce the original Phase-1 config exactly.
"""
opts = dict(
language="en",
beam_size=5,
word_timestamps=True,
vad_filter=True,
vad_parameters={"min_silence_duration_ms": 500},
initial_prompt=INITIAL_PROMPT,
)
if decode:
decode = dict(decode)
vp = decode.pop("vad_parameters", None)
if vp:
opts["vad_parameters"] = {**(opts.get("vad_parameters") or {}), **vp}
opts.update(decode)
# if VAD is disabled, drop vad_parameters so faster-whisper doesn't warn
if opts.get("vad_filter") is False:
opts.pop("vad_parameters", None)
seg_gen, _info = model.transcribe(audio, **opts)
words, segments_meta = [], []
for seg in seg_gen:
ns = round(float(seg.no_speech_prob), 4)
alp = round(float(seg.avg_logprob), 4)
n_seg_words = 0
if seg.words:
for w in seg.words:
if w.start is None or w.end is None:
continue
words.append({
"word": w.word.strip(),
"start": round(w.start, 3),
"end": round(w.end, 3),
"prob": round(float(w.probability), 4),
"ns": ns,
"alp": alp,
})
n_seg_words += 1
segments_meta.append({
"start": round(seg.start, 3),
"end": round(seg.end, 3),
"text": seg.text.strip(),
"avg_logprob": alp,
"no_speech_prob": ns,
"compression_ratio": round(float(seg.compression_ratio), 4),
"n_words": n_seg_words,
})
return words, segments_meta
def transcribe_call(model, agent_path, customer_path, preprocess_fn=None, decode=None):
"""
Per-channel transcription of one call.
preprocess_fn: optional callable(audio)->audio applied to each channel before
transcription (Phase 2: high-pass + loudness normalization).
decode: optional dict of model.transcribe() overrides (see transcribe_channel).
Returns dict pieces: (agent_words, customer_words, segments, duration_s).
"""
a_audio, a_sr = _load_mono(agent_path)
c_audio, c_sr = _load_mono(customer_path)
if preprocess_fn is not None:
a_audio = preprocess_fn(a_audio)
c_audio = preprocess_fn(c_audio)
agent_words, agent_segs = transcribe_channel(model, a_audio, "agent", decode)
customer_words, customer_segs = transcribe_channel(model, c_audio, "customer", decode)
duration = max(len(a_audio) / SR, len(c_audio) / SR)
segments = {"agent": agent_segs, "customer": customer_segs}
return agent_words, customer_words, segments, duration