EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / เสียงชุดใหม่ยาวไม่เท่าเดิม ย้ายแผนตัดต่อด้วย waveform correlation
Linux

เสียงชุดใหม่ยาวไม่เท่าเดิม ย้ายแผนตัดต่อด้วย waveform correlation

โดย Ton-Or ·31 ต.ค. 2026·อ่าน 10 นาที
เสียงชุดใหม่ยาวไม่เท่าเดิม ย้ายแผนตัดต่อด้วย waveform correlation

เสียงสองไฟล์ที่ควรเป็นเนื้อเดียวกันแต่ยาวต่างกัน 1.48 วินาที ไม่ได้แปลว่าต้องยืดทั้งเส้น ถ้าวัดดูจะพบว่าส่วนใหญ่เป็นการเลื่อนคงที่ประมาณ 2 วินาที กับสเกลเล็กมากที่แทบมองไม่เห็น

สรุปใน 1 นาที

อาการ/ปัญหา
แผนตัดต่อ 33 ท่อนวางบนเสียง 194.28 วินาที แต่เสียงชุดใหม่ยาว 192.80 วินาที
ใครควรอ่าน
คนที่ต้องย้ายแผนตัดต่อไปใช้กับไฟล์เสียงที่ยาวไม่เท่าเดิม
ทางแก้แบบสั้น
อย่ายืดตามอัตราส่วน ให้ทำ cross-correlation 8 หน้าต่างที่ 8 kHz ฟิตเส้นตรง new = a*old + b แล้วแปลงทุกจุดตัดผ่านเส้นเดียวกัน
ข้ามไปขั้นตอน

พอดีผมวางแผนตัดต่อ 33 ท่อน 9 ส่วนของเพลงตัวอย่างหนึ่งเพลงไว้บนเสียงที่อัดจากเครื่องเล่น (ผมเรียกว่า "ไฟล์อัดเดิม") ความยาว 194.28 วินาที แล้วต่อมาได้ไฟล์สเต็มชุดใหม่แบบ lossless จากต้นทางอื่น ยาว 192.80 วินาที ต้องย้ายแผนทั้งชุดไปอยู่บนเวลาใหม่ ผมไม่อยากทำแผนใหม่ทั้งหมด เลยต้องหาวิธีย้ายที่พิสูจน์ได้ว่าไม่เพี้ยน

ผมไม่เล่าเนื้อหาของเพลงหรือรายละเอียดแต่ละท่อน เอาเฉพาะวิธีวัดและตัวเลข และต้องบอกก่อนว่าผลที่เขียนในไฟล์ผลลัพธ์ระบุ humanListeningVerified = false คือผมยังไม่ได้ฟังด้วยหูเพื่อยืนยัน ทุกอย่างในบทความนี้ยืนยันด้วยตัวเลขเท่านั้น

วิธีลวกที่ผมเกือบใช้ คือคูณอัตราส่วน

วิธีที่ทุกคนนึกถึงก่อนคือเอา 192.80 / 194.28 = 0.99238 ไปคูณทุกจุดตัด ฟังดูสมเหตุสมผลเพราะความยาวสองไฟล์ต่างกัน แต่ผมคำนวณเทียบกับผลที่วัดได้ (เส้นฟิตจะมาในหัวข้อถัดไป) แล้วพบว่าวิธีนี้ผิดตั้งแต่ 0.5 ถึง 1.85 วินาที

เวลาในไฟล์เก่า คูณ 0.99238 ตามเส้นที่วัดได้ ผิดไป
20 s 19.848 17.995 1.85 s
100 s 99.238 98.002 1.24 s
177 s 175.652 175.008 0.64 s
194.28 s 192.800 192.289 0.51 s

ความจริงคือ "เลื่อนคงที่ประมาณ 2 วินาที" บวก "สเกลเล็กมาก" ไม่ใช่ยืดทั้งเส้น การคูณอัตราส่วนความยาวไปผลักทั้งการเลื่อนและความยาวส่วนเกินท้ายไฟล์ให้กระจายผิดที่ ผลคือจุดตัดช่วงต้นเพลงที่ควรเลื่อนแค่สองวินาทีถูกลากไปผิดเกือบสองวินาทีเต็ม

ความยาวต่างกัน ไม่ได้แปลว่ายืดสัดส่วนเดียว

