อัดเสียงลำโพงบน Windows แล้วช่วงเงียบหาย ไฟล์สั้นกว่าเวลาจริง
ไฟล์เสียงที่อัดมาเงียบ ๆ ช่วงหนึ่งไม่ได้แปลว่าช่วงนั้นเป็นศูนย์ มันอาจแปลว่าช่วงนั้นไม่มีอยู่ในไฟล์เลย และถ้าไฟล์นั้นต้องใช้เป็นเส้นเวลาของงานต่อไป ความต่างสองอย่างนี้คือต่างกันทั้งงาน
สรุปใน 1 นาที
- อาการ/ปัญหา
- อัดเสียงลำโพงด้วย WASAPI loopback แล้วไฟล์สั้นกว่าเวลาจริง เพราะช่วงเงียบไม่มี buffer
- ใครควรอ่าน
- คนทำโปรแกรมอัดเสียงที่ออกลำโพงบน Windows ด้วย C#
- ทางแก้แบบสั้น
- เล่นสัญญาณเงียบบนอุปกรณ์เดียวกันคู่ไปกับการอัด จองเลขไฟล์ด้วย .lock เขียน WAV ก่อนแล้วแปลงเป็น MP3 ด้วย ffmpeg
พอดีผมต้องอัดเสียงที่เล่นออกลำโพงของเครื่อง Windows เป็นไฟล์ MP3 คุณภาพสูง เพื่อเอาไปวิเคราะห์ต่อ และต้องการไฟล์ที่ ความยาวเท่าเวลาจริงทุกวินาที เพราะภายหลังผมต้องใช้ไฟล์นี้เป็นไทม์ไลน์ ถ้าช่วงเงียบหายไปส่วนหนึ่ง เวลาของทุกอย่างที่อยู่หลังจุดนั้นก็เลื่อนตาม ผมเลยเขียนโปรแกรมอัดเล็ก ๆ ขึ้นมาเองบน .NET 8 กับ NAudio 2.2.1 แล้วเจอว่าวิธีมาตรฐานของ WASAPI loopback มีกับดักเรื่องช่วงเงียบอยู่ข้อหนึ่ง บทความนี้เล่าตัวกับดัก วิธีกัน และลำดับการเขียนไฟล์ที่ผมใช้เพื่อไม่ให้มีไฟล์ครึ่ง ๆ กลาง ๆ ใช้ชื่อสุดท้ายเลย
โปรแกรมแบบนี้อัดทุกอย่างที่ออกอุปกรณ์เสียง ใช้อัดเสียงที่เราเล่นเองจากเครื่อง จาก DAW หรือจากแหล่งที่เรามีสิทธิ์ใช้งานเท่านั้น ไม่ได้เปิดไมโครโฟน แต่เสียงแจ้งเตือนของระบบจะติดไปด้วยถ้ามันดังตอนอัด
อาการ: loopback ไม่ส่งอะไรมาเลยตอนไม่มีเสียง
WASAPI loopback คือการจับสตรีมที่กำลังถูกส่งออกอุปกรณ์เรนเดอร์ (ลำโพงหรือหูฟัง) ใน NAudio ใช้คลาส WasapiLoopbackCapture แล้วรับข้อมูลทางอีเวนต์ DataAvailable ทีละก้อน เขียนก้อนเหล่านั้นต่อกันเป็นไฟล์ WAV
ตามเอกสารของ NAudio อุปกรณ์จะไม่ส่งข้อมูลให้ตอนที่ไม่มีเสียงเล่นอยู่ แปลว่าในช่วงเงียบ DataAvailable ไม่ถูกเรียกเลย ไม่ใช่ถูกเรียกพร้อม buffer ที่เป็นศูนย์ ตัวโปรแกรมที่แค่เขียนทุกก้อนที่ได้รับเลยต่อช่วงที่มีเสียงชิดกัน ช่วงเงียบไม่มีที่อยู่ในไฟล์ ผลคือไฟล์ที่ออกมา สั้นกว่าเวลาจริง และทุกจังหวะหลังช่วงเงียบเลื่อนเข้ามาก่อนเวลา
ผมขอบอกตรง ๆ ว่าข้อนี้ผมอ้างตามเอกสารของไลบรารีและพิสูจน์ด้านที่แก้แล้วด้วย self-test ในหัวข้อท้าย ๆ ส่วนตัวเลขว่าไฟล์ที่ไม่แก้สั้นลงเท่าไร ผมไม่ได้วัดเก็บไว้ ผังข้างล่างเลยใช้ตัวอย่างสมมติ 10 วินาทีเพื่อให้เห็นกลไก ไม่ใช่ค่าที่วัดจากงานจริง
แก้ด้วยการเล่นความเงียบบนอุปกรณ์เดียวกัน
เมื่ออุปกรณ์ส่งข้อมูลเฉพาะตอนมีเสียงเล่น ทางแก้คือทำให้มีเสียงเล่นอยู่ตลอด แต่เป็นเสียงที่เงียบสนิท ผมสร้าง WasapiOut บนอุปกรณ์เรนเดอร์ตัวเดียวกับที่อัด ป้อนด้วย SilenceProvider ที่ใช้ MixFormat ของอุปกรณ์ (IEEE float ตามอัตราซัมเปิลและจำนวนช่องที่อุปกรณ์ใช้อยู่) แล้วสั่ง Play() ก่อนจะสั่ง StartRecording() ของ capture endpoint จะได้ทำงานต่อเนื่อง และ capture ได้ buffer มาเรื่อย ๆ รวมถึงช่วงที่เป็นศูนย์ล้วน

