PlusMagi's Blog By Pitt Phunsanit

เปลี่ยนวิดีโอประชุมเป็นโน้ต Obsidian อัตโนมัติด้วย Whisper.cpp (Metal Stack) และ Python

เคยไหม? ประชุมเสร็จแล้วต้องมานั่งเปิดวิดีโอย้อนหลังเพื่อสรุปรายงานการประชุม (Minutes of Meeting) วันนี้เราจะมาสร้าง Automated Transcription Pipeline บนเครื่อง Mac ที่จะคอย “เฝ้า” โฟลเดอร์เก็บไฟล์ประชุม เมื่อมีคลิปใหม่เข้ามา ระบบจะดึงเสียงไปแปลงเป็นข้อความภาษาไทยให้อัตโนมัติ แยกย่อยให้ทุก ๆ 5 นาที แล้วส่งตรงเข้า Obsidian ทันที!


🛠️ โครงสร้างระบบ (Directory Setup)

เพื่อให้ระบบทำงานได้ถูกต้อง Script นี้จะอ้างอิง Path บน Mac ของคุณดังนี้ครับ


💡 เจาะลึกการทำงานของ Script

Script นี้ถูกออกแบบมาให้รันแบบ Persistent Loop (ทำงานตลอดเวลา) โดยมี Logic สำคัญดังนี้

  1. Batch Scan (กวาดรวบจบในรอบเดียว): เมื่อถูกเรียกใช้งาน Script จะเข้าไป Scan หาไฟล์วิดีโอทั้งหมดในโฟลเดอร์แหล่งข้อมูลแบบ Recursive (ค้นหาลงไปในโฟลเดอร์ย่อยด้วย) เพื่อนำมาจัดคิวประมวลผลเป็นชุด ๆ จนครบทั้งหมดในคราวเดียว ก่อนจะทำการปิดโปรแกรมลงอย่างปลอดภัยเพื่อให้ระบบสามารถ Sleep หรือ Shutdown เครื่องต่อได้ทันที
  2. Smart Skip (ทำเฉพาะไฟล์ใหม่): ระบบจะเช็กก่อนว่าวิดีโอนั้น ๆ เคยถูกถอดความหรือยัง ถ้าตรวจพบไฟล์คู่หู _mom.md และ _summaries.md ใน Obsidian แล้ว มันจะข้ามทันที ไม่ทำซ้ำให้เปลืองแรงเครื่อง
  3. High-Performance Audio Extraction & Transcription: * ใช้ ffmpeg แปลงเสียงจากวิดีโอให้เป็นคลื่นเสียงแบบ 16kHz Mono PCM 16-bit ซึ่งเป็น Spec ที่ดีที่สุดสำหรับ Whisper
    • ส่งต่อให้ whisper-cli ทำงานร่วมกับ Apple Silicon (Metal Stack) ดึงพลังของ GPU บน Mac มาประมวลผลภาษาไทยอย่างรวดเร็ว
  4. 5-Minute Time Block Grouping: แทนที่จะพ่นข้อความเป็นก้อนยาว ๆ อ่านยาก Script มี RegEx ช่วยดักจับ Timecode แล้วหั่นเนื้อหาแยกเป็นหัวข้อ ### minute 00:00, ### minute 05:00 ทำให้คุณกด Jump ย้อนไปฟังในวิดีโอจริงได้ง่ายมาก
  5. Obsidian Double-File Output: ระบบจะสร้างไฟล์ให้ 2 ไฟล์เสมอ:
    • [ชื่อไฟล์]_mom.md: เก็บ Metadata และ Raw Transcription แยกตามเวลา (เป็นก้อนความรู้หลักที่ห้ามแก้ไข)
    • [ชื่อไฟล์]_summaries.md: ไฟล์ว่างที่ลิงก์กลับไปหาไฟล์หลัก เพื่อให้คุณเขียนสรุปประเด็นสำคัญ หรือจดโน้ตเพิ่มเติมได้โดยไม่กระทบกับตัวถอดความเดิม

batch_transcribe_whisper_cpp.py

import os
import sys
import time
import subprocess
import warnings
import re
from datetime import datetime
from pathlib import Path

# Suppress warnings noise
warnings.filterwarnings("ignore", category=RuntimeWarning)
warnings.filterwarnings("ignore", message=".*NotOpenSSLWarning.*")

# ==========================================
# CONFIGURATION
# ==========================================
SOURCE_DIR = "/Users/common/Meetings"   # Source folder containing video files
OBSIDIAN_DIR = "/Users/common/Obsidian"  # Destination folder for writing files
USER_GROUP = "staff"

# Whisper.cpp Settings
WHISPER_LANGUAGE = "th"
WHISPER_MODEL_PATH = "/Users/common/models/ggml-large-v3.bin"
WHISPER_CLI_PATH = "whisper-cli"