ไฟล์ใหม่ยาวกว่าที่เส้นฟิตทำนายอยู่ประมาณ 0.5 วินาทีที่ท้ายไฟล์ (194.28 x a + b = 192.29 แต่ไฟล์ยาว 192.80) ผมไม่ขอสรุปสาเหตุของหางส่วนเกินนี้ เพราะไม่มีหลักฐานพอ ผมจัดการด้วยการบังคับให้ท่อนสุดท้ายจบที่ 192.80 พอดี

วัดจริงด้วย cross-correlation 8 หน้าต่าง

ผมเอาเสียงร้องที่แยกจากไฟล์อัดเดิม (ได้จากเครื่องมือแยกเสียง เป็นไฟล์พรีวิว) ไปเทียบกับสเต็มเสียงร้องแบบ lossless ของชุดใหม่ ใช้ normalized signed-waveform cross-correlation แปลงเป็น mono 8 kHz ก่อน เพราะที่ 8 kHz หนึ่งซัมเปิลยาว 0.125 มิลลิวินาที ละเอียดกว่าหนึ่งเฟรมที่ 30 fps (33.3 มิลลิวินาที) เกือบสามร้อยเท่า และเบากว่าการเทียบที่ 48 kHz มาก

หน้าต่างทั้ง 8 เริ่มที่ 20, 45, 70, 90, 115, 140, 165 และ 177 วินาทีของไฟล์เก่า ผลที่บันทึกไว้คือ

8 หน้าต่าง เสียงเดียวกัน คนละแกนเวลาไฟล์เก่า0 s194.28 sไฟล์ใหม่0 s192.80 sจุด 1-5 (20 ถึง 115 s) offset -2.0019 sจุด 6-8 (140 ถึง 177 s) offset -1.9919 scorrelation 0.978 ถึง 0.992 ทั้ง 8 จุดต่างกัน 10 ms = 80 ซัมเปิลที่ 8 kHz แปลว่ามีสเกลเล็กมากซ่อนอยู่
ช่องแดงคือหน้าต่างในไฟล์เก่า ช่องดำคือที่ที่มันไปตกในไฟล์ใหม่ ทุกเส้นเลื่อนซ้ายประมาณ 2 วินาที
-2.0019 soffset 5 จุดแรก
-1.9919 soffset 3 จุดหลัง
10 msส่วนต่าง = 80 ซัมเปิล
0.978-0.992correlation 8 จุด

ผมไม่มีสคริปต์ที่สร้างไฟล์ผลลัพธ์นั้นอยู่ในมือแล้ว (เหลือแต่ผลที่บันทึกไว้) เลยไม่รู้ความยาวหน้าต่างและรายละเอียดการฟิตที่ใช้จริง โค้ดข้างล่างเป็น "ตัวอย่างวิธี" ที่ผมเขียนใหม่ให้ตรงหลักการเดียวกัน ไม่ใช่สคริปต์เดิม

import subprocess
import numpy as np
from scipy.signal import correlate

SR = 8000

def load_mono(path):
    raw = subprocess.run(
        ['ffmpeg', '-v', 'error', '-i', path,
         '-ac', '1', '-ar', str(SR), '-f', 'f32le', '-'],
        capture_output=True, check=True).stdout
    return np.frombuffer(raw, dtype=np.float32).astype(np.float64)

def ncc_best(ref, probe):
    """ref ยาวกว่า probe; คืน (offset เป็นซัมเปิลใน ref, คะแนน -1..1)"""
    n = len(probe)
    cc = correlate(ref, probe, mode='valid', method='fft')
    c2 = np.concatenate(([0.0], np.cumsum(ref * ref)))
    energy = c2[n:] - c2[:-n]                       # พลังงานหน้าต่างเลื่อน
    denom = np.sqrt(np.maximum(energy, 1e-12) * float(np.dot(probe, probe)))
    score = cc / denom
    k = int(np.argmax(score))
    return k, float(score[k])

def locate(old, new, t_old, win=6.0, search=6.0):
    p0 = int(t_old * SR)
    probe = old[p0:p0 + int(win * SR)]
    lo = max(0, p0 - int(search * SR))
    hi = min(len(new), p0 + int((search + win) * SR))
    k, s = ncc_best(new[lo:hi], probe)
    return (lo + k) / SR, s