ส่วนแกนของโปรแกรมเป็นแบบนี้ ผมตัดส่วนจัดการ error บางส่วนออกให้อ่านง่าย
// สร้างตัวอัดและตัวเล่นความเงียบบนอุปกรณ์เรนเดอร์ตัวเดียวกัน
device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
capture = new WasapiLoopbackCapture(device);
writer = new WaveFileWriter(slot.WavPath, capture.WaveFormat);
// keepalive: เล่นสัญญาณเงียบเพื่อให้ endpoint ทำงานต่อเนื่อง
silence = new WasapiOut(device, AudioClientShareMode.Shared, false, 100);
var mix = device.AudioClient.MixFormat;
silence.Init(new SilenceProvider(
WaveFormat.CreateIeeeFloatWaveFormat(mix.SampleRate, mix.Channels)));
capture.DataAvailable += (_, e) =>
{
writer.Write(e.Buffer, 0, e.BytesRecorded);
Interlocked.Add(ref bytes, e.BytesRecorded); // นับไบต์ไว้คำนวณความยาว
};
capture.RecordingStopped += (_, e) => stopped.TrySetResult(e.Exception);
silence.Play(); // เล่นความเงียบก่อน
capture.StartRecording(); // แล้วค่อยเริ่มอัด
ความยาวที่โชว์ในหน้าต่างคำนวณจากจำนวนไบต์ที่เขียนจริง หารด้วย AverageBytesPerSecond ของฟอร์แมตที่ capture ได้ ไม่ได้ดูจากนาฬิกาผนัง เพราะถ้าข้อมูลหายระหว่างทาง ตัวเลขจากไบต์จะสั้นลงตามจริง ส่วนนาฬิกาผนังจะโกหกว่ายาวปกติ
ค่า latency 100 มิลลิวินาทีของ WasapiOut มาจากโค้ดที่ผมใช้อยู่ ผมไม่ได้ลองค่าอื่นเทียบ เลยไม่มีข้อมูลว่าค่าอื่นให้ผลต่างกันไหม ถ้าใครลองเปลี่ยนก็ต้องรัน self-test ซ้ำเองนะครับ
ลำดับไฟล์: จองเลขก่อน เขียน WAV ก่อน แล้วค่อยแปลง
เรื่องที่สองที่ผมให้ความสำคัญเท่ากับเรื่องช่วงเงียบ คือต้องไม่มีวันที่ได้ไฟล์ MP3 ที่เขียนไม่ครบแต่ใช้ชื่อสุดท้ายแล้ว และต้องไม่เขียนทับของเดิม ผมเปิดหน้าต่างอัดได้หลายหน้าต่างพร้อมกัน เลยต้องจองเลขไฟล์แบบที่ชนกันไม่ได้
- จองเลข: สแกนโฟลเดอร์หาเลขสูงสุดจากรูปแบบ
rec_NNNNแล้วบวกหนึ่ง สร้างไฟล์.mp3.lockด้วยFileMode.CreateNewกับFileShare.Noneถ้าชนกับหน้าต่างอื่นก็เพิ่มเลขแล้วลองใหม่ - ระหว่างอัด: เขียนลงไฟล์
.recording.wavด้วยWaveFileWriterฟอร์แมตของ capture ปกติเป็น float 32 บิต - หยุดอัด: สั่ง
silence.Stop()แล้วwriter.Dispose()ถ้าได้ศูนย์ไบต์ให้โยน error ว่าไม่ได้รับข้อมูลเสียง - แปลง: ให้ ffmpeg เขียนเป็นชื่อชั่วคราว
.encoding.mp3 - ส่งมอบ: เมื่อ ffmpeg สำเร็จและไฟล์ไม่ว่างเท่านั้น ถึงจะย้ายเป็น
.mp3ด้วยFile.Moveแบบoverwrite: falseแล้วลบ WAV
โค้ดจองเลขกับตัวส่งมอบเป็นแบบนี้
public static RecordingSlot Reserve(string directory)
{
Directory.CreateDirectory(directory);
long next = Directory.EnumerateFiles(directory)
.Select(p => Regex.Match(Path.GetFileName(p), @"^rec_(\d+)\.", RegexOptions.IgnoreCase))
.Where(m => m.Success).Select(m => long.Parse(m.Groups[1].Value))
.DefaultIfEmpty(0).Max() + 1;
while (true)
{
var path = Path.Combine(directory, $"rec_{next:D4}.mp3");
try
{
var handle = new FileStream(path + ".lock", FileMode.CreateNew,
FileAccess.Write, FileShare.None);
if (File.Exists(path)) { handle.Dispose(); File.Delete(path + ".lock"); next++; continue; }
return new RecordingSlot(path, handle);
}
catch (IOException) when (File.Exists(path + ".lock")) { next++; }
}
}
ที่ผมชอบวิธีนี้คือ CreateNew เป็นตัวตัดสินว่าใครได้เลขนั้น ไม่ต้องพึ่งการเช็กว่าไฟล์มีอยู่แล้วค่อยสร้าง (ที่มีช่องให้สองโปรเซสเช็กผ่านพร้อมกัน) ถ้าไฟล์ .lock ของเลขนั้นมีคนถืออยู่ มันจะโยน IOException แล้ววนบวกเลขไปเรื่อย ๆ จนได้เลขที่ว่างจริง

