| |
| """ |
| Audio Transcription Pipeline CLI. |
| Process audio files through transcription, audience classification, |
| diarization, summarization, and ASCII spectrogram visualization. |
| |
| Usage: |
| python main.py transcribe <audio_path> [--output json] |
| python main.py summarize <audio_path> [--output json] |
| python main.py audience <audio_path> [--output json] |
| python main.py ascii-viz <audio_path> [--output file] |
| python main.py stream <device> [--output json] |
| python main.py all <audio_path> [--output json] [--ascii] |
| """ |
|
|
| import argparse |
| import json |
| import logging |
| import os |
| import sys |
| from typing import Dict, Any |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(levelname)s:%(name)s:%(message)s", |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| logging.getLogger("faster_whisper").setLevel(logging.WARNING) |
| logging.getLogger("transformers").setLevel(logging.WARNING) |
| logging.getLogger("librosa").setLevel(logging.WARNING) |
| logging.getLogger("pipeline").setLevel(logging.INFO) |
|
|
|
|
| def _import_pipeline(): |
| """Import pipeline modules (lazy to avoid slow startup for help).""" |
| from pipeline.transcriber import Transcriber |
| from pipeline.audience_classifier import AudienceResponseClassifier |
| from pipeline.diarizer import Diarizer |
| from pipeline.summarizer import MeetingSummarizer |
| from pipeline.ascii_spectrogram import AsciiSpectrogram |
| from pipeline.orchestrator import AudioPipeline |
|
|
| return { |
| "Transcriber": Transcriber, |
| "AudienceResponseClassifier": AudienceResponseClassifier, |
| "Diarizer": Diarizer, |
| "MeetingSummarizer": MeetingSummarizer, |
| "AsciiSpectrogram": AsciiSpectrogram, |
| "AudioPipeline": AudioPipeline, |
| } |
|
|
|
|
| def _output_result(result: Dict[str, Any], output_format: str, output_path: str = None): |
| """Output result in specified format.""" |
| |
| clean = _clean_for_json(result) |
|
|
| if output_format == "json": |
| output = json.dumps(clean, indent=2, ensure_ascii=False, default=str) |
| if output_path: |
| with open(output_path, "w") as f: |
| f.write(output) |
| logger.info(f"Output written to {output_path}") |
| else: |
| print(output) |
| else: |
| |
| _print_text_result(clean, output_path) |
|
|
|
|
| def _clean_for_json(obj): |
| """Recursively clean objects for JSON serialization.""" |
| if isinstance(obj, dict): |
| return {k: _clean_for_json(v) for k, v in obj.items() if not k.startswith("_")} |
| elif isinstance(obj, list): |
| return [_clean_for_json(item) for item in obj] |
| elif isinstance(obj, float): |
| if obj != obj: |
| return None |
| return obj |
| return obj |
|
|
|
|
| def _print_text_result(result: Dict[str, Any], output_path: str = None): |
| """Print result in human-readable text format.""" |
| lines = [] |
|
|
| if "error" in result: |
| lines.append(f"ERROR: {result['error']}") |
| else: |
| |
| meta = result.get("metadata", {}) |
| lines.append("=" * 60) |
| lines.append("AUDIO TRANSCRIPTION PIPELINE RESULT") |
| lines.append("=" * 60) |
| lines.append( |
| f"Duration: {meta.get('duration', 'N/A'):.1f}s | " |
| f"Segments: {meta.get('num_segments', 0)} | " |
| f"Processing: {meta.get('processing_time_seconds', 0):.1f}s" |
| ) |
| lines.append("") |
|
|
| |
| segments = result.get("segments", []) |
| if segments: |
| lines.append("--- TRANSCRIPT ---") |
| for seg in segments: |
| speaker = seg.get("speaker", "?") |
| text = seg.get("text", "").strip() |
| start = seg.get("start", 0) |
| end = seg.get("end", 0) |
| audience = seg.get("audience_response", "") |
| conf = seg.get("confidence", 0) |
| tag = f" [{audience}]" if audience and audience != "unknown" else "" |
| lines.append(f" [{start:6.1f}s-{end:6.1f}s] {speaker}: {text}{tag}") |
| lines.append("") |
|
|
| |
| ar = result.get("audience_responses", []) |
| if ar: |
| lines.append("--- AUDIENCE RESPONSES ---") |
| for resp in ar[:10]: |
| lines.append( |
| f" [{resp.get('start', 0):.1f}s-{resp.get('end', 0):.1f}s] " |
| f"{resp.get('response_class', '?')} " |
| f"(conf: {resp.get('confidence', 0):.2f})" |
| ) |
| if len(ar) > 10: |
| lines.append(f" ... and {len(ar) - 10} more") |
| lines.append("") |
|
|
| |
| summary = result.get("summary", {}) |
| if summary and summary.get("overview"): |
| lines.append("--- SUMMARY ---") |
| lines.append(f" Overview: {summary.get('overview', 'N/A')}") |
| decisions = summary.get("decisions", []) |
| if decisions: |
| lines.append(" Decisions:") |
| for d in decisions: |
| lines.append(f" - {d}") |
| actions = summary.get("action_items", []) |
| if actions: |
| lines.append(" Action Items:") |
| for a in actions: |
| lines.append(f" - {a}") |
| topics = summary.get("topics", []) |
| if topics: |
| lines.append(" Topics:") |
| for t in topics: |
| lines.append(f" - {t}") |
| lines.append("") |
|
|
| |
| frames = result.get("ascii_frames", []) |
| if frames: |
| lines.append(f"--- ASCII SPECTROGRAM ---") |
| lines.append(f" {len(frames)} frames generated") |
| lines.append(f" First frame preview:") |
| first = frames[0] |
| if isinstance(first, dict): |
| lines.append(f" t={first.get('timestamp', 0):.1f}s") |
| frame_text = first.get("frame", "") |
| for line in frame_text.split("\n")[:5]: |
| lines.append(f" |{line}") |
| else: |
| lines.append(f" {str(first)[:60]}...") |
| lines.append("") |
|
|
| output = "\n".join(lines) |
| if output_path: |
| with open(output_path, "w") as f: |
| f.write(output) |
| logger.info(f"Output written to {output_path}") |
| else: |
| print(output) |
|
|
|
|
| def cmd_transcribe(args): |
| """Transcribe an audio file.""" |
| |
| if getattr(args, "hybrid", False): |
| from hybrid_model.infer import run_pipeline |
|
|
| |
| models_dir = "models" |
| hybrid_dir = "hybrid_model" |
| qwen_gguf = os.path.join(models_dir, "Qwen3-8B-Q4_K_M.gguf") |
| qwen_fallback = os.path.join(models_dir, "qwen2.5-0.5b-instruct-q4_k_m.gguf") |
| projector_ckpt = os.path.join(hybrid_dir, "projector_checkpoint_best.pt") |
|
|
| if os.path.exists(qwen_gguf): |
| llm_path = qwen_gguf |
| elif os.path.exists(qwen_fallback): |
| llm_path = qwen_fallback |
| logger.warning("Qwen3-8B not found, using Qwen2.5-0.5B fallback") |
| else: |
| logger.error("No LLM GGUF found for hybrid mode!") |
| sys.exit(1) |
|
|
| logger.info(f"Hybrid mode: LLM={llm_path}, Projector={projector_ckpt}") |
| result_text = run_pipeline( |
| audio_path=args.audio_path, |
| llm_path=llm_path, |
| projector_checkpoint=projector_ckpt, |
| max_new_tokens=200, |
| temperature=0.1, |
| refine_with_llm_flag=True, |
| ) |
|
|
| |
| import librosa |
| try: |
| audio_dur = librosa.get_duration(path=args.audio_path) |
| except Exception: |
| audio_dur = 0.0 |
|
|
| |
| result = { |
| "metadata": { |
| "hybrid_mode": True, |
| "llm": os.path.basename(llm_path), |
| "duration": audio_dur, |
| "num_segments": 1, |
| "processing_time_seconds": 0.0, |
| }, |
| "segments": [{"start": 0, "end": audio_dur, "speaker": "SPEAKER_00", "text": result_text}], |
| } |
| _output_result(result, args.output, getattr(args, "output_file", None)) |
| return |
|
|
| |
| mods = _import_pipeline() |
| pipeline = mods["AudioPipeline"](enable_summarizer=False, enable_ascii=False) |
| result = pipeline.process_file( |
| args.audio_path, |
| language=args.language, |
| vad_filter=not args.no_vad, |
| ) |
| _output_result(result, args.output, getattr(args, "output_file", None)) |
|
|
|
|
| def cmd_summarize(args): |
| """Transcribe and summarize a meeting audio.""" |
| mods = _import_pipeline() |
| pipeline = mods["AudioPipeline"]( |
| enable_summarizer=True, enable_ascii=False |
| ) |
| result = pipeline.process_file( |
| args.audio_path, |
| language=args.language, |
| vad_filter=not args.no_vad, |
| ) |
| _output_result(result, args.output, getattr(args, "output_file", None)) |
|
|
|
|
| def cmd_audience(args): |
| """Classify audience responses in an audio file.""" |
| mods = _import_pipeline() |
| classifier = mods["AudienceResponseClassifier"]() |
| if args.output == "json": |
| result = classifier.classify_file(args.audio_path) |
| print(json.dumps(result, indent=2)) |
| else: |
| result = classifier.classify_file(args.audio_path) |
| print(f"Audience Responses ({len(result)} detections):") |
| for resp in result: |
| print( |
| f" [{resp['start']:.1f}s-{resp['end']:.1f}s] " |
| f"{resp['response_class']} ({resp['confidence']:.2f})" |
| ) |
|
|
|
|
| def cmd_ascii_viz(args): |
| """Generate ASCII spectrogram visualization.""" |
| import librosa |
|
|
| mods = _import_pipeline() |
| audio, sr = librosa.load(args.audio_path, sr=16000, mono=True) |
| duration = len(audio) / sr |
|
|
| viz = mods["AsciiSpectrogram"]( |
| columns=args.columns, |
| rows=args.rows, |
| fps=args.fps, |
| mode=args.mode, |
| ) |
|
|
| frames = list(viz.generate_frames(audio, sr)) |
| print(f"Generated {len(frames)} frames from {duration:.1f}s audio") |
|
|
| if args.output == "file" or args.output_file: |
| path = args.output_file or f"ascii_output_{os.path.basename(args.audio_path)}.txt" |
| with open(path, "w") as f: |
| for ascii_text, timestamp in frames: |
| f.write(f"--- Frame @ {timestamp:.1f}s ---\n{ascii_text}\n\n") |
| print(f"ASCII frames written to: {path}") |
| else: |
| |
| for i, (ascii_text, timestamp) in enumerate(frames[:5]): |
| print(f"\n=== Frame {i + 1} @ {timestamp:.1f}s ===") |
| print(ascii_text) |
| if len(frames) > 5: |
| print(f"\n... and {len(frames) - 5} more frames") |
|
|
|
|
| def cmd_stream(args): |
| """Process stream from microphone or file.""" |
| |
| import librosa |
| import soundfile as sf |
| import tempfile |
|
|
| mods = _import_pipeline() |
| pipeline = mods["AudioPipeline"](enable_summarizer=False, enable_ascii=False) |
|
|
| |
| audio, sr = librosa.load(args.audio_path, sr=16000, mono=True) |
| chunk_size = sr * 2 |
|
|
| def chunk_gen(): |
| for i in range(0, len(audio), chunk_size): |
| yield audio[i : i + chunk_size] |
|
|
| print(f"Stream processing: {args.audio_path}") |
| for i, result in enumerate(pipeline.process_stream(chunk_gen(), sr)): |
| segments = result.get("segments", []) |
| print(f"Chunk {i + 1}: {len(segments)} segments") |
| for seg in segments[:2]: |
| print(f" [{seg.get('start', 0):.1f}s] {seg.get('speaker', '?')}: {seg.get('text', '')[:60]}") |
|
|
|
|
| def cmd_all(args): |
| """Run full pipeline (transcribe + audience + diarize + summarize + optional ASCII).""" |
| mods = _import_pipeline() |
| pipeline = mods["AudioPipeline"]( |
| enable_summarizer=True, |
| enable_ascii=args.ascii, |
| ) |
| result = pipeline.process_file( |
| args.audio_path, |
| language=args.language, |
| vad_filter=not args.no_vad, |
| ) |
| _output_result(result, args.output, getattr(args, "output_file", None)) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Audio Transcription Pipeline — transcribe, classify, " |
| "diarize, summarize, and visualize audio", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=""" |
| Examples: |
| python main.py transcribe meeting.wav |
| python main.py transcribe meeting.wav --output json --no-vad |
| python main.py summarize meeting.wav --output json |
| python main.py all meeting.wav --ascii --output json |
| python main.py audience presentation.wav |
| python main.py ascii-viz music.mp3 --mode combined --columns 100 |
| """, |
| ) |
| parser.add_argument( |
| "--verbose", "-v", action="store_true", help="Enable verbose logging" |
| ) |
|
|
| subparsers = parser.add_subparsers(dest="command", help="Command to execute") |
|
|
| |
| p_transcribe = subparsers.add_parser( |
| "transcribe", help="Transcribe audio to text" |
| ) |
| p_transcribe.add_argument("audio_path", help="Path to audio file") |
| p_transcribe.add_argument( |
| "--output", choices=["text", "json"], default="text", help="Output format" |
| ) |
| p_transcribe.add_argument( |
| "--output-file", "-o", help="Write output to file instead of stdout" |
| ) |
| p_transcribe.add_argument( |
| "--language", "-l", help="Language code (e.g., 'en'). Default: auto-detect" |
| ) |
| p_transcribe.add_argument( |
| "--no-vad", action="store_true", help="Disable VAD filtering" |
| ) |
| p_transcribe.add_argument( |
| "--hybrid", action="store_true", |
| help="Use hybrid Encoder-Projector-LLM model (Whisper+Qwen3-8B)" |
| ) |
| p_transcribe.set_defaults(func=cmd_transcribe) |
|
|
| |
| p_summarize = subparsers.add_parser( |
| "summarize", help="Transcribe and summarize meeting audio" |
| ) |
| p_summarize.add_argument("audio_path", help="Path to audio file") |
| p_summarize.add_argument( |
| "--output", choices=["text", "json"], default="text", help="Output format" |
| ) |
| p_summarize.add_argument( |
| "--output-file", "-o", help="Write output to file instead of stdout" |
| ) |
| p_summarize.add_argument( |
| "--language", "-l", help="Language code (default: auto-detect)" |
| ) |
| p_summarize.add_argument( |
| "--no-vad", action="store_true", help="Disable VAD filtering" |
| ) |
| p_summarize.set_defaults(func=cmd_summarize) |
|
|
| |
| p_audience = subparsers.add_parser( |
| "audience", help="Classify audience responses in audio" |
| ) |
| p_audience.add_argument("audio_path", help="Path to audio file") |
| p_audience.add_argument( |
| "--output", choices=["text", "json"], default="text", help="Output format" |
| ) |
| p_audience.set_defaults(func=cmd_audience) |
|
|
| |
| p_ascii = subparsers.add_parser( |
| "ascii-viz", help="Generate ASCII spectrogram visualization" |
| ) |
| p_ascii.add_argument("audio_path", help="Path to audio file") |
| p_ascii.add_argument( |
| "--mode", |
| choices=["spectrogram", "waveform", "combined"], |
| default="spectrogram", |
| help="Visualization mode", |
| ) |
| p_ascii.add_argument("--columns", type=int, default=80, help="ASCII width") |
| p_ascii.add_argument("--rows", type=int, default=20, help="ASCII height") |
| p_ascii.add_argument("--fps", type=int, default=10, help="Frames per second") |
| p_ascii.add_argument( |
| "--output", choices=["text", "file"], default="text", help="Output format" |
| ) |
| p_ascii.add_argument( |
| "--output-file", "-o", help="Write output to file" |
| ) |
| p_ascii.set_defaults(func=cmd_ascii_viz) |
|
|
| |
| p_stream = subparsers.add_parser( |
| "stream", help="Stream process audio (chunked from file)" |
| ) |
| p_stream.add_argument("audio_path", help="Path to audio file to stream") |
| p_stream.add_argument( |
| "--output", choices=["text", "json"], default="text", help="Output format" |
| ) |
| p_stream.set_defaults(func=cmd_stream) |
|
|
| |
| p_all = subparsers.add_parser( |
| "all", help="Run full pipeline (transcribe + audience + diarize + summarize)" |
| ) |
| p_all.add_argument("audio_path", help="Path to audio file") |
| p_all.add_argument( |
| "--output", choices=["text", "json"], default="text", help="Output format" |
| ) |
| p_all.add_argument( |
| "--output-file", "-o", help="Write output to file instead of stdout" |
| ) |
| p_all.add_argument( |
| "--language", "-l", help="Language code (default: auto-detect)" |
| ) |
| p_all.add_argument( |
| "--no-vad", action="store_true", help="Disable VAD filtering" |
| ) |
| p_all.add_argument( |
| "--ascii", action="store_true", help="Include ASCII spectrogram frames" |
| ) |
| p_all.set_defaults(func=cmd_all) |
|
|
| args = parser.parse_args() |
|
|
| if args.verbose: |
| logging.getLogger().setLevel(logging.DEBUG) |
|
|
| if not hasattr(args, "func"): |
| parser.print_help() |
| sys.exit(1) |
|
|
| args.func(args) |
|
|
|
|
| if __name__ == "__main__": |
| main() |