การหารด้วยรากของพลังงานคือส่วนที่ทำให้คะแนนอยู่ระหว่าง -1 ถึง 1 โดยไม่ขึ้นกับความดังของแต่ละช่วง ผมคำนวณพลังงานหน้าต่างเลื่อนด้วย cumsum ครั้งเดียวแทนการวนคำนวณทุกตำแหน่ง ใช้สูตรเดียวกับที่ผมใช้จับคู่ไฟล์ด้วยเสียงในบทความ จับคู่ไฟล์ที่ไม่มีป้ายด้วย correlation และ Hungarian

ผมทดสอบฟังก์ชันนี้กับสัญญาณสังเคราะห์ที่สร้างให้เลื่อน 2.006 วินาทีและยืด 1.0000778 เท่า ฟิตจาก 8 หน้าต่างที่ตำแหน่งเดียวกันได้ a = 1.0000781 และ b = -2.0058 ซึ่งใกล้กับค่าที่ตั้งไว้ คะแนน correlation ในสัญญาณทดสอบอยู่ราว 0.90 (นี่เป็นค่าของสัญญาณทดสอบ ไม่ใช่ค่าของงานจริง)

คลื่นเสียงสองเส้นซ้อนกัน เส้นล่างเลื่อนไปทางขวาเล็กน้อย มีเส้นประแนวตั้งเชื่อมยอดคลื่นที่ตรงกัน
รูปคลื่นเหมือนกัน เพียงเลื่อนเวลา ความสูงของยอด correlation บอกว่าสองเส้นเป็นเนื้อเดียวกันแค่ไหน

ฟิตเส้นตรง new = a*old + b

แปด offset ทำให้ผมได้แปดคู่ (เวลาเก่า, เวลาใหม่) แล้ว np.polyfit ดีกรี 1 ได้เส้นตรงที่ลดความคลาดรวมให้ต่ำสุด

import numpy as np

old_t = np.array([20, 45, 70, 90, 115, 140, 165, 177], dtype=float)
off   = np.array([-2.001875] * 5 + [-1.991875] * 3)
new_t = old_t + off

a, b = np.polyfit(old_t, new_t, 1)
resid = a * old_t + b - new_t
print(f'a = {a:.9f}  ({(a - 1) * 1e6:.1f} ppm)')
print(f'b = {b:.5f} s')
print(f'residual max = {np.abs(resid).max() * 1000:.1f} ms')

ผมเอา offset ที่บันทึกไว้มาคำนวณซ้ำได้ a = 1.000077763 (ต่างจาก 1 อยู่ 77.8 ส่วนในล้าน) b = -2.00612 วินาที และ residual สูงสุด 4.7 มิลลิวินาที ซึ่งน้อยกว่าหนึ่งเฟรมที่ 30 fps (33.3 มิลลิวินาที) มาก

1.0000778a (77.8 ppm)
-2.006 sb ค่าคงที่
4.7 msresidual สูงสุด
15 msสเกลสะสมใน 194 s

สเกล 77.8 ppm คิดเป็นสะสม 15 มิลลิวินาทีตลอด 194 วินาที เล็กกว่าหนึ่งเฟรม แต่ผมเก็บไว้ในสมการเพราะมันไม่มีต้นทุน สาเหตุของสเกลนี้ผมเดาว่าอาจเป็นความต่างของนาฬิกาอุปกรณ์เล่นหรืออัดเทียบกับไฟล์ นั่นเป็นสมมติฐาน ผมไม่ได้พิสูจน์

แปลงแผนทั้งชุดผ่านเส้นเดียว

จุดสำคัญคือทุกขอบของแผนต้องผ่านสมการเดียวกัน ทั้งขอบท่อนตัดและขอบส่วนของเพลง ไม่ใช่แต่ละท่อนฟิตเอง

def mapped(t, a, b, new_dur):
    return round(min(max(a * t + b, 0.0), new_dur), 6)

def retime(plan, a, b, new_dur, src_len):
    adjustments = []
    for cut in plan['cuts']:
        cut['start'] = mapped(cut['start'], a, b, new_dur)
        cut['end'] = mapped(cut['end'], a, b, new_dur)
    plan['cuts'][-1]['end'] = new_dur                    # ท่อนสุดท้ายจบพอดีที่ท้ายไฟล์ใหม่
    plan['cuts'][0]['in'] += max(0.0, -b / a)           # ตัดช่วงเงียบหัวไฟล์อัดเดิม
    for i, cut in enumerate(plan['cuts']):
        length = cut['end'] - cut['start']
        limit = src_len[i] - length - 0.025              # กัน in เลยปลายคลิปต้นทาง
        if cut['in'] > limit:
            adjustments.append({'cut': i, 'was': cut['in'], 'now': max(0.0, limit)})
            cut['in'] = max(0.0, limit)
    return adjustments