แปลงเป็น MP3 ด้วย ffmpeg แบบไม่เขียนทับและไม่รอ stdin
ขั้นแปลงผมเรียก ffmpeg ผ่าน ProcessStartInfo โดยส่งอาร์กิวเมนต์ทีละตัวผ่าน ArgumentList ไม่ประกอบสตริงคำสั่งเอง จะได้ไม่ต้องกังวลเรื่องช่องว่างในพาธ ค่าที่ใช้คือ
ffmpeg -hide_banner -loglevel error -nostdin -n \
-i rec_0001.recording.wav \
-vn -ar 48000 -ac 2 -c:a libmp3lame -b:a 320k \
rec_0001.encoding.mp3
เหตุผลของแต่ละธงสั้น ๆ คือ -n ห้ามเขียนทับไฟล์ที่มีอยู่แล้ว ถ้ามีชื่อซ้ำ ffmpeg จะออกด้วย error แทนที่จะทับเงียบ ๆ -nostdin กันไม่ให้ ffmpeg ไปรออ่านคีย์บอร์ดเมื่อถูกเรียกจากโปรแกรมที่ไม่มีคอนโซล -loglevel error ให้ stderr มีแต่ข้อความผิดพลาดจริง ซึ่งโปรแกรมอ่านเก็บไว้แนบในข้อความ error เวลาล้ม ส่วนต้องใช้ ffmpeg ที่คอมไพล์มากับ libmp3lame ถ้ารุ่นที่หาเจอไม่มี ก็จะล้มตรงขั้นนี้ และ WAV ยังอยู่ให้ใช้ต่อ
โปรแกรมหา ffmpeg.exe ตามลำดับ คือข้าง ๆ ไฟล์โปรแกรม โฟลเดอร์มาตรฐานที่ตั้งไว้ แล้วค่อยไล่ตาม PATH และตรวจตั้งแต่ก่อนเริ่มอัดเลย ไม่ใช่รอให้อัดจบแล้วค่อยรู้ว่าไม่มี
เขียนตรงเป็น .mp3
ถ้า ffmpeg ถูกฆ่ากลางคันหรือดิสก์เต็ม จะมีไฟล์ .mp3 ที่ดูเหมือนเสร็จแล้วแต่เล่นได้แค่ครึ่ง และโปรแกรมอื่นที่คอยดูโฟลเดอร์อาจหยิบไปใช้ทันที
ชื่อสุดท้ายแปลว่าเสร็จจริง
ไฟล์ที่ลงท้าย .mp3 ทุกไฟล์ผ่านการแปลงสำเร็จและไม่ว่างมาแล้ว ส่วนที่ค้างกลางทางจะมีชื่อ .encoding.mp3 หรือ .recording.wav ซึ่งดูแล้วรู้ทันทีว่าไม่ใช่ของสำเร็จรูป
--self-test: พิสูจน์ keepalive โดยไม่ต้องเปิดเสียงทดสอบ
ผมไม่ชอบเชื่อว่า keepalive ทำงานเพียงเพราะโค้ดดูถูก เลยทำโหมด --self-test ที่รันกับอุปกรณ์จริงของเครื่อง โดยไม่เปิดเสียงทดสอบออกลำโพง ตรวจสี่อย่างตามลำดับ
- จองเลขสองครั้งซ้อนกัน ต้องได้เลข 0001 กับ 0002 ตามลำดับ
- สร้างคลื่นไซน์ 440 Hz แอมพลิจูด 0.05 ยาว 1.5 วินาที แปลงเป็น MP3 แล้วอ่านความยาวกลับมา ต้องไม่ต่ำกว่า 1.4 วินาที
- จองเลขอีกรอบ ต้องไม่ได้ชื่อชนกับไฟล์แรก (ของเดิมไม่โดนทับ)
- อัด loopback จริงบนอุปกรณ์เริ่มต้นนาน 1.8 วินาที ขณะที่ไม่มีเสียงออกเลย แล้วตรวจว่า MP3 ที่ได้ยาวไม่ต่ำกว่า 1.3 วินาที
ข้อสี่คือหัวใจ ถ้าไม่มี keepalive อุปกรณ์ที่เงียบสนิทจะไม่ส่ง buffer และไฟล์จะสั้นมากหรือเป็นศูนย์ไบต์ แต่ถ้าผ่านเกณฑ์ 1.3 วินาที แปลว่า buffer มาต่อเนื่องแม้ไม่มีเสียงออก ซึ่งตรงกับที่ต้องพิสูจน์พอดี
var session = new RecordingSession(directory, device.ID);
var run = session.RunAsync(() => { });
await Task.Delay(1800); // อัดตอนเงียบ 1.8 วินาที
session.RequestStop();
var output = await run.WaitAsync(TimeSpan.FromSeconds(20));
using var captured = new Mp3FileReader(output);
if (captured.TotalTime.TotalSeconds < 1.3)
throw new Exception("Loopback/silence timing failed");
ผลการรันเขียนเป็น self-test-result.json ข้างไฟล์โปรแกรม ถ้าล้มก็เขียน self-test-error.txt แทน เกณฑ์ 1.3 ต่ำกว่า 1.8 เพราะผมเผื่อเวลาเริ่มและหยุดของอุปกรณ์ ไม่ได้ต้องการวัดความแม่นระดับมิลลิวินาที แค่แยกให้ออกว่า "เวลาไม่หาย" กับ "เวลาหายไปเกือบหมด"
ทำไมเขียน WAV ก่อน ไม่เข้ารหัส MP3 ระหว่างอัด
คำถามที่ผมโดนถามบ่อยคือทำไมไม่ pipe เสียงเข้า ffmpeg ตรง ๆ ให้ได้ MP3 เลยทีเดียว เหตุผลของผมมีสองข้อ ข้อแรก ระหว่างอัดผมอยากให้เส้นทางสั้นที่สุด คือ DataAvailable แล้วเขียนลงดิสก์ทันที ไม่มีโปรเซสอีกตัวมาคั่น ถ้า ffmpeg ค้างหรือท่อเต็ม ข้อมูลเสียงจะไม่ติดไปด้วย ข้อสอง ถ้ากระบวนการแปลงล้ม ผมยังมี WAV ดิบเก็บไว้ ลองแปลงใหม่ได้ด้วยคำสั่งเดิม ไม่ต้องอัดซ้ำ ซึ่งสำหรับเสียงที่เล่นรอบเดียวจากแหล่งที่เล่นซ้ำยาก ข้อนี้มีค่ามาก
ราคาที่จ่ายคือพื้นที่ดิสก์ชั่วคราว ถ้าอุปกรณ์ทำงานที่ 48 kHz สเตอริโอ และ capture ส่งมาเป็น float 32 บิต อัตราข้อมูลคือ 48,000 x 2 x 4 = 384,000 ไบต์ต่อวินาที หรือประมาณ 1.3 GB ต่อชั่วโมง (เป็นการคำนวณจากฟอร์แมตสมมติ ของจริงดูได้จาก AverageBytesPerSecond ที่โปรแกรมอ่านจากอุปกรณ์) สำหรับงานอัดไม่กี่นาทีไม่เป็นปัญหา แต่ถ้าจะอัดยาวเป็นชั่วโมงต้องเผื่อที่ว่างไว้ และ WAV จะถูกลบเองเมื่อแปลงสำเร็จเท่านั้น
อีกเรื่องที่เกี่ยวกัน คือฟอร์แมตของไฟล์ WAV ถูกกำหนดจาก capture.WaveFormat ไม่ใช่ค่าที่ผมเลือกเอง ขั้นแปลงด้วย -ar 48000 -ac 2 เลยเป็นตัวปรับให้ปลายทางเป็น 48 kHz สเตอริโอเสมอ ไม่ว่าอุปกรณ์ต้นทางจะใช้ฟอร์แมตอะไร
ข้อควรระวังที่ยังเหลือ
WAV ที่ค้างอยู่อาจมีส่วนหัวไม่สมบูรณ์ เพราะความยาวในส่วนหัวไฟล์ถูกเขียนตอนปิดไฟล์ ผมยังไม่ได้ทดสอบวิธีกู้ไฟล์แบบนี้ เลยไม่ขอเขียนวิธีกู้ที่ไม่เคยลอง ถ้าเจอเคสนี้ให้ถือว่าข้อมูลดิบยังอยู่แต่ต้องหาวิธีอ่านเอง
- เปลี่ยนลำโพงหรือหูฟังระหว่างอัด ให้หยุดแล้วเริ่มรอบใหม่ เพราะทั้ง capture และตัวเล่นเงียบผูกกับอุปกรณ์เดียวตั้งแต่เริ่ม
- อุปกรณ์เสียงหยุดกลางคัน (อีเวนต์
RecordingStoppedมี exception) โปรแกรมยังแปลงเท่าที่อัดได้แล้วแจ้งเตือน ไม่ทิ้งทั้งหมด - ปิดหน้าต่างระหว่างอัด โปรแกรมสั่งหยุดและบันทึกก่อนปิดเสมอ
- ความยาวไฟล์คือความยาวของสิ่งที่อุปกรณ์ส่งมา ถ้าอุปกรณ์เปลี่ยนอัตราซัมเปิลกลางทาง ต้องหยุดแล้วเริ่มใหม่เหมือนข้อแรก
build และสรุป
โปรเจกต์เป็น Windows Forms บน .NET 8 อ้าง NAudio ผ่าน NuGet คำสั่ง build กับ publish เป็นไฟล์เดียวคือ
dotnet build -c Release
dotnet publish -r win-x64 --self-contained false -p:PublishSingleFile=true
.\PcAudioRecorder.exe --self-test
ส่วนตัวผมถือว่าถ้าอัดเสียงเพื่อใช้เป็นเส้นเวลา ต้องมี keepalive เท่านั้น ไม่งั้นความยาวไฟล์ไม่ได้บอกอะไรเกี่ยวกับเวลาจริง ส่วนรายละเอียดอื่นอย่างขนาด latency 100 ms หรือชื่อสกุลไฟล์ชั่วคราว ตามใจชอบได้ตราบที่หลักการสามข้อยังอยู่ คือเล่นเงียบคู่กับการอัด จองเลขด้วย CreateNew และไม่ให้ชื่อสุดท้ายไปอยู่กับไฟล์ที่ยังไม่เสร็จ
ถ้าชอบเรื่องฮาร์ดแวร์ฝั่ง I/O แบบนี้ มีอีกสองเรื่องที่คนละปัญหากัน คือการส่ง USB เข้า WSL ใน usbip-wsl2 กับการอ่านเครื่องชั่งผ่านบลูทูธใน serial-scale-toledo-bluetooth ตรงไหนผมเขียนตกหรืออธิบายไม่ชัด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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