เสียงทุกอย่างที่เกมสร้างขึ้น — เสียงฝีเท้า เสียงปืน เสียงพูดของตัวละคร เพลงประกอบ — พอไปถึงลำโพงแล้วกลายเป็นสิ่งเดียวกันหมด คือ list ตัวเลขยาวๆ บทนี้พูดถึง list นั้น เสียงกลายเป็นตัวเลขได้ยังไงตั้งแต่แรก แล้วก็คณิตศาสตร์เล็กๆ ที่เราใช้เปลี่ยนตัวเลขพวกนั้น เช่น เพิ่มความดัง ผสมสองเสียงเข้าด้วยกัน ทำให้เสียงแหบๆ นุ่มขึ้น ใส่เสียงก้อง หรือปั้นความดังของโน้ตตามเวลา วงการทั้งหมดนี้เรียกว่า DSP (Digital Signal Processing — การประมวลผลที่มอง signal ในที่นี้คือเสียง เป็นข้อมูล คือตัวเลขทีละตัว วนใน loop) ไม่มีอะไรเกินสิ่งที่เรารู้อยู่แล้วเลย: array, loop, for วนบน buffer และเลขคณิตนิดหน่อย
เราจะสร้างมันตามลำดับที่ audio engine จริงๆ ทำ: sample คืออะไรกันแน่ สร้างขึ้นมาจากศูนย์ ปรับให้ดังขึ้นหรือเบาลง ผสมสองเสียงเข้าด้วยกันอย่างปลอดภัย กรองมันด้วย filter หน่วงมันให้กลายเป็นเสียงก้อง คิดถึงมันในเชิง frequency แทนที่จะเป็นเวลา ปั้นรูปทรงมันด้วย envelope และสุดท้าย โค้ดทั้งหมดนี้รันอยู่ตรงไหนในเกมจริงๆ — ใน callback ที่เข้มงวดมาก ห้าม allocate memory เด็ดขาด รันอยู่บน thread ของตัวเองโดยเฉพาะ
เสียงในโลกจริงคือความดันอากาศที่สั่นขึ้นลงเร็วมากๆ ไมโครโฟนแปลงการสั่นนั้นให้เป็นแรงดันไฟฟ้า (voltage) ที่สั่นขึ้นลงแบบต่อเนื่อง ราบเรียบ ไม่มีช่องว่าง คอมพิวเตอร์เก็บสิ่งที่ต่อเนื่อง (continuous) ไม่ได้ — เก็บได้แค่ตัวเลขจำนวนจำกัด ดังนั้นเพื่อแปลงเสียงเป็นข้อมูล คอมพิวเตอร์จะวัดความสูงของการสั่นนั้นที่ช่วงเวลาคงที่สม่ำเสมอ การวัดแต่ละครั้งเรียกว่า sample: ตัวเลขหนึ่งตัว ปกติเป็นค่า floating-point ประมาณระหว่าง -1.0 ถึง +1.0 ตัวเลขนั้นสูงหรือต่ำแค่ไหน ณ ขณะหนึ่งๆ เรียกว่า amplitude (ความดัง/แรงดันของเสียง ณ ขณะนั้น — 0.0 แปลว่าเงียบสนิทในขณะนั้น +1.0 หรือ -1.0 แปลว่าดังสุดเท่าที่ format จะรองรับได้)
ความถี่ที่วัดค่าพวกนี้เรียกว่า sample rate วัดเป็น Hz (จำนวนครั้งต่อวินาที) เสียงคุณภาพระดับ CD และเสียงเกมส่วนใหญ่ใช้ 44100 Hz (44,100 sample ต่อวินาที) บางทีเขียนเป็น 44.1kHz บาง engine ใช้ 48000 Hz แทน ทำไมต้องสูงขนาดนั้น หูมนุษย์ได้ยินสูงสุดประมาณ 20,000 Hz และมีกฎจาก information theory (the Nyquist theorem — จะไม่พิสูจน์ในที่นี้) บอกว่าต้องใช้ sample rate อย่างน้อยสองเท่าของความถี่สูงสุดที่ต้องการจับ 44100 นั้นสูงกว่าสองเท่าของ 20,000 อย่างสบายๆ
sample ทั้งหมดนี้เรียงต่อกันถูกเก็บไว้ใน array — ในโค้ดเสียง array นี้เกือบทุกครั้งจะเรียกว่า buffer ถ้าเอาค่าใน buffer มาพล็อตตามเวลาจะได้ภาพที่เรียกว่า waveform — รูปทรงที่เห็นในโปรแกรมตัดต่อเสียงทุกตัว
float ใน memory ไม่ว่าไฟล์จะเก็บแบบไหนก็ตาม นี่คือเหตุผลที่ตัวอย่างทุกอันในบทนี้ใช้ floatเสียงที่ง่ายที่สุดคือ pure tone: sine wave เส้นเดียวที่เรียบๆ มันคือ building block ที่ทุกอย่างในบทนี้ถูกอธิบายด้วย (พูดเพิ่มในหัวข้อ 9) sine wave มี frequency (จำนวนรอบการสั่นเต็มรอบต่อวินาที หน่วยเป็น Hz — นี่คือสิ่งที่เรารับรู้เป็น pitch หรือระดับเสียง) และ amplitude (การสั่นสูงแค่ไหน — นี่คือสิ่งที่เรารับรู้เป็นความดัง)
สูตรคณิตศาสตร์สำหรับหนึ่ง sample คือ: sample = amplitude * sin(2 * pi * frequency * n / sampleRate) โดย n คือ index ของ sample (sample 0, sample 1, sample 2, ...) ส่วนที่อยู่ใน sin(...) เรียกว่า phase — ตำแหน่งปัจจุบันรอบวงกลมของคลื่น หน่วยเป็น radian แทนที่จะคำนวณสูตรทั้งหมดใหม่ทุกครั้งสำหรับทุก sample ง่ายกว่าและถูกกว่าถ้าเก็บค่า phase ไว้ตัวเดียวแล้วบวกค่าคงที่เข้าไปทีละสเต็ป:
#include <cmath>
void FillSineBuffer(float* buffer, int numSamples, float frequency,
float sampleRate, float amplitude, float& phase)
{
float phaseStep = 2.0f * 3.14159265f * frequency / sampleRate;
for (int n = 0; n < numSamples; n++)
{
buffer[n] = amplitude * std::sin(phase);
phase += phaseStep;
if (phase > 2.0f * 3.14159265f)
phase -= 2.0f * 3.14159265f; // keep the angle from growing forever
}
}
phase ถูกส่งแบบ pass by reference (float&) และเก็บไว้นอกฟังก์ชันโดยตั้งใจ: ถ้าเรียก FillSineBuffer อีกครั้งสำหรับ chunk เสียงถัดไป คลื่นต้องต่อจากจุดที่ค้างไว้พอดี ไม่งั้นจะได้ยินเสียง click ตรงรอยต่อระหว่าง buffer ลองไล่ดู 5 sample ของโทน 440 Hz (โน้ตดนตรี A4) ที่ 44100 Hz, amplitude 0.5, เริ่มจาก phase = 0:
float phase = 0.0f;
float buf[5];
FillSineBuffer(buf, 5, 440.0f, 44100.0f, 0.5f, phase);
for (int i = 0; i < 5; i++)
printf("%.4f\n", buf[i]);
Output:
0.0000
0.0313
0.0625
0.0935
0.1241
แต่ละสเต็ปที่เดินหน้าไปจะบวก phaseStep ค่าคงที่เท่าเดิม (ประมาณ 0.0627 radian ในที่นี้) ทำให้ sin(phase) ไต่ขึ้นจาก 0 อย่างนุ่มนวล — นี่คือจุดเริ่มต้นของคลื่นที่กำลังไต่ขึ้นไปหาจุดสูงสุด frequency ที่สูงขึ้นจะทำให้ phaseStep ใหญ่ขึ้น sine เลยไต่และวนซ้ำเร็วขึ้น (สั่นถี่ขึ้นต่อวินาที pitch สูงขึ้น) ส่วน amplitude ที่ใหญ่ขึ้นแค่ scale ทุก sample ให้มากขึ้นหรือน้อยลงโดยไม่เปลี่ยนรูปทรงเลย — ซึ่งเป็นหัวข้อของหัวข้อถัดไปพอดี
การปรับเสียงให้ดังขึ้นหรือเบาลงก็แค่การคูณทุก sample ด้วยตัวเลขตัวหนึ่ง เรียกว่า gain (ตัวคูณที่ใส่เข้าไปใน signal เวลาที่ผู้ใช้เห็นมักเรียกว่า volume) gain เท่ากับ 1.0 ทำให้เสียงไม่เปลี่ยน 0.5 ทำให้เบาลง 0.0 ทำให้เงียบสนิท และค่าที่มากกว่า 1.0 ทำให้ดังกว่าเดิม
void ApplyVolume(float* buffer, int numSamples, float volume)
{
for (int n = 0; n < numSamples; n++)
buffer[n] *= volume;
}
float samples[4] = { 0.2f, -0.4f, 0.6f, -0.8f };
ApplyVolume(samples, 4, 0.5f);
for (int i = 0; i < 4; i++)
printf("%.4f\n", samples[i]);
Output:
0.1000
-0.2000
0.3000
-0.4000
ทุกค่าหดลงเหลือครึ่งหนึ่งพอดี ไม่ว่าจะเป็นค่าบวกหรือลบ — การคูณด้วย 0.5 ดึงทุก sample เข้าใกล้ 0.0 เท่าๆ กัน นี่คือไอเดียทั้งหมดที่อยู่เบื้องหลัง volume slider ใน settings menu ของเกม: อ่านค่าจาก slider (0.0 ถึง 1.0) แล้วคูณเข้าไปใน buffer ก่อนที่เสียงจะไปถึงลำโพง
-1.0) ไม่ได้ทำให้เสียง "ดังติดลบ" — มันแค่กลับ sign ของทุก sample ซึ่งทำให้ waveform กลับหัว ฟังเดี่ยวๆ หูมนุษย์จะได้ยินเหมือนต้นฉบับทุกอย่าง (ความดังขึ้นอยู่กับขนาดของตัวเลขเท่านั้น ไม่ใช่ sign) แต่มันสำคัญทีหลัง: การเอา signal มาบวกกับสำเนาที่ถูกกลับ sign ของตัวมันเองจะหักล้างกันจนเหลือ 0.0 พอดี ซึ่งเป็นกลไกทั้งหมดที่อยู่เบื้องหลัง noise-cancelling และ phase-cancellation effectเวลาสองเสียงเล่นพร้อมกัน — เช่น เสียงฝีเท้ากับเพลงประกอบ — engine ต้องรวมมันเข้าเป็น buffer เดียวก่อนที่จะไปถึงลำโพง Mixing สอง buffer เข้าด้วยกันก็แค่บวก sample ของมันเข้าด้วยกันทีละตำแหน่ง
float Clamp(float x, float lo, float hi)
{
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
void MixBuffers(float* dst, const float* src, int numSamples)
{
for (int n = 0; n < numSamples; n++)
{
float sum = dst[n] + src[n];
dst[n] = Clamp(sum, -1.0f, 1.0f); // see the warning below
}
}
ลองไล่ดูด้วย buffer เสียงฝีเท้า (dry) กับ buffer sound effect (sfx) ที่บังเอิญดังพร้อมกันในจังหวะเดียวกัน:
float dry[3] = { 0.6f, 0.7f, -0.5f };
float sfx[3] = { 0.5f, 0.6f, -0.6f };
MixBuffers(dry, sfx, 3);
for (int i = 0; i < 3; i++)
printf("%.4f\n", dry[i]);
Output:
1.0000
1.0000
-1.0000
ก่อน clamp ผลรวมดิบๆ คือ 1.1, 1.3 และ -1.1 — ทุกตัวอยู่นอกช่วง -1.0 .. +1.0 ที่ลำโพง (และตัว audio format เอง) รองรับได้ นี่เรียกว่า clipping: sample ตัวไหนที่เกินขีดจำกัดจะถูกตัดให้แบนแทนที่จะไปถึงจุดสูงสุดจริงๆ ของมัน
Clamp ข้างบนไม่ได้ลดความดังอย่างนุ่มนวล — มันตัดคลื่นให้แบน ซึ่งฟังดูเหมือน digital distortion ที่แสบหู ไม่ใช่แค่ "ดังขึ้น" วิธีแก้ทั่วไป: ให้แต่ละหมวดเสียง (ฝีเท้า เพลง เสียงพูด SFX) มี volume budget ของตัวเอง เพื่อให้ผลรวมกรณีเลวร้ายที่สุดแทบไม่เกิน 1.0 ลด master mix volume ตอนที่มีหลายเสียงซ้อนกัน หรือรัน limiter (DSP effect ที่ดึงทั้ง signal ลงอัตโนมัติแค่พอไม่ให้เกิด clipping แทนที่จะตัดมันแบน) บน buffer ที่ mix เสร็จแล้วก่อนที่จะไปถึงลำโพงเสียง mono (ลำโพงเดียว) คือ buffer เดียว ตัวเลขหนึ่งตัวต่อหนึ่งขณะเวลา เสียง stereo (ลำโพงซ้าย/ขวา) ต้องการตัวเลขสองตัวต่อขณะเวลา — หนึ่งสำหรับแชนแนลซ้าย หนึ่งสำหรับขวา game engine เกือบทุกตัวเก็บเสียง stereo แบบ interleaved: ไม่ใช่ array แยกกันสองตัว แต่เป็น array เดียวที่สลับกัน left, right, left, right, ...
Pan ควบคุมว่าเสียง mono จะไปลำโพงซ้ายกี่ส่วนเทียบกับขวา (ค่าตั้งแต่ -1.0 ซ้ายสุด, 0.0 กึ่งกลาง, ไปจนถึง +1.0 ขวาสุด) pan แบบง่าย (linear) ก็แค่แยกหนึ่ง sample ออกเป็นสองสำเนาที่ scale ไม่เท่ากัน:
void ApplyPan(float sampleIn, float pan, float& outLeft, float& outRight)
{
// pan: -1.0 = full left, 0.0 = center, 1.0 = full right
outLeft = sampleIn * 0.5f * (1.0f - pan);
outRight = sampleIn * 0.5f * (1.0f + pan);
}
float left, right;
ApplyPan(0.8f, -0.5f, left, right);
printf("left=%.4f right=%.4f\n", left, right);
Output:
left=0.6000 right=0.2000
เมื่อ pan = -0.5 (เอียงไปทางซ้าย) แชนแนลซ้ายเก็บ signal ไว้ส่วนใหญ่ (0.6) ส่วนแชนแนลขวาได้น้อยกว่า (0.2) ถ้า pan = 0 ทั้งสองแชนแนลจะได้ครึ่งหนึ่งเท่ากันพอดี
pan = 0) ทั้งสองแชนแนลถูกคูณด้วย 0.5 เหมือนกัน ซึ่งอาจทำให้เสียงตรงกลางฟังดูเบากว่าเสียงที่ pan ไปสุดข้างใดข้างหนึ่ง เพราะลำโพงสองตัวที่เล่น signal เดียวกันที่ half-volume จะถูกรับรู้ว่าดังกว่าลำโพงตัวเดียวที่ full volume เมื่อรวมกัน เครื่องมือเสียงระดับมืออาชีพมักใช้ equal-power pan แทน โดย scale ด้วย cos/sin ของมุมที่คำนวณจาก pan แทนที่จะเป็นเส้นตรง เพื่อให้ความดังที่รับรู้คงที่ตลอดขณะที่เสียงเคลื่อนข้ามสนาม stereo เวอร์ชัน linear ข้างบนเรียนรู้ง่ายกว่าและเพียงพอสำหรับสถานการณ์ในเกมหลายๆ แบบfilter ปรับรูปทรงของ signal ตามความเร็วที่มันเปลี่ยนแปลง ไม่ใช่ตามความดัง low-pass filter ปล่อยให้การเปลี่ยนแปลงที่ช้าๆ ค่อยเป็นค่อยไปผ่านไปได้ แล้วทำให้การเปลี่ยนแปลงที่เร็วฉับพลันเรียบลง — ในแง่ของเสียงแปลว่ามันปล่อยให้ pitch ต่ำผ่านแล้วอู้ pitch สูง (การตัด "highs" ออก เหมือนเสียงที่ได้ยินผ่านกำแพงหรือใต้น้ำ)
low-pass filter ที่ง่ายที่สุดต้องการคณิตศาสตร์แค่บรรทัดเดียว เรียกว่า recurrence relation (สูตรที่นิยามค่าถัดไปจากค่าก่อนหน้า — เคยเห็นไอเดียนี้มาแล้วตอน recursion ในบทก่อนๆ แต่ในที่นี้มันวิ่งไปข้างหน้าผ่าน buffer แทนที่จะเรียกตัวเองซ้ำ):
output ใหม่แต่ละตัวคือ output เก่าที่ถูกดันขยับไปทางค่า input ใหม่แค่บางส่วน แทนที่จะกระโดดไปเลย การดันขยับแบบนี้แหละที่ทำให้การสั่นเร็วๆ เรียบลง filter แบบ one-pole จริงๆ ต้องเก็บ memory แค่ตัวเลขเดียวระหว่างการเรียกแต่ละครั้ง (y[n-1]) นี่คือเหตุผลที่มันถูกเรียกว่า "one-pole"
struct OnePoleLowPass
{
float a; // smoothing coefficient, 0..1
float y_prev = 0.0f; // filter memory: the last output
float Process(float x)
{
y_prev = y_prev + a * (x - y_prev);
return y_prev;
}
};
ลองไล่ดูกับ input ที่ฉับพลันแล้วคงอยู่ต่อเนื่อง — เหมือนเสียงดังที่เริ่มขึ้นทันทีแล้วดังต่อเนื่อง (x = 1.0 ซ้ำๆ) โดย a = 0.3:
OnePoleLowPass lp;
lp.a = 0.3f;
for (int i = 0; i < 5; i++)
printf("%.3f\n", lp.Process(1.0f));
Output:
0.300
0.510
0.657
0.760
0.832
แทนที่จะกระโดดไป 1.000 ทันที filter ค่อยๆ ไต่เข้าหามัน: แต่ละสเต็ปปิดช่องว่างที่เหลือไป 30% (a = 0.3) การกระโดดฉับพลันคือการเปลี่ยนแปลงที่เร็ว — ตรงกับสิ่งที่ low-pass filter ถูกสร้างมาเพื่อทำให้เรียบลงพอดี ถ้าป้อน signal ที่ช้าและนิ่งอยู่แล้วเข้าไป มันแทบจะไม่เปลี่ยนอะไรเลย
a เชื่อมโยงกับ cutoff frequency จริงๆ (pitch ที่เสียงเริ่มถูกอู้ตั้งแต่ตรงนั้นขึ้นไป) ด้วยสูตร a = 1 - exp(-2 * pi * cutoffHz / sampleRate) ไม่ต้องจำการพิสูจน์ก็ได้ — จำแค่ทิศทาง: cutoffHz ที่ต่ำกว่าจะให้ a เล็กกว่า ซึ่งหมายถึงการทำให้เรียบหนักขึ้น (signal ถูกอู้มากขึ้น)high-pass filter ทำตรงข้ามกับ low-pass: มันปล่อยให้การเปลี่ยนแปลงเร็วๆ (pitch สูง) ผ่าน แล้วกั้นสิ่งที่ช้าและนิ่ง (pitch ต่ำและเสียงฮัม) วิธีที่ถูกที่สุดในการสร้างมันคือเอา low-pass filter ที่มีอยู่แล้วมาใช้ซ้ำ: อะไรก็ตามที่ low-pass ทำให้เรียบหายไปและกันไว้ นั่นแหละคือผลลัพธ์ของ high-pass เอา low-pass output ไปลบออกจาก input ต้นฉบับ เหลืออะไรก็คือทุกอย่างที่ low-pass filter เอาออกไป
struct OnePoleHighPass
{
OnePoleLowPass lowpass; // reuse the low-pass from section 6
float Process(float x)
{
float low = lowpass.Process(x);
return x - low; // whatever the low-pass did NOT keep
}
};
ลองรัน input x = 1.0 แบบต่อเนื่องเดียวกับหัวข้อ 6 ผ่านมัน (a = 0.3):
0.700
0.490
0.343
0.240
0.168
ค่าพวกนี้คือ 1.0 ลบด้วยค่า low-pass แต่ละตัวจากหัวข้อ 6 พอดี (1.0 - 0.300 = 0.700 ไปเรื่อยๆ) high-pass output เริ่มแรงตั้งแต่ต้น (การกระโดดฉับพลันเองก็คือการเปลี่ยนแปลงที่ใหญ่และเร็ว) แล้วค่อยๆ จางลงเป็นศูนย์ตามที่ signal นิ่งลงเป็นค่าคงที่ — ซึ่งก็สมเหตุสมผล: high-pass filter ตอบสนองต่อ การเปลี่ยนแปลง เท่านั้น และ signal คงที่สุดท้ายก็ไม่มีอะไรให้ตอบสนองอีกแล้ว
band-pass filter ปล่อยผ่านแค่ช่วงกลาง ("band") ของ frequency เท่านั้น กั้นทั้ง lows และ highs ที่อยู่นอกช่วงนั้น — เสียง "เสียงพูดในโทรศัพท์" หรือ "วิทยุเก่า" แบบคลาสสิก สร้างมันโดยการต่อ high-pass (เพื่อเอา lows ออก) เข้ากับ low-pass (เพื่อเอา highs ออก) เพื่อให้เหลือแต่สิ่งที่รอดทั้งสองขั้นตอนแล้วผ่านไปได้:
struct OnePoleBandPass
{
OnePoleHighPass highpass;
OnePoleLowPass lowpass;
float Process(float x)
{
float noLows = highpass.Process(x);
return lowpass.Process(noLows);
}
};
ป้อน step ต่อเนื่องเดิม (x = 1.0 ซ้ำๆ) เข้าไปใน OnePoleBandPass ส่วนที่ต่ำและนิ่งจะถูก high-pass ภายในกั้นไว้ก่อน แล้วพลังงานเร็วๆ ที่เหลืออยู่ก็จะถูก low-pass ภายในทำให้เรียบต่ออีกที ผลลัพธ์คือขึ้นสูงสุดสั้นๆ ทันทีหลังการกระโดด แล้วก็จางกลับไปเป็นศูนย์ — มันไม่ปล่อยผ่านทั้งโทนคงที่บริสุทธิ์และช่วงเวลาที่เปลี่ยนแปลงคมที่สุด ปล่อยผ่านแค่สิ่งที่อยู่ระหว่างกลาง
echo ทำเสียงซ้ำหลังจากหน่วงเวลาสั้นๆ เบาลงทุกครั้ง การสร้างมันต้องมีวิธีจำ sample จากอดีต — delay line: buffer ขนาดคงที่ที่ใช้เป็น ring (circular buffer วนกลับไป index 0 หลังจาก slot สุดท้าย) sample ใหม่ถูกเขียนเข้าไป sample เก่าที่อยู่ห่างออกไปเป็นจำนวน slot คงที่ถูกอ่านกลับออกมา
struct Delay
{
static const int kSize = 4; // kept tiny so it is easy to trace by hand
float buffer[kSize] = { 0.0f, 0.0f, 0.0f, 0.0f };
int writePos = 0;
float feedback = 0.5f; // how much of the echo repeats again -- keep < 1.0
float Process(float input)
{
float wet = buffer[writePos]; // the echo: written here N samples ago
buffer[writePos] = input + wet * feedback; // new input + a fraction of the echo
writePos = (writePos + 1) % kSize; // move forward, wrap after the last slot
return wet; // caller mixes this with the dry signal
}
};
Process แค่คืนค่า sample ที่เป็น echo ("wet") กลับมา — การผสมมันกับ sample ต้นฉบับ ("dry") ก็คือ mixing (การบวก) จากหัวข้อ 4 นั่นเอง:
Delay delay;
float dry[9] = { 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f };
for (int n = 0; n < 9; n++)
{
float wet = delay.Process(dry[n]);
float outSample = dry[n] + wet * 0.5f; // 50% wet mix under the dry signal
printf("n=%d output=%.4f\n", n, outSample);
}
Output (แสดงเฉพาะ sample ที่น่าสนใจ ที่เหลือเป็น 0.0000):
n=0 output=1.0000
n=4 output=0.5000
n=8 output=0.2500
เสียง "clap" ดังครั้งเดียวที่ n=0 กลับมาเป็น echo อีก 4 sample ถัดมา (n=4) ถูก scale ลงด้วย wet mix 0.5 อีก 4 sample ถัดจากนั้น (n=8) มันกลับมาอีกครั้ง — คราวนี้ถูก scale ด้วย feedback = 0.5 ซ้อนเข้าไปอีก จึงเบาลงครึ่งหนึ่งของ echo แรก ทุกรอบเพิ่มเติมที่วนรอบ buffer จะคูณด้วย feedback ซ้ำอีก นี่คือเหตุผลที่ echo จริงๆ ค่อยๆ จางหายไปแทนที่จะซ้ำไปตลอดกาล
feedback เป็น 1.0 หรือมากกว่า ที่ 1.0 ไม่มีอะไรค่อยๆ จางเลย — ทุกครั้งที่ซ้ำจะดังเท่าเดิมตลอดไป มากกว่า 1.0 ทุกรอบจะยิ่งดังขึ้นเรื่อยๆ และค่าใน buffer จะโตขึ้นไม่มีขีดจำกัด จนกระทั่ง output กลายเป็นแค่ noise ที่ clip แบบแสบหู ให้ feedback น้อยกว่า 1.0 อย่างเคร่งครัดเสมอreverb พื้นฐาน (ความรู้สึกว่าเสียงอยู่ในห้อง ไม่ใช่แค่ซ้ำครั้งเดียว) สร้างจากไอเดียเดียวกัน แค่ขยายใหญ่ขึ้น: แทนที่จะใช้ delay line เดียว ใช้หลาย delay line ที่มีความยาวต่างกันไม่ตรงกัน ป้อนเข้าหากันและผสมกัน echo ที่ซ้อนกันและจางลงมากมายมาถึงใกล้กันมากจนหูหยุดได้ยินการซ้ำทีละครั้งแล้วได้ยินเป็นหางเสียงที่เรียบและกว้างแทน — ซึ่งตรงกับสิ่งที่ห้องเต็มไปด้วยพื้นผิวสะท้อนทำกับเสียงในชีวิตจริงพอดี
หัวข้อ 2 สร้าง pure sine wave เดี่ยวๆ เสียงจริงๆ — เสียงพูด สายกีตาร์ ระเบิด — ไม่ใช่ sine wave เดียว มันคือ sine wave หลายตัวที่มี frequency และความดังต่างกัน บวกรวมกันทั้งหมดพร้อมกัน นี่คือไอเดียหลักของ frequency domain: signal ที่ซ้ำตัวเองได้อธิบายเป็นผลรวมของ sine wave ง่ายๆ แต่ละตัวมี frequency กับ amplitude ของตัวเอง การอธิบายเสียงแบบนี้ (เป็น list ว่า "มี frequency ไหนมากแค่ไหน") ถูกต้องพอๆ กับการอธิบายมันเป็น buffer ของ sample ตามเวลา (time domain ซึ่งคือทุกอย่างที่เราทำมาตลอดบทนี้) — ทั้งสองแบบเป็นมุมมองที่ต่างกันของเสียงเดียวกันเป๊ะๆ
การสร้างโทนที่ "อุดมสมบูรณ์" ขึ้นจริงๆ ก็แค่การบวก การกระทำเดียวกับ mixing ในหัวข้อ 4 — แค่ในที่นี้เราบวก pitch สองแบบที่ต่างกันของเครื่องดนตรีชิ้นเดียวกัน แทนที่จะเป็นสองเสียงที่ต่างกัน:
const int N = 8;
float toneA[N];
float toneB[N];
float complexTone[N];
float phaseA = 0.0f;
float phaseB = 0.0f;
FillSineBuffer(toneA, N, 220.0f, 44100.0f, 0.3f, phaseA); // the fundamental note
FillSineBuffer(toneB, N, 440.0f, 44100.0f, 0.15f, phaseB); // one octave up, quieter
for (int n = 0; n < N; n++)
complexTone[n] = toneA[n] + toneB[n]; // this line IS "mixing" from section 4
ที่ n = 0 ทั้งสอง phase เริ่มที่ 0 ดังนั้น complexTone[0] = 0.3 * sin(0) + 0.15 * sin(0) = 0.0000 จากตรงนั้น sine wave ทั้งสองไต่ขึ้นด้วยความเร็วต่างกัน (220 Hz กับ 440 Hz มีค่า phaseStep ต่างกัน) ทำให้ผลรวมเลิกดูเหมือน sine เรียบๆ เส้นเดียวและเริ่มดูขรุขระ — ความขรุขระนั้นคือ frequency สองตัวซ้อนทับกันในตัวเลขชุดเดียวกันจริงๆ
การทำย้อนทิศทาง — เอา buffer ของ sample มาแล้วหาว่า frequency ไหนบ้าง (และมากแค่ไหน) ซ่อนอยู่ข้างใน — คือสิ่งที่ FFT (Fast Fourier Transform, อัลกอริทึมหนึ่ง เราจะไม่เขียนโค้ดของมันในที่นี้) ทำ ป้อน chunk ของ sample แบบ time-domain เข้าไป มันจะคืนชุด frequency bin กลับมา แต่ละ bin บอกว่ามีพลังงานอยู่แถวๆ frequency นั้นมากแค่ไหน
EQ (equalizer — ปุ่มควบคุมสไตล์ "bass/mid/treble" ในแอปเสียง) คือประโยคที่แล้วพอดี: boost หรือ cut ช่วง frequency ที่กำหนด ในทางแนวคิดคือรัน FFT คูณ bin บางตัวด้วย gain แล้วรันการดำเนินการย้อนกลับเพื่อกลับไปเป็น buffer แบบ time-domain:
// Pseudocode -- shows the IDEA of an FFT-based EQ; not runnable as-is.
float freqBins[bins] = FFT(timeBuffer); // time domain -> frequency domain
freqBins[bassRange] *= 1.5f; // boost the bass frequencies
freqBins[trebleRange] *= 0.6f; // cut the treble frequencies
float timeBuffer2[N] = InverseFFT(freqBins); // frequency domain -> time domain again
หัวข้อ 3 คูณทุก sample ด้วย volume คงที่ตัวเดียว envelope คือไอเดียเดียวกันที่ยืดออกไปตามเวลา: แทนที่จะเป็นตัวคูณค่าคงที่ตัวเดียว มันคือตัวคูณที่เปลี่ยนไปตลอดช่วงชีวิตของเสียง — เบาตอนแรก ไต่ขึ้นไปจุดสูงสุด นิ่งลง แล้วจางหายไป รูปทรงที่พบบ่อยที่สุด ใช้กันแทบทุก synthesizer และ sampler คือ ADSR: Attack, Decay, Sustain, Release
enum class EnvelopeState { Attack, Decay, Sustain, Release, Idle };
struct ADSR
{
float attackStep; // added to value each sample during Attack
float decayStep; // subtracted from value each sample during Decay
float sustainLevel; // held level during Sustain
float releaseStep; // subtracted from value each sample during Release
float value = 0.0f;
EnvelopeState state = EnvelopeState::Attack;
void NoteOff() { state = EnvelopeState::Release; }
float Next()
{
switch (state)
{
case EnvelopeState::Attack:
value += attackStep;
if (value >= 1.0f) { value = 1.0f; state = EnvelopeState::Decay; }
break;
case EnvelopeState::Decay:
value -= decayStep;
if (value <= sustainLevel) { value = sustainLevel; state = EnvelopeState::Sustain; }
break;
case EnvelopeState::Sustain:
break; // holds until NoteOff() is called
case EnvelopeState::Release:
value -= releaseStep;
if (value <= 0.0f) { value = 0.0f; state = EnvelopeState::Idle; }
break;
case EnvelopeState::Idle:
break;
}
return value;
}
};
ลองไล่ดูด้วย attackStep = 1/3, decayStep = 0.25, sustainLevel = 0.5, releaseStep = 0.25 เรียก Next() หนึ่งครั้งต่อ sample แล้วเรียก NoteOff() ทันทีหลังการเรียกครั้งที่ 6:
call 1 (Attack): 0.3333
call 2 (Attack): 0.6667
call 3 (Attack): 1.0000 -> reaches peak, switches to Decay
call 4 (Decay): 0.7500
call 5 (Decay): 0.5000 -> reaches sustainLevel, switches to Sustain
call 6 (Sustain): 0.5000 -> NoteOff() called here
call 7 (Release): 0.2500
call 8 (Release): 0.0000 -> reaches 0, switches to Idle
เอาค่า value ที่คืนมานี้ไปคูณเข้ากับ sample ของเสียงทุก sample (เหมือน ApplyVolume ในหัวข้อ 3 เป๊ะๆ ต่างแค่ตัวคูณตอนนี้มาจาก Next() แทนที่จะเป็นตัวเลขคงที่) แล้วก็จะได้โน้ตที่ค่อยๆ ดังขึ้น นิ่งลง ค้างอยู่ตอนที่กดปุ่มหรือ trigger ค้างไว้ แล้วค่อยๆ จางหายไปตอนปล่อย — แทนที่จะเป็นเสียง click on/off แบบทันทีทันใด
โค้ดทุกชิ้นในบทนี้มีจุดร่วมกันอย่างหนึ่ง: มันเติมหรือแก้ buffer ของ sample ทีละ chunk นี่คือวิธีที่ engine จริงๆ ขอเสียง บน audio thread ของตัวเองโดยเฉพาะ (แยกจาก game thread ที่รัน Update(), physics, และ AI) engine จะเรียกเข้ามาในโค้ดของเราเป็นระยะๆ ผ่าน audio callback — ฟังก์ชันที่มีหน้าที่ทั้งหมดคือ: "เติม buffer sample นี้ให้เสร็จ เดี๋ยวนี้เลย ก่อนที่มันจะถึงกำหนดไปถึงลำโพง"
เพราะ deadline เข้มงวดมากและผลของการพลาด deadline ได้ยินทันที โค้ดที่รันบน audio thread ต้องทำตามกฎที่เข้มงวดข้อหนึ่งซึ่งไม่มีที่ไหนในหนังสือเล่มนี้ต้องการมาก่อน: ห้าม allocate memory ที่นั่น — ห้าม new, ห้าม malloc, ห้าม resize List หรือ std::vector, ห้าม lock mutex ที่ game thread อาจถืออยู่, ห้าม logging, ห้ามเข้าถึงไฟล์ สิ่งเหล่านี้ทำให้ audio thread หยุดชะงักไปชั่วขณะที่คาดเดาไม่ได้ (garbage collector รัน, OS ขอ memory, lock ที่รอ thread อื่นอยู่) — และการหยุดชะงักที่คาดเดาไม่ได้บน thread นี้แหละคือสาเหตุของเสียง click หรือ pop ที่บางทีได้ยินในเกมที่ optimize ไม่ดี buffer ทุกตัวและ state ของ filter ทุกตัวควรถูก allocate ครั้งเดียวล่วงหน้า แล้วแค่อ่านหรือเขียนภายใน callback เท่านั้น
Unity เรียก OnAudioFilterRead บน audio thread สำหรับ AudioSource ใดๆ ที่อยู่บน GameObject เดียวกัน มันส่ง buffer มาให้ตรงๆ — data ถูก interleave เหมือน diagram ในหัวข้อ 5 เป๊ะๆ — เลย filter มันในที่เดิมได้เลย โดยไม่ต้อง allocate อะไรเลย:
using UnityEngine;
public class SimpleLowPassFilter : MonoBehaviour
{
public float cutoffAmount = 0.3f; // 0..1, smaller = more filtering
// Filter memory lives here, as fields -- allocated once, never inside the callback.
private float lowpassStateL = 0.0f;
private float lowpassStateR = 0.0f;
// Unity calls this on the AUDIO THREAD, not the main thread.
// It must finish fast, and it must not allocate.
void OnAudioFilterRead(float[] data, int channels)
{
for (int i = 0; i < data.Length; i += channels)
{
lowpassStateL = lowpassStateL + cutoffAmount * (data[i] - lowpassStateL);
data[i] = lowpassStateL;
if (channels > 1)
{
lowpassStateR = lowpassStateR + cutoffAmount * (data[i + 1] - lowpassStateR);
data[i + 1] = lowpassStateR;
}
}
}
}
นี่คือ one-pole low-pass จากหัวข้อ 6 เป๊ะๆ ใช้กับ buffer จริงของ Unity ทีละ sample — lowpassStateL/lowpassStateR ทำหน้าที่เหมือน y_prev และเพราะมันเป็น field บน object (ไม่ใช่ local variable) มันเลยเก็บค่าไว้ข้ามจาก callback หนึ่งไปอีก callback หนึ่งได้ เหมือนที่ phase ต้องทำในหัวข้อ 2
engine ระดับล่างและ native audio API (สไตล์ที่ library อย่าง miniaudio, PortAudio, WASAPI, หรือ CoreAudio ใช้) จะส่ง raw pointer กับจำนวน frame มาให้ แทนที่จะเป็น managed array แต่รูปทรงของงานเหมือนกันทุกอย่าง:
struct DspState
{
OnePoleLowPass filterL;
OnePoleLowPass filterR;
}; // allocated ONCE, e.g. in an Init() function, long before playback starts
void AudioCallback(float* outputBuffer, int numFrames, int numChannels, void* userData)
{
DspState* dsp = static_cast<DspState*>(userData); // NOT allocated here
for (int frame = 0; frame < numFrames; frame++)
{
int i = frame * numChannels;
outputBuffer[i] = dsp->filterL.Process(outputBuffer[i]);
if (numChannels > 1)
outputBuffer[i + 1] = dsp->filterR.Process(outputBuffer[i + 1]);
}
}
Awake()/Start()) แล้วส่ง pointer หรือ reference ไปยัง memory ที่มีอยู่แล้วให้ callback ถ้า parameter ของ sound effect ต้องเปลี่ยนตอน runtime (cutoff frequency ใหม่, pan value ใหม่) ให้เขียนตัวเลขใหม่ลงใน field ที่มีอยู่แล้ว อย่า allocate object ใหม่มาเก็บมันเด็ดขาดy[n] = y[n-1] + a * (x[n] - y[n-1])dry = [0.9, -0.9, 0.5, 0.95] เล่นที่ dryVolume = 0.8 sfx = [0.6, 0.7, -0.3, 0.5] เล่นที่ sfxVolume = 0.7 ให้ apply ApplyVolume กับแต่ละ buffer ก่อน แล้วรวมมันด้วย MixBuffers (ที่ hard-clip ไปที่ -1.0 .. 1.0) คำนวณ sample สุดท้ายทั้ง 4 ค่า แล้วบอกว่าตัวไหนบ้างที่ clipก่อนอื่น apply volume กับแต่ละ buffer แยกกัน dry * 0.8 = [0.72, -0.72, 0.40, 0.76] sfx * 0.7 = [0.42, 0.49, -0.21, 0.35]
ตอนนี้บวกมันทีละตำแหน่ง: index 0: 0.72 + 0.42 = 1.14 index 1: -0.72 + 0.49 = -0.23 index 2: 0.40 + (-0.21) = 0.19 index 3: 0.76 + 0.35 = 1.11
Clamp แต่ละค่าให้อยู่ใน -1.0 .. 1.0: index 0 (1.14) clip ลงมาเหลือ 1.00 index 1 (-0.23) อยู่ในช่วงอยู่แล้ว คงที่ -0.23 index 2 (0.19) คงที่ 0.19 index 3 (1.11) clip ลงมาเหลือ 1.00
Buffer สุดท้าย: [1.00, -0.23, 0.19, 1.00] index 0 กับ 3 clip
OnePoleLowPass เริ่มด้วย y_prev = 0 และ a = 0.5 เรียก Process(0.8) ติดต่อกัน 4 ครั้งด้วย input เดิมทุกครั้ง เขียนค่าที่คืนมาทั้ง 4 ค่า แล้วอธิบายด้วยคำพูดตัวเองว่าทำไม output ไม่กระโดดไป 0.8 ทันทีตั้งแต่ครั้งแรกใช้ y[n] = y[n-1] + a * (x - y[n-1]) โดย a = 0.5, x = 0.8, เริ่มจาก y_prev = 0:
Call 1: 0 + 0.5 * (0.8 - 0) = 0.4 Call 2: 0.4 + 0.5 * (0.8 - 0.4) = 0.4 + 0.2 = 0.6 Call 3: 0.6 + 0.5 * (0.8 - 0.6) = 0.6 + 0.1 = 0.7 Call 4: 0.7 + 0.5 * (0.8 - 0.7) = 0.7 + 0.05 = 0.75
ลำดับ: 0.4, 0.6, 0.7, 0.75 มันไม่กระโดดไป 0.8 ตรงๆ เพราะ one-pole low-pass filter ปิดช่องว่างที่เหลือแค่บางส่วนเท่านั้น (ในที่นี้คือครึ่งหนึ่ง เพราะ a = 0.5) ในทุกๆ sample — นั่นคือกลไกทั้งหมดที่ทำให้การกระโดดฉับพลันเรียบลง แทนที่จะปล่อยผ่านไปเฉยๆ เมื่อ a < 1 output จะเข้าใกล้ input ที่คงอยู่ต่อเนื่องเรื่อยๆ แต่จะถึงมันพอดีก็ต่อเมื่อเข้าใกล้อนันต์เท่านั้น ไม่มีทางถึงในสเต็ปเดียว
public class BadFilter : MonoBehaviour
{
public float cutoffAmount = 0.3f;
void OnAudioFilterRead(float[] data, int channels)
{
float[] smoothed = new float[data.Length]; // temp buffer
float state = 0.0f;
for (int i = 0; i < data.Length; i++)
{
state = state + cutoffAmount * (data[i] - state);
smoothed[i] = state;
}
for (int i = 0; i < data.Length; i++)
data[i] = smoothed[i];
}
}
มีบั๊กสองอันแยกกันในนี้ ทั้งคู่มาจากกฎเรื่อง audio thread ในหัวข้อ 11 อันแรก: new float[data.Length] allocate array ใหม่ทุกครั้งที่ OnAudioFilterRead รัน — หลายครั้งต่อวินาที ตลอดไป บน audio thread ไม่ช้าก็เร็วมันจะไป trigger garbage collector ซึ่งทำให้ thread นี้หยุดชะงักไปชั่วขณะที่คาดเดาไม่ได้และเกิดเสียง click หรือ pop ที่ได้ยิน มันไม่จำเป็นเลยด้วยซ้ำ — filter เขียนกลับเข้าไปใน data ตรงๆ ได้เลย เหมือนที่ SimpleLowPassFilter ในหัวข้อ 11 ทำ โดยไม่ต้องมี temporary array เลย
อันที่สอง ซึ่งแยบยลกว่า: float state = 0.0f; ถูกประกาศเป็น local variable ภายใน OnAudioFilterRead แปลว่ามันถูกรีเซ็ตเป็น 0.0 ทุกครั้งที่ callback เริ่มใหม่ แทนที่จะเก็บค่าไว้ต่อเนื่องแบบที่ y_prev ทำในหัวข้อ 6 หรือ lowpassStateL/lowpassStateR ทำใน SimpleLowPassFilter filter แทบจะลืมทุกอย่างและเริ่มใหม่จากความเงียบทุกครั้งที่ buffer เริ่มต้น ซึ่งทำลาย smoothing effect ที่ทุกรอยต่อของ buffer และจะฟังดูเหมือนเสียงสะดุดหรือ artifact ที่เกิดซ้ำๆ ถึงแม้จะไม่มี crash หรือ exception เกิดขึ้นเลยก็ตาม
วิธีแก้: ย้าย state ออกไปเป็น private field บน class (เพื่อให้มันคงอยู่ข้ามการเรียกแต่ละครั้ง เหมือน lowpassStateL) และเอา temporary array ออกทั้งหมด เขียนค่าที่ smooth แล้วลงไปใน data[i] ตรงๆ ตอนที่คำนวณเสร็จ — pattern เดียวกับที่ SimpleLowPassFilter ใช้อยู่แล้ว
นั่นคือแก่นของ game audio DSP ทั้งหมด: เสียงในรูปแบบ buffer ของ sample ที่ sample rate คงที่ volume คือการคูณ mixing คือการบวก (ระวังเรื่อง clipping) แยก signal ข้ามแชนแนลด้วย gain กับ pan ปั้น frequency content ด้วย one-pole filter จำอดีตด้วย delay line เพื่อสร้าง echo คิดถึง signal ในแบบ frequency domain แทนที่จะเป็น time domain ปั้นความดังตามเวลาด้วย envelope และสุดท้าย กฎห้าม allocate ที่เข้มงวดของ audio thread ที่โค้ดทั้งหมดนี้ต้องรันอยู่จริงๆ ทุกส่วนของมันย่อลงเหลือแค่การกระทำไม่กี่แบบเหมือนกัน — คูณ บวก จำค่าก่อนหน้าหนึ่งค่า — ทำซ้ำไปทั่ว buffer วนไปเรื่อยๆ หลายครั้งต่อวินาที