def get_readable_file_size(file_path):
	try:
		size_bytes = os.path.getsize(file_path)
		for unit in ['Bytes', 'KB', 'MB', 'GB']:
			if size_bytes < 1024.0:
				return f"{size_bytes:.2f} {unit}"
			size_bytes /= 1024.0
	except Exception:
		return "Unknown"

def get_video_duration_ffprobe(file_path):
	try:
		cmd = [
			"ffprobe", "-v", "error", "-show_entries", "format=duration",
			"-of", "default=noprint_wrappers=1:nocikey=1", str(file_path)
		]
		result = subprocess.run(cmd, capture_output=True, text=True, check=True)
		return float(result.stdout.strip())
	except Exception:
		return 0.0

def format_duration_hh_mm_ssss(duration_sec):
	hours = int(duration_sec // 3600)
	minutes = int((duration_sec % 3600) // 60)
	seconds = duration_sec % 60
	sec_str = f"{seconds:07.4f}".replace('.', ':')
	return f"{hours:02d}:{minutes:02d}:{sec_str}"

# ==========================================
# EXECUTION PIPELINE (สแกนรอบเดียวทำจนหมดแล้วจบ)
# ==========================================
print("🚀 Starting Video Processing Batch Pipeline...")
print(f"🔍 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Scanning source directory for videos...")

# Locate and recursively sort video assets alphabetically A-Z
video_files = []
extensions = (".avi", ".mkv", ".mov", ".mp4")

for root, dirs, files in os.walk(SOURCE_DIR):
	dirs.sort()
	files.sort()
	for file in files:
		if file.lower().endswith(extensions):
			video_files.append(os.path.join(root, file))

print(f"Total video files discovered: {len(video_files)}")

processed_in_this_run = 0

for video_path in video_files:
	video_path_obj = Path(video_path)
	relative_path_str = os.path.relpath(video_path, SOURCE_DIR)
	rel_path = Path(relative_path_str)

	# กำหนด Path ไฟล์ปลายทางก่อน เพื่อเอาไว้เช็กสถานะ
	output_path_md = Path(OBSIDIAN_DIR) / rel_path.with_name(f"{rel_path.stem}_mom.md")
	output_path_summaries = Path(OBSIDIAN_DIR) / rel_path.with_name(f"{rel_path.stem}_summaries.md")

	# ถ้าไฟล์ปลายทางมีอยู่แล้ว แปลว่าเคยทำไปแล้ว ให้ข้ามเลย (ไม่ต้องทำซ้ำ)
	if output_path_md.exists() and output_path_summaries.exists():
		continue

	processed_in_this_run += 1
	start_process_time_raw = time.time()

	now = datetime.now()
	start_process_time = now.strftime("%Y-%m-%d %H:%M:%S")

	sec_with_micro = f"{now.second}.{now.microsecond:04d}".replace('.', ':')
	formatted_meta_date = f"{now.strftime('%Y-%m')}-{now.strftime('%H:%M')}:{sec_with_micro}"

	file_size_str = get_readable_file_size(video_path)
	raw_duration_sec = get_video_duration_ffprobe(video_path)
	video_duration_str = format_duration_hh_mm_ssss(raw_duration_sec)

	# ลบไฟล์เก่ากรณีที่ไฟล์ดันหลงเหลืออยู่แค่ไฟล์เดียว เพื่อความคลีน
	if output_path_md.exists():
		os.remove(output_path_md)
	if output_path_summaries.exists():
		os.remove(output_path_summaries)

	output_path_md.parent.mkdir(parents=True, exist_ok=True)

	metadata_block = (
		f"> [!info] **📋 Session Metadata & Attendance**\n"
		f"> - 📅 **Original File Date:** `{formatted_meta_date}`\n"
		f"> - 🎬 **Reference Video Path:** `{video_path}`\n"
		f"> - 📦 **Video File Size:** `{file_size_str}`\n"
		f"> - ⏳ **Total Video Duration:** `{video_duration_str}`\n"
		f"> - ⚙️ **AI Processing Engine:** `whisper.cpp CLI (Metal Stack) - Raw Only`\n"
		f"> - ⏱️ **Transcription Start Time:** `{start_process_time}`\n"
	)

	print(f"\n==================================================")
	print(f"[Processing] Starting execution pipeline for: {rel_path}")
	print(f"==================================================")
	print(metadata_block)
	print("-" * 50)

	# =================================================================
	# 1. Execute Audio-to-Text via whisper.cpp native CLI
	# =================================================================
	temp_wav_path = Path(OBSIDIAN_DIR) / f"current_processing_audio_{int(time.time())}.wav"
	print(f"🎵 Extracting and converting audio to clean temporary WAV...")

	ffmpeg_cmd = [
		"ffmpeg", "-y", "-i", str(video_path),
		"-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", str(temp_wav_path)
	]

	try:
		subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

		print(f"🔊 Running Whisper.cpp native engine via CLI (Streaming output)...")
		whisper_cmd = [
			WHISPER_CLI_PATH,
			"-m", str(WHISPER_MODEL_PATH),
			"-l", str(WHISPER_LANGUAGE),
			"-f", str(temp_wav_path)
		]

		process = subprocess.Popen(whisper_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

		stdout_output_lines = []

		while True:
			line = process.stdout.readline()
			if not line and process.poll() is not None:
				break
			if line:
				clean_line = line.strip()
				stdout_output_lines.append(clean_line)
				if clean_line.startswith("["):
					print(f"🔊 Transcribed -> {clean_line}")

		stdout_output = "\n".join(stdout_output_lines)

		if process.returncode and process.returncode != 0:
			stderr_err = process.stderr.read()
			raise subprocess.CalledProcessError(process.returncode, whisper_cmd, stderr=stderr_err)

	except subprocess.CalledProcessError as e:
		print(f"❌ Subprocess execution failed!")
		print(f"Command run: {' '.join(e.cmd)}")
		if temp_wav_path.exists():
			os.remove(temp_wav_path)
		continue
	except Exception as e:
		print(f"❌ Unexpected non-CLI crash encountered: {e}")
		if temp_wav_path.exists():
			os.remove(temp_wav_path)
		continue

	if temp_wav_path.exists():
		os.remove(temp_wav_path)

	# =================================================================
	# Parse ผลลัพธ์เก็บลงช่วงละ 5 นาที
	# =================================================================
	raw_text_by_5min = {}
	current_interval = -1
	timeline_blocks = []

	time_extract_pattern = re.compile(r"\[(\d{2}):(\d{2}):(\d{2})")

	for line in stdout_output.splitlines():
		line_str = line.strip()
		if not line_str.startswith("["):
			continue

		match = time_extract_pattern.match(line_str)
		if match:
			hours = int(match.group(1))
			minutes = int(match.group(2))
			seconds = int(match.group(3))

			clean_text = re.sub(r"\[.*?\]", "", line_str).strip()
			if not clean_text:
				continue

			total_seconds = (hours * 3600) + (minutes * 60) + seconds
			interval = int(total_seconds // 300) * 5
			time_tag = f"{interval:02d}:00"

			if interval != current_interval:
				if current_interval != -1:
					old_tag = f"{current_interval:02d}:00"
					combined_chunk = " ".join(raw_text_by_5min[old_tag])
					timeline_blocks.append(f"### [[#{old_tag}]] minute {old_tag}\n{combined_chunk}\n\n")

				current_interval = interval
				raw_text_by_5min[time_tag] = []

			raw_text_by_5min[time_tag].append(clean_text)

	if current_interval != -1:
		last_tag = f"{current_interval:02d}:00"
		timeline_blocks.append(f"### [[#{last_tag}]] minute {last_tag}\n{' '.join(raw_text_by_5min[last_tag])}\n\n")

	print("✅ Transcription loaded successfully into local buffer memory")

	end_process_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
	status_str = "Status: ✅ Successfully transcribed (Ollama Summary Disabled)"
	exec_content = "*Ollama summary was disabled by configuration. Full transcription logging only.*"

	# =================================================================
	# 3. Assemble and Commit clean File Markdown Document Payload
	# =================================================================
	timeline_joined = "## 🕒 Timeline Log (Separate every 5 minutes.)\n\n" + "".join(timeline_blocks)

	final_markdown = (
		f"# 📂 Transcribed Knowledge Base: {video_path_obj.stem}\n\n"
		f"{metadata_block}"
		f"> - 🏁 **Processing End Time:** `{end_process_time}`\n"
		f"> - 📍 **Location / Link:** \n"
		f"> - 🏷️ **Tags:** #PTT_Archive #Raw_Transcription\n\n"
		f"--- \n\n"
		f"## 📝 Raw Content Details\n"
		f"{exec_content}\n\n"
		f"--- \n\n"
		f"{timeline_joined}\n"
		f"--- \n\n"
		f"{status_str}"
	)

	blank_summary_markdown = (
		f"# 📝 Additional Summaries & Personal Notes: {video_path_obj.stem}\n\n"
		f"> [!info] **🔗 Linked Assets**\n"
		f"> - 📑 **Primary MoM Note:** [[{output_path_md.name}]]\n"
		f"> - 🎬 **Source Video:** `{video_path_obj.name}`\n\n"
		f"--- \n\n"
		f"## ✒️ Custom Notes & Takeaways\n"
		f"*(Feel free to write down additional custom summaries or ad-hoc takeaways here)*\n\n"
	)

	# เขียนไฟล์ลงดิสก์
	with open(output_path_md, "w", encoding="utf-8") as md_file:
		md_file.write(final_markdown)

	with open(output_path_summaries, "w", encoding="utf-8") as summary_file:
		summary_file.write(blank_summary_markdown)

	print(f"\n📝 [File Committed] Successfully wrote Markdown notes to Obsidian target:")
	print(f"   📍 Primary Note  -> {output_path_md}")
	print(f"   📍 Personal Note -> {output_path_summaries}")

	try:
		for p_target in (output_path_md, output_path_summaries):
			os.chmod(p_target, 0o770)
		current_p = output_path_md.parent
		while current_p != Path(OBSIDIAN_DIR).parent and current_p != current_p.parent:
			os.chmod(current_p, 0o770)
			current_p = current_p.parent
	except Exception:
		pass

	elapsed_total_min = (time.time() - start_process_time_raw) / 60
	print(f"🎉 Documents paired and compiled successfully (Execution time: {elapsed_total_min:.2f} mins)")
	print("=" * 60)
	print(f"FINISHED: {video_path_obj.name} pair allocation completed successfully.")
	print("_" * 60 + "\n")

if processed_in_this_run == 0:
	print("💤 No new videos found. Everything is up to date.")
else:
	print(f"✅ Processed {processed_in_this_run} new video(s) in this batch.")

print("🏁 Batch complete. Program exiting safely.")
sys.exit(0)

⏱️ เคล็ดลับสำคัญ: ป้องกัน Mac หลับด้วย caffeinate

เนื่องจากคลิปการประชุมมักจะมีความยาว และการประมวลผล AI ขนาดใหญ่ (Large-v3 Model) ต้องใช้เวลา หากปล่อย Mac ทิ้งไว้ หน้าจออาจจะดับหรือเครื่องเข้าสู่ Sleep Mode ทำให้กระบวนการทั้งหมดหยุดชะงัก

เราจึงต้องใช้คำสั่ง caffeinate ซึ่งเป็น Command Line พื้นฐานของ macOS ในการสั่งให้เครื่องตื่นตัวตลอดเวลาตราบใดที่ Script ยังทำงานอยู่

วิธีการรัน Script ร่วมกับ caffeinate

เปิด Terminal แล้วใช้คำสั่งนี้ในการเริ่มระบบ
caffeinate -i python3 batch_transcribe_whisper_cpp.py && sudo shutdown -h now
คำสั่งนี้สามารถแยกโครงสร้างออกเป็น 3 ส่วนหลัก ที่ทำงานร่วมกันดังนี้ครับ

คำสั่งนี้สามารถแยกโครงสร้างออกเป็น 3 ส่วนหลัก ที่ทำงานร่วมกันดังนี้ครับ:

1. caffeinate -i python3 batch_transcribe_whisper_cpp.py (ตื่นไว้จนกว่าจะเสร็จ)

สรุปส่วนที่ 1: Mac จะตื่นตัวและจ่ายไฟเต็มกำลังให้โปรแกรม Whisper.cpp แปลงไฟล์วิดีโอจนกว่าจะเสร็จสิ้นทุกไฟล์

2. && (ตัวเชื่อมเงื่อนไข “ถ้าสำเร็จ…ให้ทำต่อ”)

3. sudo shutdown -h now (ปิดเครื่องทันที)

🔄 ลำดับเหตุการณ์จริงเมื่อคุณกดรันคำสั่งนี้:

  1. Terminal จะถามรหัสผ่านเครื่อง Mac ทันที (จาก sudo): คุณต้องพิมพ์รหัสผ่านของเครื่องแล้วกด Enter (ตอนพิมพ์รหัสจะไม่ขึ้นตัวอักษรใด ๆ เป็นเรื่องปกติของความปลอดภัยบน Mac)
  2. Python เริ่มทำงาน สแกนและถอดความเสียงการประชุมไปเรื่อย ๆ โดยมี caffeinate คอยค้ำไม่ให้เครื่องหลับ
  3. เมื่อ Python แปลงไฟล์จนหมดโฟลเดอร์ และจบโปรแกรมลงอย่างสมบูรณ์
  4. เงื่อนไข && ทำงาน ส่งไม้ต่อให้ sudo shutdown -h now
  5. Mac ของคุณจะทำกระบวนการปิดเครื่อง (Shutdown) ตัวเองโดยอัตโนมัติทันทีครับ เหมาะมากสำหรับการเปิดรันทิ้งไว้ก่อนนอน หรือก่อนเลิกงานครับ

อ่านเพิ่มเติม

Exit mobile version