เคยไหม? ประชุมเสร็จแล้วต้องมานั่งเปิดวิดีโอย้อนหลังเพื่อสรุปรายงานการประชุม (Minutes of Meeting) วันนี้เราจะมาสร้าง Automated Transcription Pipeline บนเครื่อง Mac ที่จะคอย “เฝ้า” โฟลเดอร์เก็บไฟล์ประชุม เมื่อมีคลิปใหม่เข้ามา ระบบจะดึงเสียงไปแปลงเป็นข้อความภาษาไทยให้อัตโนมัติ แยกย่อยให้ทุก ๆ 5 นาที แล้วส่งตรงเข้า Obsidian ทันที!
🛠️ โครงสร้างระบบ (Directory Setup)
เพื่อให้ระบบทำงานได้ถูกต้อง Script นี้จะอ้างอิง Path บน Mac ของคุณดังนี้ครับ
- 📂 โฟลเดอร์เก็บคลิปประชุม:
/Users/common/Meetings(ที่เก็บไฟล์.mp4,.mov,.mkv) - 📂 โฟลเดอร์เก็บ AI Model:
/Users/common/models/ggml-large-v3.bin(Model ภาษาไทยความแม่นยำสูง) - 📂 โฟลเดอร์ปลายทาง Obsidian:
/Users/common/Obsidian(โฟลเดอร์สำหรับเก็บไฟล์.mdเพื่อเปิดอ่านใน Obsidian)
💡 เจาะลึกการทำงานของ Script
Script นี้ถูกออกแบบมาให้รันแบบ Persistent Loop (ทำงานตลอดเวลา) โดยมี Logic สำคัญดังนี้
- Batch Scan (กวาดรวบจบในรอบเดียว): เมื่อถูกเรียกใช้งาน Script จะเข้าไป Scan หาไฟล์วิดีโอทั้งหมดในโฟลเดอร์แหล่งข้อมูลแบบ Recursive (ค้นหาลงไปในโฟลเดอร์ย่อยด้วย) เพื่อนำมาจัดคิวประมวลผลเป็นชุด ๆ จนครบทั้งหมดในคราวเดียว ก่อนจะทำการปิดโปรแกรมลงอย่างปลอดภัยเพื่อให้ระบบสามารถ Sleep หรือ Shutdown เครื่องต่อได้ทันที
- Smart Skip (ทำเฉพาะไฟล์ใหม่): ระบบจะเช็กก่อนว่าวิดีโอนั้น ๆ เคยถูกถอดความหรือยัง ถ้าตรวจพบไฟล์คู่หู
_mom.mdและ_summaries.mdใน Obsidian แล้ว มันจะข้ามทันที ไม่ทำซ้ำให้เปลืองแรงเครื่อง - High-Performance Audio Extraction & Transcription: * ใช้
ffmpegแปลงเสียงจากวิดีโอให้เป็นคลื่นเสียงแบบ16kHz Mono PCM 16-bitซึ่งเป็น Spec ที่ดีที่สุดสำหรับ Whisper- ส่งต่อให้
whisper-cliทำงานร่วมกับ Apple Silicon (Metal Stack) ดึงพลังของ GPU บน Mac มาประมวลผลภาษาไทยอย่างรวดเร็ว
- ส่งต่อให้
- 5-Minute Time Block Grouping: แทนที่จะพ่นข้อความเป็นก้อนยาว ๆ อ่านยาก Script มี RegEx ช่วยดักจับ Timecode แล้วหั่นเนื้อหาแยกเป็นหัวข้อ
### minute 00:00,### minute 05:00ทำให้คุณกด Jump ย้อนไปฟังในวิดีโอจริงได้ง่ายมาก - 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 (ตื่นไว้จนกว่าจะเสร็จ)
caffeinate: เป็นคำสั่ง built-in ของ macOS เปรียบเสมือนการป้อนกาแฟให้ Mac เพื่อสั่งห้ามไม่ให้เครื่องแอบหลับ (Sleep) ระหว่างที่ทำงานหนัก-i: ย่อมาจาก Idle sleep prevention เป็นการเจาะจงว่า “ตราบใดที่โปรแกรมที่พ่วงท้ายยังรันอยู่ ห้ามเครื่องหลับเด็ดขาด” แม้ว่าหน้าจอจะดับไปแล้วก็ตามpython3 batch_transcribe_whisper_cpp.py: คือโปรแกรม Python ที่ทำหน้าที่แปลงไฟล์เสียงของคุณ
สรุปส่วนที่ 1: Mac จะตื่นตัวและจ่ายไฟเต็มกำลังให้โปรแกรม Whisper.cpp แปลงไฟล์วิดีโอจนกว่าจะเสร็จสิ้นทุกไฟล์
2. && (ตัวเชื่อมเงื่อนไข “ถ้าสำเร็จ…ให้ทำต่อ”)
- ในทาง Command Line เครื่องหมาย
&&คือการเชื่อมคำสั่งแบบมีเงื่อนไข หมายความว่า “ให้คำสั่งแรก (ส่วนที่ 1) ทำงานให้เสร็จสมบูรณ์และไม่มีฟ้อง Error ก่อน ถึงจะยอมให้ทำคำสั่งถัดไป (ส่วนที่ 3)” - หากโปรแกรม Python เกิด Crash หรือคุณกด
Ctrl + Cเพื่อสั่งหยุดกลางคัน ระบบจะไม่ทำคำสั่งปิดเครื่องหลังจากนั้น เพื่อความปลอดภัยของข้อมูลครับ
3. sudo shutdown -h now (ปิดเครื่องทันที)
sudo: ย่อมาจาก SuperUser DO เป็นการขอสิทธิ์ผู้ดูแลระบบ (Admin) เนื่องจากคำสั่งปิดเครื่องเป็นคำสั่งระดับโครงสร้างระบบshutdown: คำสั่งสั่งปิดการทำงานของระบบปฏิบัติการ-h: ย่อมาจาก Halt หมายถึงให้ตัดการทำงานของฮาร์ดแวร์และดับไฟเครื่องทั้งหมด (ปิดเครื่องสนิท ไม่ใช่แค่รีสตาร์ท)now: สั่งให้ทำทันที ณ วินาทีนั้น โดยไม่ต้องตั้งเวลานับถอยหลัง
🔄 ลำดับเหตุการณ์จริงเมื่อคุณกดรันคำสั่งนี้:
- Terminal จะถามรหัสผ่านเครื่อง Mac ทันที (จาก
sudo): คุณต้องพิมพ์รหัสผ่านของเครื่องแล้วกด Enter (ตอนพิมพ์รหัสจะไม่ขึ้นตัวอักษรใด ๆ เป็นเรื่องปกติของความปลอดภัยบน Mac) - Python เริ่มทำงาน สแกนและถอดความเสียงการประชุมไปเรื่อย ๆ โดยมี
caffeinateคอยค้ำไม่ให้เครื่องหลับ - เมื่อ Python แปลงไฟล์จนหมดโฟลเดอร์ และจบโปรแกรมลงอย่างสมบูรณ์
- เงื่อนไข
&&ทำงาน ส่งไม้ต่อให้sudo shutdown -h now - Mac ของคุณจะทำกระบวนการปิดเครื่อง (Shutdown) ตัวเองโดยอัตโนมัติทันทีครับ เหมาะมากสำหรับการเปิดรันทิ้งไว้ก่อนนอน หรือก่อนเลิกงานครับ
อ่านเพิ่มเติม