ขอบหน้าของท่อนแรกอยู่ที่ศูนย์ในแผนเก่า พอแปลงแล้วได้ค่าลบ (-2.006) แต่ถูก clamp ที่ศูนย์ ช่วง 2.006 วินาทีแรกของไฟล์เก่าเลยไม่มีที่ลงในเสียงใหม่ ผมเลยขยับจุดเริ่มอ่านของท่อนแรกในคลิปต้นทางเพิ่ม 2.006 วินาที ซึ่งเป็นช่วงเงียบหัวไฟล์อัดเดิมพอดี ส่วนท่อนอื่นมีการตรวจ limit ว่า in-point บวกความยาวใหม่ต้องไม่เกินความยาวคลิปต้นทางลบ 0.025 วินาที ผลคือมี 2 ท่อนที่ต้องลด source-in และผมบันทึกเหตุผลลงใน sourceInAdjustments

ไทม์ไลน์ของบล็อกตัดหลายสีกำลังถูกไม้บรรทัดเลื่อนและยืดเล็กน้อย โดยมีไทม์ไลน์เดิมจางอยู่ข้างใต้
ทุกบล็อกขยับด้วยกฎเดียวกัน ไทม์ไลน์เดิมจางไว้ข้างใต้เป็นหลักฐานเทียบก่อนและหลัง

สรุปลำดับงานทั้งหมดตั้งแต่วัดจนถึงเขียนแผนใหม่ไว้ในผังนี้

เสียงร้องแยกแล้วเก่า เทียบ ใหม่ลดเป็น mono8 kHzcorrelate8 หน้าต่างฟิตเส้นตรงnew = a*old + bแปลงทุกขอบ33 ท่อน9 ส่วนของเพลงปรับ source-inท่อนแรกท่อนเกิน limitเขียนแผน v2ไฟล์แยกจากแผนเก่าalignment.jsona = 1.0000778b = -2.006 sresidual 4.7 ms
เส้นเดียวสำหรับทุกขอบ แผนเก่าเก็บไว้เป็นประวัติ แผนใหม่เป็นไฟล์แยก

แผนเก่าผมไม่แตะ เก็บเป็นประวัติพร้อมบันทึกค่า SHA-256 ไว้ใน alignment.json ส่วนแผนใหม่เขียนเป็นไฟล์แยก ถ้ามีอะไรผิดสามารถย้อนกลับไปเทียบได้เสมอ

สิ่งที่ผมยังไม่รู้ และข้อจำกัด

  1. ผมไม่รู้ความยาวหน้าต่างและวิธีฟิตที่ใช้ในการวัดจริง โค้ดในบทความเป็นตัวอย่างวิธี ผมยืนยันได้เฉพาะตัวเลขที่บันทึกไว้และผลคำนวณซ้ำจากตัวเลขนั้น
  2. ผลลัพธ์ระบุว่ายังไม่ได้ฟังด้วยหูยืนยัน (`humanListeningVerified = false`) ผมคงข้อความนี้ไว้ ไม่ปรับให้ดูดีกว่าความจริง
  3. วิธีนี้ต้องมีเสียงร้องหรือเนื้อสัญญาณเดียวกันในสองไฟล์ ถ้าเสียงผ่านการแปลงจนรูปคลื่นเปลี่ยน คะแนน correlation จะตกและเส้นฟิตใช้ไม่ได้
  4. แปดหน้าต่างพอสำหรับเส้นตรง ถ้าสงสัยว่ามีการเลื่อนแบบไม่เป็นเส้นตรง (เช่นมีช่วงที่ตัดต่อมา) ต้องเพิ่มหน้าต่างแล้วดู residual เป็นรายจุด ไม่ใช่เชื่อเส้นเดียว

ผมสรุปได้ว่าก่อนย้ายแผนตัดต่อไปอยู่บนเสียงชุดใหม่ ให้วัดก่อนด้วยหน้าต่างหลายจุด ไม่เดา และไม่ยืดตามความยาวรวม ดูทั้ง offset ที่ต่างกันตามเวลา (บ่งว่ามีสเกล) กับ residual ว่าเล็กกว่าหนึ่งเฟรมไหม แล้วเขียนผลวัดเก็บเป็นหลักฐาน เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย