374 lines
12 KiB
Python
374 lines
12 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
import click
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
|
|
from config import config
|
|
from ingestor.novofon_api import NovofonApiClient
|
|
from ingestor.downloader import download_audio
|
|
from audio.preprocessor import convert_to_wav, split_stereo_channels
|
|
from audio.stt import transcribe
|
|
from aligner.aligner import align
|
|
from storage.contacts import resolve_contacts, resolve_contact_name
|
|
from storage.exporter import export_to_json
|
|
from storage.directus import DirectusClient
|
|
|
|
|
|
# ── Audio helpers ──────────────────────────────────────────────────
|
|
|
|
def _get_wav_channels(wav_path: str) -> int:
|
|
import wave
|
|
try:
|
|
with wave.open(wav_path, "rb") as wf:
|
|
return wf.getnchannels()
|
|
except Exception:
|
|
return 2
|
|
|
|
|
|
def _is_mono_duplicate(wav_path: str, threshold: float = 0.98) -> bool:
|
|
import wave
|
|
import struct
|
|
|
|
try:
|
|
with wave.open(wav_path, "rb") as wf:
|
|
if wf.getnchannels() != 2:
|
|
return False
|
|
sampwidth = wf.getsampwidth()
|
|
framerate = wf.getframerate()
|
|
nframes = wf.getnframes()
|
|
|
|
total = min(nframes, framerate * 3)
|
|
raw = wf.readframes(total)
|
|
|
|
diffs = 0
|
|
non_silent = 0
|
|
for i in range(total):
|
|
offset = i * 2 * sampwidth
|
|
if sampwidth == 2:
|
|
left = struct.unpack_from("<h", raw, offset)[0]
|
|
right = struct.unpack_from("<h", raw, offset + 2)[0]
|
|
else:
|
|
left = struct.unpack_from("<b", raw, offset)[0]
|
|
right = struct.unpack_from("<b", raw, offset + 1)[0]
|
|
|
|
if abs(left) < 50 and abs(right) < 50:
|
|
continue
|
|
non_silent += 1
|
|
if abs(left - right) > 100:
|
|
diffs += 1
|
|
|
|
if non_silent == 0:
|
|
return True
|
|
return diffs / non_silent < (1 - threshold)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ── Transcription pipeline ─────────────────────────────────────────
|
|
|
|
def _transcribe_stereo(wav_path: str, meta: dict) -> dict:
|
|
channels = _get_wav_channels(wav_path)
|
|
|
|
if channels == 1:
|
|
click.echo(" Mono file detected, single transcription.")
|
|
left_segments, left_text = transcribe(
|
|
wav_path,
|
|
model_size=config.WHISPER_MODEL_SIZE,
|
|
device=config.WHISPER_DEVICE,
|
|
compute_type=config.WHISPER_COMPUTE_TYPE,
|
|
)
|
|
final_segments = []
|
|
for seg in left_segments:
|
|
final_segments.append({
|
|
"start": seg["start"],
|
|
"end": seg["end"],
|
|
"speaker": "unknown",
|
|
"text": seg["text"].strip(),
|
|
})
|
|
is_stereo = False
|
|
elif _is_mono_duplicate(wav_path):
|
|
click.echo(" Stereo with duplicate channels, treating as mono.")
|
|
left_segments, left_text = transcribe(
|
|
wav_path,
|
|
model_size=config.WHISPER_MODEL_SIZE,
|
|
device=config.WHISPER_DEVICE,
|
|
compute_type=config.WHISPER_COMPUTE_TYPE,
|
|
)
|
|
final_segments = []
|
|
for seg in left_segments:
|
|
final_segments.append({
|
|
"start": seg["start"],
|
|
"end": seg["end"],
|
|
"speaker": "unknown",
|
|
"text": seg["text"].strip(),
|
|
})
|
|
is_stereo = False
|
|
else:
|
|
click.echo(" Real stereo, splitting channels...")
|
|
left_wav, right_wav = split_stereo_channels(wav_path, config.TEMP_DIR)
|
|
|
|
click.echo(" Transcribing left channel (callee)...")
|
|
left_segments, left_text = transcribe(
|
|
left_wav,
|
|
model_size=config.WHISPER_MODEL_SIZE,
|
|
device=config.WHISPER_DEVICE,
|
|
compute_type=config.WHISPER_COMPUTE_TYPE,
|
|
)
|
|
|
|
click.echo(" Transcribing right channel (caller)...")
|
|
right_segments, right_text = transcribe(
|
|
right_wav,
|
|
model_size=config.WHISPER_MODEL_SIZE,
|
|
device=config.WHISPER_DEVICE,
|
|
compute_type=config.WHISPER_COMPUTE_TYPE,
|
|
)
|
|
|
|
click.echo(" Aligning speakers & text...")
|
|
final_segments = align(left_segments, right_segments)
|
|
is_stereo = True
|
|
|
|
caller_name = resolve_contact_name(meta.get("caller_number"))
|
|
callee_name = resolve_contact_name(meta.get("callee_number"))
|
|
caller_number = meta.get("caller_number") or ""
|
|
callee_number = meta.get("callee_number") or ""
|
|
|
|
full_text = ""
|
|
for seg in final_segments:
|
|
spk = seg["speaker"]
|
|
if spk == "caller":
|
|
label = caller_name or caller_number or "Звонящий"
|
|
elif spk == "callee":
|
|
label = callee_name or callee_number or "Принимающий"
|
|
else:
|
|
label = "?"
|
|
full_text += f"[{label}]: {seg['text']}\n"
|
|
|
|
contact_info = resolve_contacts(
|
|
meta.get("caller_number"),
|
|
meta.get("callee_number"),
|
|
)
|
|
|
|
call_data = {
|
|
**meta,
|
|
**contact_info,
|
|
"transcript": full_text,
|
|
"segments": final_segments,
|
|
"status": "processed",
|
|
"raw_metadata": meta,
|
|
}
|
|
|
|
try:
|
|
dc = DirectusClient()
|
|
if dc.is_available():
|
|
collection = _choose_collection(meta)
|
|
dc.create_call(call_data, collection=collection)
|
|
click.echo(f" Saved to Directus ({collection}).")
|
|
dc.close()
|
|
except Exception as e:
|
|
click.echo(f" Directus save error: {e}")
|
|
|
|
to_remove = [wav_path]
|
|
if is_stereo:
|
|
to_remove.extend([left_wav, right_wav])
|
|
for f in to_remove:
|
|
if f and os.path.exists(f):
|
|
os.remove(f)
|
|
|
|
return call_data
|
|
|
|
|
|
def _process_call(audio_path: str, meta: dict) -> dict:
|
|
click.echo(" Converting audio...")
|
|
wav_path, channels = convert_to_wav(audio_path, config.TEMP_DIR)
|
|
return _transcribe_stereo(wav_path, meta)
|
|
|
|
|
|
def _get_audio_file(meta: dict, temp_dir: str) -> Optional[str]:
|
|
url = meta.get("audio_url")
|
|
if url:
|
|
return download_audio(url, temp_dir)
|
|
return None
|
|
|
|
|
|
def _is_already_processed(call_id: str) -> bool:
|
|
dc = DirectusClient()
|
|
try:
|
|
for col in ["calls", "calls_74951284933"]:
|
|
resp = dc._client.get(f"/items/{col}", params={
|
|
"filter[call_id][_eq]": call_id,
|
|
"limit": 1, "fields": "id",
|
|
})
|
|
if resp.status_code == 200 and resp.json().get("data"):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
dc.close()
|
|
return False
|
|
|
|
|
|
def _choose_collection(meta: dict) -> str:
|
|
caller = meta.get("caller_number") or ""
|
|
callee = meta.get("callee_number") or ""
|
|
for num in [caller, callee]:
|
|
cleaned = num.replace("+", "").replace("-", "").replace(" ", "")
|
|
if "74951284933" in cleaned:
|
|
return "calls_74951284933"
|
|
return "calls"
|
|
|
|
|
|
# ── API batch processing ───────────────────────────────────────────
|
|
|
|
def _run_api_batch(
|
|
limit: Optional[int] = None,
|
|
date_from: Optional[str] = None,
|
|
date_till: Optional[str] = None,
|
|
) -> int:
|
|
api = NovofonApiClient()
|
|
|
|
if not date_from:
|
|
date_from = config.NOVOFON_DATE_FROM
|
|
|
|
click.echo(f"Fetching calls from {date_from}...")
|
|
calls = api.get_all_calls(date_from, date_till)
|
|
api.close()
|
|
click.echo(f"Found {len(calls)} calls via API.")
|
|
|
|
tasks = []
|
|
for meta in calls:
|
|
if _is_already_processed(meta.call_id):
|
|
continue
|
|
if not meta.audio_url:
|
|
continue
|
|
tasks.append(meta)
|
|
if limit and len(tasks) >= limit:
|
|
break
|
|
|
|
click.echo(f"New calls with audio to process: {len(tasks)}")
|
|
|
|
if not tasks:
|
|
click.echo("Nothing new to process.")
|
|
return 0
|
|
|
|
click.echo(f"\n=== Download + convert ({len(tasks)} files) ===")
|
|
prepared = []
|
|
for meta in tasks:
|
|
try:
|
|
click.echo(f" Preparing {meta.call_id}...")
|
|
audio_path = _get_audio_file(
|
|
{"audio_url": meta.audio_url},
|
|
config.TEMP_DIR,
|
|
)
|
|
if not audio_path:
|
|
click.echo(" No audio, skipping.")
|
|
continue
|
|
wav_path, _ = convert_to_wav(audio_path, config.TEMP_DIR)
|
|
if audio_path != wav_path and os.path.exists(audio_path):
|
|
os.remove(audio_path)
|
|
prepared.append((meta, wav_path))
|
|
except Exception as e:
|
|
click.echo(f" Error: {e}")
|
|
|
|
if not prepared:
|
|
click.echo("No files prepared for transcription.")
|
|
return 0
|
|
|
|
click.echo(f"\n=== Transcribe + save ({len(prepared)} files, sequential on GPU) ===")
|
|
processed = 0
|
|
for meta, wav_path in prepared:
|
|
try:
|
|
call_data = _transcribe_stereo(wav_path, {
|
|
"call_id": meta.call_id,
|
|
"datetime": meta.datetime,
|
|
"direction": meta.direction,
|
|
"caller_number": meta.caller_number,
|
|
"callee_number": meta.callee_number,
|
|
"employee": meta.employee,
|
|
"duration_sec": meta.duration_sec,
|
|
"audio_url": meta.audio_url,
|
|
})
|
|
processed += 1
|
|
click.echo(f" Done! Call ID: {call_data.get('call_id')}")
|
|
except Exception as e:
|
|
click.echo(f" Error processing {meta.call_id}: {e}")
|
|
if wav_path and os.path.exists(wav_path):
|
|
os.remove(wav_path)
|
|
|
|
return processed
|
|
|
|
|
|
# ── CLI ────────────────────────────────────────────────────────────
|
|
|
|
@click.group()
|
|
def cli():
|
|
pass
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--limit", default=None, type=int, help="Max calls to process")
|
|
@click.option("--date-from", default=None, help="YYYY-MM-DD or YYYY-MM-DD hh:mm:ss")
|
|
def process(limit, date_from):
|
|
cnt = _run_api_batch(limit=limit, date_from=date_from)
|
|
click.echo(f"\nProcessed {cnt} calls.")
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--file", required=True, help="Path to audio file")
|
|
@click.option("--caller", default=None, help="Caller number")
|
|
@click.option("--callee", default=None, help="Callee number")
|
|
@click.option("--duration", default=None, type=int, help="Duration in seconds")
|
|
def process_file(file, caller, callee, duration):
|
|
click.echo(f"Processing file: {file}")
|
|
meta = {
|
|
"call_id": f"manual_{os.path.splitext(os.path.basename(file))[0]}",
|
|
"caller_number": caller,
|
|
"callee_number": callee,
|
|
"direction": "inbound" if caller else None,
|
|
"duration_sec": duration,
|
|
"audio_url": file,
|
|
}
|
|
call_data = _process_call(file, meta)
|
|
click.echo(f"Done! Call ID: {call_data.get('call_id')}")
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--output", default="export.json", help="Output file path")
|
|
def export(output):
|
|
export_to_json(output)
|
|
click.echo(f"Exported to {output}")
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--max", "max_per_cycle", default=None, type=int, help="Max calls per cycle")
|
|
@click.option("--hours-back", default=6, type=int, help="How far back to check for calls")
|
|
def daemon(max_per_cycle, hours_back):
|
|
import schedule
|
|
|
|
max_calls = max_per_cycle or config.MAX_PER_CYCLE
|
|
|
|
def job():
|
|
now = datetime.utcnow()
|
|
since = (now - timedelta(hours=hours_back)).strftime("%Y-%m-%d %H:%M:%S")
|
|
till = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
click.echo(f"Checking for new calls since {since}...")
|
|
try:
|
|
cnt = _run_api_batch(limit=max_calls, date_from=since, date_till=till)
|
|
click.echo(f"Processed {cnt} calls this cycle.")
|
|
except Exception as e:
|
|
click.echo(f"Error: {e}")
|
|
|
|
schedule.every(5).minutes.do(job)
|
|
|
|
click.echo(f"Daemon started. Checking every 5 minutes, max {max_calls} calls/cycle.")
|
|
while True:
|
|
schedule.run_pending()
|
|
time.sleep(30)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|