คาบ 8 — เสียงเกม: sfx + tone

ให้เกมของเรา "ส่งเสียง" ได้จริงบนบอร์ด

Peripheral Lab · 3 คน / 1 บอร์ด

คาถาประจำคาบ: "เสียงคือการเปิด-ปิดขาเร็ว ๆ แล้วเลือกรูปคลื่น"

คาบนี้เราเปิดฝาเข้าไปแตะฮาร์ดแวร์ตรง ๆ — ทำเสียงให้เกม Pong มีชีวิต

เป้าหมายของคาบนี้

วันนี้เราจะทำให้เกมของเรา "พูด" ได้ ผ่าน 2 เครื่องมือในโมดูล ui:

  1. ui.sfx(id) — เสียงสำเร็จรูปที่บอร์ดเตรียมไว้ให้ (เริ่มเกม, กินอาหาร, เกมจบ) เราแค่หยิบ id มาเรียก
  2. ui.tone(note, wave, velocity, ms) — แต่งทำนองสั้น ๆ เอง ทีละโน้ต เลือกได้ว่าจะให้น้ำเสียงแบบไหน

ระหว่างทางเราจะเข้าใจว่า บอร์ดสร้างเสียงอย่างไร (PWM + waveform) แล้วเอาความรู้นี้ไปใส่เสียงให้ Pong

ปลายทาง: กดรันแล้วได้ยินเสียงเริ่มเกม ทำนองสั้น ๆ และเข้าใจว่าทำไมเสียง square ถึงฟัง "เป็นเกม"

บอร์ดสร้างเสียงได้อย่างไร — PWM

ลำโพงเล็ก ๆ บนบอร์ดไม่ได้ "เล่นไฟล์เพลง" แต่มันแกว่งไปมาตามที่ขาสัญญาณสั่ง

บอร์ดใช้ PWM (Pulse Width Modulation) = เปิด-ปิดขาเร็ว ๆ เป็นจังหวะ
ยิ่งสลับเปิด-ปิดถี่ (ความถี่สูง) → เสียงยิ่งสูง

ความถี่ต่ำ → เสียงต่ำ ความถี่สูง → เสียงสูง

ลองรู้สึก: สลับเร็วขึ้น → เสียงสูงขึ้น (อนิเมชันเล่นเอง — เข็ม pitch ไต่ขึ้นตามความถี่)

ขาเปิด = แรงดันดันลำโพงออก, ขาปิด = ปล่อยกลับ — สลับเร็วพอหู เราได้ยินเป็น "โน้ต"

บนบอร์ดจริง เสียงสังเคราะห์ผ่าน audio DAC (จึงเล่น SINE / TRIANGLE ได้เนียน ไม่ใช่ square ล้วน) — PWM ข้างบนคือภาพง่าย ๆ ที่ช่วยให้เห็นหลักการ "ดิจิทัล → เสียง"

โน้ต 1 ตัว เดินทางจากโค้ดไปถึงลำโพงอย่างไร

เวลาเราเขียน ui.tone(60, ui.WAVE_SQUARE, ...) หนึ่งบรรทัด เบื้องหลังบอร์ดทำงานเป็นทอด ๆ แบบนี้:

เราคุมแค่ 2 อย่าง คือ โน้ตไหน (note) กับ รูปคลื่นไหน (wave) ที่เหลือ — แปลงเป็นความถี่, หยิบรูปคลื่น, ส่งเข้า DAC — บอร์ดจัดการให้หมด นี่คือเหตุผลที่เล่นเสียงได้โดยไม่ต้องรู้ฮาร์ดแวร์ลึก

เกร็ด: ทำไม embedded สำคัญ — Sega Genesis (1988)

Genesis มี CPU ตัวที่สอง (Z80) ไว้ทำเสียงโดยเฉพาะ ปลด CPU หลัก (68000) ให้ทุ่มกับ gameplay — แบ่งงานให้ชิปที่ถนัด

เครื่องยุค 8-bit หลายรุ่น (Master System, ColecoVision, BBC Micro) ใช้ชิปเสียงตัวเดียวกันคือ SN76489 ในรูป มันสร้างเสียง square wave ได้โดยตรง — นี่แหละต้นกำเนิดน้ำเสียง "เป็นเกม" ที่เราจะลองทำวันนี้

ที่มา: "SN76489" — Evan-Amos, Public domain, Wikimedia Commons

เชื่อมกับวันนี้: บอร์ดเราก็มี audio codec แยกทำเสียงจริง (ไม่ใช่ buzzer) ปล่อยให้ core เกมไม่สะดุด — partitioning แนวคิดเดียวกันกับ Genesis เมื่อ 30 กว่าปีก่อน

PWM ไม่ได้มีแค่เสียง — หรี่ไฟ LED ด้วย

duty cycle ตัวเดียวกับที่ทำเสียง เอามาคุม ความสว่าง LED ได้เลย: gpio.led(n).brightness(0–100)

import gpio, time
led = gpio.led(0)
# ไฟหายใจ (breathing) — สว่างขึ้น/หรี่ลง นุ่ม ๆ
while True:
    for b in range(0, 101, 2):
        led.brightness(b); time.sleep_ms(15)
    for b in range(100, -1, -2):
        led.brightness(b); time.sleep_ms(15)

brightness(0) ดับ · 100 สว่างเต็ม · ระหว่างนั้นหรี่ — เบื้องหลังคือ PWM ~10 kHz ที่ตามองเป็นความสว่างต่อเนื่อง (ต้องใช้เฟิร์มแวร์ Game รุ่นล่าสุดที่เปิด LED PWM)

brightness 0 100 ไฟ "หายใจ" — duty 0→100→0

"รูปคลื่น" (waveform) คืออะไร

โน้ตเดียวกัน (ความถี่เท่ากัน) แต่ รูปคลื่นต่างกัน → น้ำเสียงต่างกัน
นี่คือเหตุผลที่กีตาร์กับเปียโนเล่นโน้ตเดียวกันแล้วเสียงไม่เหมือนกัน

WAVE_SQUARE — สี่เหลี่ยม
เสียงกระด้าง "เป็นเกม" แบบ 8-bit คลาสสิก
WAVE_SINE — โค้งนุ่ม
เสียงกลม นุ่มนวล ฟังสบายหู
SQUARE SINE

โน้ตเดิม เปลี่ยนแค่รูปคลื่น — ดูทรงคลื่น "morph" ไปมา (square ↔ sine) ความถี่คงเดิม:

มี TRIANGLE / SAW ด้วย น้ำเสียงก้ำกึ่งระหว่างสองแบบนี้ คาบนี้เราโฟกัส SQUARE กับ SINE ก่อน

ดูรูปคลื่นทั้ง 4 แบบ "ขยับจริง"

โน้ตเดียวกัน (ความถี่เท่ากัน) เปลี่ยนแค่ wave รูปคลื่นที่ลำโพงวาดก็เปลี่ยนรูปทรง — นี่คือสิ่งที่หูเราตีความเป็น "น้ำเสียง" ที่ต่างกัน:

รูปคลื่น ทรง ความรู้สึก
WAVE_SQUARE สี่เหลี่ยม กระด้าง เป็นเกม 8-bit
WAVE_SINE โค้งนุ่ม กลม นุ่มนวล
WAVE_TRIANGLE สามเหลี่ยม นุ่มแต่มีเหลี่ยม
WAVE_SAW ฟันเลื่อย คม สดใส แหลม

ทั้ง 4 ตัวนี้เรียกผ่าน ui.WAVE_* ได้จริงบนบอร์ด ลองสลับดูว่าเสียงของกลุ่มเราเข้ากับแบบไหน

โน้ตเป็นตัวเลข — เลข MIDI

ui.tone ไม่ต้องจำความถี่เป็นเฮิรตซ์ ใช้ เลข MIDI ที่จำง่ายกว่า:

เลข MIDI โน้ต หมายเหตุ
60 C4 (โดกลาง) จุดอ้างอิงที่จำง่าย
62 D4 (เร) +2 = เต็มเสียง
64 E4 (มี)
65 F4 (ฟา) +1 = ครึ่งเสียง
67 G4 (ซอล)

กติกาที่ต้องจำ:

+1=ครึ่งเสียง+12=สูงขึ้น 1 ออกเทฟ\text{+1} = \text{ครึ่งเสียง} \qquad \text{+12} = \text{สูงขึ้น 1 ออกเทฟ}

อยากได้เสียงเดิมแต่สูงขึ้นทั้งชุด? บวก 12 เข้าไปทุกโน้ตได้เลย

ทำไม "+12 = สูงขึ้น 1 ออกเทฟ"

เลข MIDI เป็นแค่ดัชนี บอร์ดต้องแปลงเป็น ความถี่จริง (Hz) ก่อนถึงเล่นได้ สูตรที่เฟิร์มแวร์ใช้จริงคือ:

f(n)=440×2(n69)/12f(n) = 440 \times 2^{\,(n - 69)/12}

โน้ต 69 คือ A4 = เสียงอ้างอิงมาตรฐาน 440 Hz ทุกครั้งที่บวกโน้ตขึ้น 12 ตัว เลขชี้กำลังเพิ่มขึ้น 1 พอดี:

212/12=21=2ความถี่ "คูณสอง"2^{\,12/12} = 2^{1} = 2 \quad\Rightarrow\quad \text{ความถี่ "คูณสอง"}

และ +1 โน้ตคือคูณด้วย 21/121.05952^{1/12} \approx 1.0595 — นี่คือที่มาของคำว่า "ครึ่งเสียง"

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

พารามิเตอร์ของ ui.tone

ui.tone(note, wave, velocity, ms)
พารามิเตอร์ ความหมาย ช่วงค่า
note เลข MIDI ของโน้ต 60 = โดกลาง
wave รูปคลื่น ui.WAVE_SQUARE / ui.WAVE_SINE
velocity ความดัง 0–127
ms ความยาวเสียง (มิลลิวินาที) เช่น 200

เคล็ดลับ: ui.tone เล่นทีละโน้ต เราต้อง หน่วงเวลาเอง ระหว่างโน้ต ไม่งั้นมันจะเกยกันจนฟังไม่เป็นจังหวะ

ทฤษฎี → โค้ด (bridge):

  • velocity = 100 ที่เห็นใน ui.tone(note, ui.WAVE_SQUARE, 100, NOTE_MS) (s08_sound.py:37,45) คือค่าความดังในสเกล MIDI 0–127 (ตารางข้างบน) — 100 ≈ ดังราว 79% ลองลดเหลือ 40 แล้วฟังว่าเบาลง
  • สูตรแปลงเลข MIDI → ความถี่ f(n)=440·2^((n−69)/12) และการหยิบ wavetable มาวาดเป็นสัญญาณ ไม่มีบรรทัด Python บรรทัดไหนทำ — มันรันใน C engine ของบอร์ด บรรทัด Python ที่ใกล้ที่สุดคือ ui.tone(...) (s08_sound.py:37,45) ที่ "สั่ง" ให้ engine ไปคำนวณให้ นี่คือ abstraction ที่เรายอมรับ: เราคุมแค่ note + wave + velocity + ms ที่เหลือ engine จัดการหมด

ส่วนที่ 1 — เสียงสำเร็จรูป ui.sfx

ui.sfx(id) คือเสียงที่ C engine บนบอร์ดเตรียมไว้ให้แล้ว เราแค่หยิบ id มาเรียก
ไม่ต้องจำความถี่ ไม่ต้องแต่งเอง

import ui
import time

# id พวกนี้นิยามไว้ในโมดูล ui แล้ว เราแค่หยิบมาใช้
print("เล่น sfx: เริ่มเกม")
ui.sfx(ui.SFX_UI_START)
time.sleep_ms(600)

print("เล่น sfx: กินอาหาร (snake)")
ui.sfx(ui.SFX_SNAKE_EAT)
time.sleep_ms(400)

print("เล่น sfx: เกมจบ")
ui.sfx(ui.SFX_GAME_OVER)
time.sleep_ms(900)


รันโค้ดข้างบนใน BENTO IDE → คอนโซลพิมพ์ เล่น sfx: ... ทีละบรรทัด พร้อมกับเสียงดังจากลำโพงบนบอร์ด

ใส่ sleep_ms คั่น เพื่อให้แต่ละเสียงเล่นจบก่อนเริ่มเสียงถัดไป — ไม่งั้นมันจะตัดทับกัน

เชื่อมกับเกมของเรา: ในเกมที่เขียนด้วยโมดูล bentogame เราเรียกเสียงด้วย ชื่อ เช่น game.sfx("eat") / game.sfx("die") — เบื้องหลังมันเรียก ui.sfx(...) ที่เรียนในคาบนี้ให้เอง (เสียงเดียวกัน แค่ห่อด้วยชื่อที่จำง่ายกว่าเลข id) ความเข้าใจคาบนี้จึงใช้ได้กับทุกเกม

SFX id ที่มีให้ใช้

ตัวอย่าง id เสียงสำเร็จรูป (หยิบมาจากโมดูล ui ตรง ๆ):

id ใช้ตอนไหน
ui.SFX_UI_START เริ่มเกม / กดเข้าเมนู
ui.SFX_SNAKE_EAT กินอาหาร / เก็บไอเทม / ทำแต้ม
ui.SFX_GAME_OVER เกมจบ / แพ้

เราใช้ชื่อ id ที่บอร์ดตั้งไว้ ไม่ต้องจำตัวเลข — โค้ดอ่านง่ายและสื่อความหมายชัด
ใน Pong เราจะยืมเสียงเหล่านี้มาใช้ (เช่น SFX_SNAKE_EAT ตอนบอลกระทบไม้)

ส่วนที่ 2 — แต่งทำนองด้วย ui.tone

ทีนี้มาแต่งทำนองเอง ทำนองนี้คือ "โด เร มี ฟา ซอล" (C major ครึ่งแรก)

# ทำนองนี้คือ "โด เร มี ฟา ซอล" (C major scale ครึ่งแรก)
melody = [60, 62, 64, 65, 67]      # เลข MIDI ของแต่ละโน้ต
NOTE_MS = 200                      # โน้ตละ 200 ms

print("เล่นทำนองด้วย WAVE_SQUARE (เสียงเกมคลาสสิก)")
for note in melody:
    ui.tone(note, ui.WAVE_SQUARE, 100, NOTE_MS)
    time.sleep_ms(NOTE_MS + 20)    # +20 ms กันโน้ตเกยกัน ฟังเป็นจังหวะ

เก็บทำนองเป็น list ของเลข MIDI แล้ววน for เล่นทีละโน้ต — เพิ่ม/ลด/สลับโน้ตได้ง่ายมาก

แต่ละค่าใน melody คือ "ระดับเสียง" — เรียงในลิสต์อย่างไร เสียงก็ไต่ขึ้นตามนั้น:

ระดับเสียง (pitch) สูงขึ้น ↑ 60 C4 โด 62 D4 เร +2 64 E4 มี +2 65 F4 ฟา +1 67 G4 ซอล +2 melody[0] melody[4]

โน้ต +1 (65→F4) คือก้าว ครึ่งเสียง จึงไต่ขึ้นน้อยกว่า +2 ที่เป็นเต็มเสียง — ลำดับในลิสต์ = เส้นทางขึ้นของทำนองพอดี

ฟัง SFX จริงในเกม — interactive viewer (เลือกเสียง · ลากซูมรูปคลื่น · spectrogram)

เสียงทั้ง 21 ตัว สังเคราะห์ตรงจากตารางจริงใน firmware (bento_sfx.c) — กดเลือกเสียง → กดเล่น → ลากแถบ ซูม ดูรูปคลื่นราย cycle (เห็นทรง square / triangle / saw ชัด) → เทียบ spectrogram (ความถี่ตามเวลา = ทำนอง/การกวาด + harmonics)

Snake Flappy Pong Shooter UI
ซูม snake_eat · square 659Hz
spectrogram (ความถี่ ↕ · เวลา →):

ลากแถบ ซูม: ขวา = น้อย cycle (เห็นทรงคลื่นชัด เช่น square ของ snake_eat) · ซ้าย = หลาย cycle (เห็น envelope / การไล่โน้ต) · spectrogram โชว์การกวาดความถี่จริง (ยิงเลเซอร์ saw กวาดลง, Pong ชนะ 4 โน้ตขึ้น) · เครื่องมือเต็มแยกหน้า: sound_video_demo

ลองเปลี่ยนรูปคลื่น — ฟังความต่าง

โน้ตเดิมทั้งหมด เปลี่ยนแค่ wave จาก SQUARE เป็น SINE แล้วฟังว่าน้ำเสียงต่างกันแค่ไหน

time.sleep_ms(300)

# ลองเปลี่ยน "รูปคลื่น" แล้วฟังว่าน้ำเสียงต่างกันอย่างไร (โน้ตเดิมทั้งหมด)
print("เล่นทำนองเดิมด้วย WAVE_SINE (เสียงนุ่มขึ้น)")
for note in melody:
    ui.tone(note, ui.WAVE_SINE, 100, NOTE_MS)
    time.sleep_ms(NOTE_MS + 20)

print("จบแล็บเสียง — ลองแก้ melody / wave / NOTE_MS ดูว่าผลลัพธ์เปลี่ยนยังไง")
ui.WAVE_SQUARE

ui.WAVE_SINE

นี่คือหัวใจของแล็บ: เปลี่ยนตัวแปรเดียว (wave) ผลลัพธ์เปลี่ยนทั้งความรู้สึก — นี่แหละ waveform (รูปคลื่นข้างบน morph ตามที่เราสลับ wave)

ออกแบบเสียงของกลุ่มเราเอง

เสียงที่ดี "เล่าเรื่อง" ได้ — โน้ตพุ่งขึ้น = "ได้ของ!" · โน้ตตก = "พลาด / เจ็บ"
ลองทำ helper เล็ก ๆ ที่เล่นทำนองจาก list ของโน้ต แล้วออกแบบเสียงของกลุ่มเอง:

import ui, time

# helper: เล่นโน้ตทีละตัวจาก list (หน่วงเวลาให้เอง)
def play(notes, wave=ui.WAVE_SQUARE, ms=110, gap=20):
    for n in notes:
        ui.tone(n, wave, 110, ms)
        time.sleep_ms(ms + gap)        # ถ้าไม่หน่วง โน้ตจะเกยกัน

coin = [72, 76, 79]      # โน้ตพุ่งขึ้น = เสียง "ได้เหรียญ"
hurt = [60, 55]          # โน้ตตก = เสียง "เจ็บ"
play(coin)               # ฟังเสียงที่กลุ่มเราออกแบบเอง

ลองสลับเลขใน coin / hurt ฟังว่าชอบแบบไหน — นี่คือเสียง "ของกลุ่มเรา" ไม่ใช่เสียงสำเร็จรูป

เสียงสำเร็จรูปครบทุกเกม (อยากได้แบบไหนหยิบเลย)

มีเสียงสำเร็จรูป ครบ 21 เสียง เรียกได้ด้วยชื่อง่าย ๆ game.sfx("ชื่อ") — เป็นชุดเดียวกับที่เกม C บนบอร์ดใช้จริง:

เกม ชื่อเสียง
Snake eat · turn · die
Flappy flap · point · fall
Pong wall · paddle · pong_score · win · lose
Shooter fire · hit · explode · lose_life
UI / ทั่วไป start · select · back · deny · move · gameover
import bentogame as game
game.sfx("explode")      # เสียงระเบิดเดียวกับเกม Shooter ตัวจริง
game.sfx("fall")         # นกตก (Flappy) — เรียกด้วยชื่อได้ทุกตัวแล้ว

ครบทั้ง 21 เสียง เรียกด้วยชื่อได้หมด — ลองหยิบไปใส่เกมของกลุ่มให้เข้ากับจังหวะ (เก็บแต้ม / ชน / แพ้)

วิธีรันบนบอร์ด

โค้ดเต็มอยู่ที่ practise_codes/s08_sound.py (เว้นช่องให้เราเติม)
ลองเติมเองให้สุดก่อน เดี๋ยวเฉลยพร้อมกันในห้องเรียน

ขั้นตอนรัน:

  1. เปิดไฟล์ใน BENTO IDE
  2. กดปุ่ม Program to Device
  3. ฟังเสียงจากลำโพงบนบอร์ด — sfx 3 เสียง แล้วตามด้วยทำนอง 2 รอบ (square / sine)


ปุ่ม Program to Device ใน BENTO IDE — flash โค้ดลงบอร์ดเพื่อให้เสียงดังจริง

สำคัญ: เสียงดังจริง เฉพาะบนบอร์ด เท่านั้น — ถ้ารันบน desktop simulator จะเงียบ (no-op)

เราใช้ Program to Device เสมอ ไม่ใช้ exec(open(...)) — ให้บอร์ดรันไฟล์โดยตรง

เอาเสียงไปใส่ Pong

แนวคิดเดียวกันนี้เอาไปต่อใน Pong ได้ทันที วางเสียงตาม "เหตุการณ์" ในเกม:

เหตุการณ์ใน Pong เสียงที่ใส่
เริ่มเกม / เริ่มรอบใหม่ ui.sfx(ui.SFX_UI_START)
บอลกระทบไม้ตี ui.sfx(ui.SFX_SNAKE_EAT) (เสียง "ป๊อก")
ทำแต้ม / ฝ่ายตรงข้ามพลาด ทำนองสั้นด้วย ui.tone
จบเกม ui.sfx(ui.SFX_GAME_OVER)

Pong จริงบนบอร์ด — แต่ละจุดสีคือ "เหตุการณ์ → เสียง" ที่เราวาง

# ตัวอย่างวางในลูปเกม Pong: บอลชนไม้ตี
if ball_hits_paddle:
    ui.sfx(ui.SFX_SNAKE_EAT)       # เสียงตอบรับว่าตีโดน

วางเสียงตรง "จุดที่เกิดเหตุการณ์" ในลูป — เสียงคือ feedback ที่ทำให้เกมรู้สึกมีชีวิต

กับดักที่เจอบ่อย

อาการ สาเหตุ แก้
ไม่ได้ยินเสียงเลย รันบน simulator ต้อง Program to Device ลงบอร์ดจริง
เสียงตัดทับกัน ลืม sleep_ms คั่น หน่วง NOTE_MS + 20 ระหว่างโน้ต
AttributeError: WAVE_SQUARE สะกดผิด / ลืม ui. ใช้ ui.WAVE_SQUARE
ทำนองเร็ว/ช้าผิด ปรับ NOTE_MS ผิดที่ แก้ค่าเดียวที่ NOTE_MS
โน้ตเพี้ยน ใส่ค่า note ผิด 60 = โดกลาง, +1 = ครึ่งเสียง

ถ้าโปรแกรมค้างให้กด Ctrl-C แล้ว Program to Device ใหม่อีกครั้ง

ทั้งโปรแกรม s08_sound.py · ไหลยังไง (signal → trigger → engine)

โปรแกรมเดินเป็นเส้นตรง: เล่น sfx 3 เสียง → วนทำนอง SQUARE → วนทำนอง SINE → จบ ทุกครั้งที่เรียก ui.tone / ui.sfx หนึ่งบรรทัด งานหนัก (แปลงเลข → รูปคลื่น → DAC) จะ ข้ามเส้นไปทำใน C engine ของบอร์ด แล้วออกลำโพง:

notation: เริ่ม/จบ = เทา · กระบวนการ = ฟ้า · เสียงออก = เขียว · C engine = ส้ม STARTimport ui,time ui.sfx × 3เริ่ม/กิน/จบ :18,22,26 for melodySQUARE :36-38 for melodySINE :44-46 ENDจบแล็บ โน้ตถัดไป โน้ตถัดไป แต่ละโน้ต → engine ui.tone(...)note,wave,vel,ms :37,45 C engine (บนบอร์ด)f(n)=440·2^((n−69)/12) · wavetable · DAC ลำโพงเสียงจริง

เส้นบน = control flow ของโปรแกรม Python (บรรทัดไหนทำก่อน-หลัง) · เส้นล่าง = signal chain ที่ engine ทำให้เบื้องหลังทุกครั้งที่เราสั่งหนึ่งโน้ต — นี่คือเหตุผลที่เราเขียนเสียงได้โดยไม่ต้องแตะ DAC เอง

เชื่อมโยงรากฐาน — เสียงเกมเชื่อมกับวิศวกรรมอะไรบ้าง

แล็บเล็ก ๆ วันนี้แตะ "รากฐาน" ของวิศวกรรมฝังตัวครบทั้ง 4 ด้าน — เสียง 1 โน้ตคือจุดที่ทุกอย่างมาเจอกัน:

ด้าน วันนี้เราใช้มันตรงไหน
Embedded / MCU PWM / Timer สร้างความถี่ · audio DAC แปลงดิจิทัลเป็นแรงดัน · การ partition งานเสียงออกจาก core เกม
Python เก็บทำนองเป็น list ของเลข MIDI · วน for เล่นทีละโน้ต · เขียน function play() รับพารามิเตอร์
Algorithms / DSP แปลงเลข MIDI → ความถี่ด้วยสูตร เลขชี้กำลัง · wavetable synthesis หยิบรูปคลื่นมาวาดเป็นสัญญาณ
Graphics / สัญญาณ มองรูปคลื่นเป็น "กราฟตามเวลา" — แกนเดียวกับที่เราใช้วาดภาพ แค่เปลี่ยนจากพิกเซลเป็นแรงดัน

Can you hear the difference between a sine wave and a square wave? — Audio Masterclass

How Oldschool Sound/Music worked — The 8-Bit Guy

เสียงทำให้เกมรู้สึกมีชีวิต แต่เบื้องหลังคือ PWM, DAC, list, for, และคณิตของความถี่ — ทักษะเดียวกันนี้ไปต่อยอดเป็นสินค้าจริงได้ (เครื่องดนตรีดิจิทัล, อุปกรณ์แจ้งเตือน, ของเล่นอัจฉริยะ)

สรุป + ทำเอง 30%

วันนี้เราได้

  • เข้าใจว่าบอร์ดสร้างเสียงด้วย PWM + waveform
  • ui.sfx(id) — เสียงสำเร็จรูป
  • ui.tone(note, wave, velocity, ms) — แต่งทำนองเอง
  • รู้ว่า SQUARE ฟัง "เป็นเกม" SINE ฟังนุ่ม
  • เอาเสียงไปวางตามเหตุการณ์ใน Pong

ทำเอง 30% (เติมใน practise)

  • เติมทำนองให้ครบ "โด เร มี ฟา ซอล ลา ที โด" (8 โน้ต)
  • ออกแบบเสียง coin + hurt ของกลุ่มเอง ด้วย play() แล้วเอาไปใส่ Pong
  • ลองเล่นทำนองสูงขึ้น 1 ออกเทฟ (+12 ทุกโน้ต)
  • ใส่เสียงครบทุกเหตุการณ์ใน Pong ของกลุ่มเรา

คาบหน้าเราจะเอาทักษะทั้งหมด (วาด · จอย · เสียง) มาประกอบเป็นเกมเต็มในโฟลเดอร์ full_games/

เฉลย s08_sound.py — อ่านให้เข้าใจแล้วพิมพ์เอง

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

ส่วนแรก — เล่นเสียงสำเร็จรูปด้วย id เดียว:

# --- ส่วนที่ 1: เสียงสำเร็จรูป (sfx) เรียกด้วย id ที่บอร์ดเตรียมไว้ให้ ---
# id พวกนี้นิยามไว้ในโมดูล ui แล้ว เราแค่หยิบมาใช้ (ไม่ต้องจำความถี่เอง)
print("เล่น sfx: เริ่มเกม")
ui.sfx(ui.SFX_UI_START)
time.sleep_ms(600)

print("เล่น sfx: กินอาหาร (snake)")
ui.sfx(ui.SFX_SNAKE_EAT)
time.sleep_ms(400)

print("เล่น sfx: เกมจบ")
ui.sfx(ui.SFX_GAME_OVER)
time.sleep_ms(900)
  • ทำไมเรียก ui.sfx(ui.SFX_UI_START) แทนที่จะแต่งเสียงเริ่มเกมเอง? เพราะเสียงพวกนี้ C engine บนบอร์ดสังเคราะห์ไว้ให้แล้ว ตรงกับสไลด์ "ส่วนที่ 1 — เสียงสำเร็จรูป ui.sfx" — เราหยิบ id มาใช้ ไม่ต้องจำความถี่ ไม่ต้องแตะ DAC เอง
  • สังเกต sleep_ms สามค่าไม่เท่ากัน: 600 (:19), 400 (:23), 900 (:27) — แต่ละเสียงยาวไม่เท่ากัน เราหน่วงให้เสียงก่อนหน้า "เล่นจบ" ก่อนค่อยสั่งเสียงถัดไป นี่คือกับดัก "เสียงตัดทับกัน" ที่เราเตือนไว้ในสไลด์กับดัก ถ้าลืมหน่วง sfx จะเกยกันจนฟังไม่รู้เรื่อง
  • ใช้ชื่อ id ที่บอร์ดตั้งไว้ (SFX_UI_START / SFX_SNAKE_EAT / SFX_GAME_OVER) แทนตัวเลข ทำให้โค้ด อ่านแล้วรู้เลยว่าเสียงอะไร — นิสัยตั้งชื่อให้สื่อความหมาย ไม่ฝังเลขดิบ

print ทุกบรรทัดไม่ได้มีไว้สวยงาม มันคือ "คำบรรยาย" ที่คอนโซลพิมพ์คู่กับเสียง เวลาเสียงเงียบผิดคาด เราจะรู้ทันทีว่าค้างอยู่ตรง sfx ตัวไหน

เฉลย · ส่วนที่สอง — แต่งทำนองเองด้วย ui.tone

# --- ส่วนที่ 2: แต่งทำนองเองด้วย tone ---
# ui.tone(note, wave, velocity, ms) — เล่นทีละโน้ต เราหน่วงเวลาเองให้โน้ตไม่ทับกัน
# ทำนองนี้คือ "โด เร มี ฟา ซอล" (C major scale ครึ่งแรก)
melody = [60, 62, 64, 65, 67]      # เลข MIDI ของแต่ละโน้ต
NOTE_MS = 200                      # โน้ตละ 200 ms

print("เล่นทำนองด้วย WAVE_SQUARE (เสียงเกมคลาสสิก)")
for note in melody:
    ui.tone(note, ui.WAVE_SQUARE, 100, NOTE_MS)
    time.sleep_ms(NOTE_MS + 20)    # +20 ms กันโน้ตเกยกัน ฟังเป็นจังหวะ
  • ทำไมเก็บทำนองเป็น list (:32) แล้ววน for (:36) แทนที่จะเขียน ui.tone ห้าบรรทัดเรียงกัน? เพราะพอทำนองอยู่ใน ข้อมูล เราเพิ่ม/ลด/สลับโน้ตได้โดยไม่แตะลูปเลย — แนวคิดเดียวกับ "ขับ pattern ด้วยข้อมูล" ตรงกับตาราง melody = [60,62,64,65,67] ในสไลด์ "แต่งทำนองด้วย ui.tone"
  • เลข 60, 62, 64, 65, 67 คือ เลข MIDI จากสไลด์ "โน้ตเป็นตัวเลข" — จำได้ไหมว่า +2 = เต็มเสียง +1 = ครึ่งเสียง? นั่นคือเหตุผลที่ช่วง 64→65 (มี→ฟา) ไต่ขึ้นน้อยกว่าช่วงอื่น
  • ตั้ง NOTE_MS = 200 ไว้ ที่เดียว (:33) แล้วอ้างซ้ำทั้งความยาวโน้ตและช่วงหน่วง — อยากให้ทำนองเร็ว/ช้า แก้เลขเดียวจบ ไม่ต้องไล่แก้ทุกบรรทัด นี่คือการแยก "ค่า" ออกจาก "โครง"
  • time.sleep_ms(NOTE_MS + 20) (:38) คือหัวใจของจังหวะ: ui.tone เล่นทีละโน้ตแล้วคืนคุมกลับมาทันที ถ้าไม่หน่วง โน้ตถัดไปจะทับโน้ตเดิม +20 คือช่องว่างเล็ก ๆ ให้ได้ยินเป็น "โน้ตแยกกัน"

velocity = 100 ในสเกล MIDI 0–127 คือความดังราว 79% ที่เราคุยกันในสไลด์พารามิเตอร์ — ลองลดเหลือ 40 แล้วฟังว่าเบาลงจริงไหม เปลี่ยนทีละค่าแล้วเชื่อหูตัวเอง

เฉลย · ส่วนที่สาม — เปลี่ยนแค่ wave ผลเปลี่ยนทั้งความรู้สึก

time.sleep_ms(300)

# ลองเปลี่ยน "รูปคลื่น" แล้วฟังว่าน้ำเสียงต่างกันอย่างไร (โน้ตเดิมทั้งหมด)
print("เล่นทำนองเดิมด้วย WAVE_SINE (เสียงนุ่มขึ้น)")
for note in melody:
    ui.tone(note, ui.WAVE_SINE, 100, NOTE_MS)
    time.sleep_ms(NOTE_MS + 20)

print("จบแล็บเสียง — ลองแก้ melody / wave / NOTE_MS ดูว่าผลลัพธ์เปลี่ยนยังไง")
  • ลูปนี้กับลูปในส่วนที่สอง โครงเหมือนกันเป๊ะ ต่างกันตัวเดียว: ui.WAVE_SQUAREui.WAVE_SINE (:45) โน้ตเดิม จังหวะเดิม แต่คนละน้ำเสียง — นี่คือบทเรียนหลักของแล็บที่เราพูดในสไลด์ "ลองเปลี่ยนรูปคลื่น": เปลี่ยนตัวแปรเดียว ผลลัพธ์เปลี่ยนทั้งความรู้สึก
  • ใช้ melody ตัวเดิมซ้ำ (:44) ไม่ต้องประกาศทำนองใหม่ — นี่คือผลพลอยได้ของการเก็บทำนองเป็น list ตั้งแต่แรก อยากเทียบ SQUARE กับ SINE บนทำนองเดียวกัน ก็แค่วนลิสต์เดิมอีกรอบ
  • time.sleep_ms(300) คั่นระหว่างสองรอบ (:40) เว้นช่องให้หูตั้งหลักก่อนฟังน้ำเสียงใหม่ ไม่ให้ SQUARE กับ SINE ต่อกันจนแยกไม่ออก
  • บรรทัด print ปิดท้าย (:48) ชวนให้ไปแก้ melody / wave / NOTE_MS ต่อ — เฉลยไม่ได้ให้จบแค่รันผ่าน แต่ชี้ว่า "สามลูกบิด" ไหนที่น้องหมุนเล่นได้

ทำไมเราถึงยืนยันให้เปลี่ยน "ทีละตัวแปร"? เพราะถ้าเปลี่ยนโน้ต จังหวะ และรูปคลื่นพร้อมกัน พอเสียงเพี้ยนจะไม่รู้ว่าตัวไหนทำให้เพี้ยน การแยกตัวแปรคือวิธีคิดแบบวิศวกร ไม่ใช่แค่เรื่องเสียง

เฉลย · ทั้งชุดไต่ระดับกันยังไง (ไม่ใช่วางมั่ว)

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

ชั้น โค้ด (บรรทัด) สิ่งใหม่ที่คุมได้ ของเดิมที่เอากลับมาใช้
1 sfx สำเร็จรูป ui.sfx(id) :18,22,26 เรียกเสียงทั้งก้อนด้วย id เดียว sleep_ms คั่นกันเสียงทับ
2 แต่งทำนอง ui.tone + melody + for :32,36-38 คุม note เอง · list ขับ pattern for + sleep (คาบ 1)
3 เปลี่ยนน้ำเสียง wave=ui.WAVE_SINE :45 เปลี่ยนตัวแปรเดียว น้ำเสียงเปลี่ยนหมด melody เดิม · ลูปเดิม
4 เรียกด้วยชื่อ game.sfx("eat") :51 ห่อ id ด้วยชื่ออ่านง่าย ครบ 21 เสียง ui.sfx เดิม (แค่ห่อชื่อ)
# --- เมนูเสียงสำเร็จรูปทั้งหมด 21 เสียง (หยิบไปใส่เกมได้เลย) ---
# วิธีง่ายสุด: import bentogame as game  แล้ว  game.sfx("ชื่อ")
#   Snake : "eat"  "turn"  "die"
#   Flappy: "flap"  "point"  "fall"
#   Pong  : "wall"  "paddle"  "pong_score"  "win"  "lose"
#   Shoot : "fire"  "hit"  "explode"  "lose_life"
#   UI    : "start"  "select"  "back"  "deny"  "move"  "gameover"
  • ชั้น 1→2 คือการ "เปิดฝา": จากเสียงที่บอร์ดทำให้ ไปเป็นเสียงที่เราประกอบเองจากโน้ต ชั้น 2→3 สอนว่าเปลี่ยน ตัวแปรเดียว ก็ได้ผลใหม่ ส่วนชั้น 4 (game.sfx("eat") :51) วนกลับมาหา id เดิม แค่ห่อด้วยชื่อที่จำง่ายกว่าเลข — เหมาะเวลาไปใส่เกมจริง
  • จับหลักนี้ไว้: เราเริ่มจากของสำเร็จรูปที่รันได้ทันที แล้วค่อยเปิดฝาคุมรายละเอียดทีละชั้น ไม่กระโดดไปคุมทุกอย่างพร้อมกัน — เกมทั้งเกมก็ประกอบขึ้นแบบนี้

ถ้ามองสี่ชั้นนี้ออกว่า "มันคือระดับการควบคุมที่ค่อย ๆ เพิ่ม" น้องจะเลือกได้เองว่างานไหนหยิบ ui.sfx พอ งานไหนต้อง ui.tone คุมเอง — นั่นคือดุลยพินิจของวิศวกร ไม่ใช่ท่องจำ API

เชื่อมจุดให้เห็นภาพ — เสียงโน้ตนี้มาจากไหน แล้วจะพาเราไปถึงไหน

หยุดคิดสักครู่ก่อนปิดคาบ ไม่ใช่เรื่องไวยากรณ์ แต่เรื่อง "มองให้ทะลุ" ว่าเสียงหนึ่งโน้ตที่เราเพิ่งทำ จริง ๆ แล้วมันคือชิ้นส่วนของอะไร

ที่มา — เสียงนี้ยืนอยู่บนอะไร ย้อนกลับไปสไลด์ "บอร์ดสร้างเสียงได้อย่างไร — PWM" กับ "PWM ไม่ได้มีแค่เสียง — หรี่ไฟ LED ด้วย" เราเห็นว่าเสียงเกิดจากการ เปิด-ปิดขาเร็ว ๆ แล้วเลือกรูปคลื่น และ duty cycle ตัวเดียวกันนั้นเอาไปหรี่ไฟ LED ได้ ทีนี้ลองถามตัวเอง: ไฟกะพริบช้า ๆ ที่เราทำในคาบแรก กับเสียง square wave วันนี้ ต่างกันแค่ไหน? คำตอบคือ แนวคิดเดียวกัน ต่างแค่ความถี่ — กะพริบช้าพอตาเห็นเป็นไฟ, สลับเร็วพอหูได้ยินเป็นโน้ต นี่คือรากที่ทำให้เสียงกับไฟเป็นเรื่องเดียวกัน

ที่ไป — เสียงนี้จะโตเป็นอะไร ที่สไลด์ "เอาเสียงไปใส่ Pong" เราวางเสียงตาม "เหตุการณ์" ในเกม (บอลชนไม้ → ป๊อก, จบเกม → game over) สังเกตให้ดี: การเก็บทำนองเป็น list แล้ววน for เล่นทีละตัว คือโครงเดียวกับการ ไล่เหตุการณ์ในเกมทีละอัน เสียงจึงไม่ใช่ของแต่งเติมท้ายสุด แต่คือ feedback ที่ผูกกับ game loop คาบหน้าที่เรารวม วาด+จอย+เสียง เป็นเกมเต็มใน full_games/ เสียงจะไปนั่งอยู่ในลูปนั้นพอดี

ลองตอบคำถามพวกนี้ในใจ นี่แหละคือการเชื่อมจุดด้วยตัวเอง:

  • จำ duty cycle กับ PWM หรี่ไฟ LED ได้ไหม ถ้าเสียง square กับไฟกะพริบคือ PWM เหมือนกัน แล้วอะไรกันแน่ที่ทำให้อันหนึ่ง "เห็น" อีกอันหนึ่ง "ได้ยิน"? (ใบ้: อยู่ที่ความถี่ล้วน ๆ)
  • ถ้าวันนี้เราเล่นทำนองจาก list ของเลขได้ พรุ่งนี้เราจะไล่ เหตุการณ์ในเกม จาก list แล้วยิงเสียงทีละอันได้ไหม? (ใบ้: for note in melody ↔ for event in game)
  • สังเกตไหมว่า ui.tone / ui.sfx มันคือเรื่องเดียวกับ C engine handoff ในสไลด์ signal chain — เราสั่งหนึ่งบรรทัด งานหนัก (แปลงความถี่ · wavetable · DAC) ข้ามเส้นไปทำในบอร์ด?
ที่มา PWM + duty cycle (ไฟกะพริบ คาบ 1) วันนี้ โน้ต = PWM เร็ว + รูปคลื่น (sfx + tone) ที่ไป เสียงผูกกับ game loop (เกมเต็ม full_games/)

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

ใช้จริงที่ไหน — PWM tone · wavetable · sfx event

เทคนิควันนี้ไม่ได้อยู่แค่ในแล็บ มันคือสิ่งที่อุปกรณ์จริงรอบตัวเราใช้กันทุกวัน:

เกม 8-bit / chiptune · square wave ทำนองใน list + รูปคลื่นสี่เหลี่ยม = น้ำเสียง SN76489 เสียงเตือน · PWM ขับ buzzer ตี๊ด ตี๊ด PWM เลือกความถี่ = เลือกระดับเสียงเตือน เสียงตอบรับ UI · sfx ต่อเหตุการณ์ กดปุ่ม ui.sfx(id) เสียงสั้น ATM · POS · เครื่องมือแพทย์ — เสียงยืนยัน "รับคำสั่งแล้ว" เครื่องดนตรีดิจิทัล · MIDI + wavetable MIDI 60 wavetable f(n)=440·2^((n−69)/12) → หยิบรูปคลื่นมาวาดสัญญาณ
  • เครื่องเกม 8-bit / เพลง chiptune — ชิปอย่าง SN76489 สร้าง square wave ตรง ๆ คือที่มาของน้ำเสียง "เป็นเกม" ที่เราได้ยินจาก ui.WAVE_SQUARE: ทำนองใน list + รูปคลื่นสี่เหลี่ยม
  • เสียงเตือนเครื่องใช้ไฟฟ้า — ไมโครเวฟ "ตี๊ด", เครื่องตรวจควัน, นาฬิกาปลุก ใช้ PWM ขับ buzzer ให้ได้ความถี่ที่ต้องการ — เปิด-ปิดขาเร็ว ๆ เลือกความถี่ = เลือกระดับเสียง
  • เสียงตอบสนอง UI — ปุ่ม ATM, เครื่องคิดเงิน POS, เครื่องมือแพทย์ ยิงเสียงสั้น ๆ ตอนกดปุ่ม/ทำรายการสำเร็จ/เตือน error คือ ui.sfx(id) แบบเดียวกับที่เราวางเสียงตาม "เหตุการณ์" ใน Pong — feedback ยืนยัน "รับคำสั่งแล้ว"
  • เครื่องดนตรีดิจิทัล / synthesizer — คีย์บอร์ดไฟฟ้าและ sound module ใช้ เลข MIDI (60 = โดกลาง) กับ wavetable หยิบรูปคลื่นมาวาดสัญญาณ ตามสูตร f(n)=440·2^((n−69)/12) — รากฐานเดียวกับที่เราแตะวันนี้

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

ต่อยอด — คิดต่อเอง

ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาโปรเจกต์จริงได้:

แบบบล็อก — ui.tone() แล้ว time.sleep_ms() ui.tone time.sleep_ms(200) — CPU หยุดนิ่ง ui.tone กดปุ่ม/วาดเฟรมช่วงนี้ → เกมค้าง ทำอะไรไม่ได้เลย เปลี่ยนวิธีคิด แบบไม่บล็อก — จำเวลาที่ผ่านไป (BGM ไม่หยุดเกม) ทุกวนรอบ: อ่านปุ่ม + วาดเกม + เช็คเวลา — ไม่หยุดเลย เล่นโน้ตถัดไปเมื่อครบ 200 ms ปุ่มกดตอนไหนก็รับได้ — เพลงกับเกมเดินพร้อมกัน
  • เพลงพื้นหลังที่ไม่หยุดเกม — ตอนนี้ time.sleep_ms หยุด ทุกอย่าง ระหว่างโน้ต ถ้าอยากมี BGM เล่นวนพร้อมเกมที่ยังต้องรับปุ่ม จะออกแบบยังไงไม่ให้บล็อกเกม (ใบ้: "จำเวลาที่ผ่านไป" แทนการ sleep — เล่นโน้ตถัดไปเมื่อถึงเวลา)
  • เสียงที่เล่าสถานะเกม — ถ้าผูก note หรือ velocity กับสถานะ เช่น ยิ่งใกล้แพ้เสียงยิ่งสูง/ถี่ขึ้น จะ map สถานะเป็นพารามิเตอร์เสียงยังไงให้ผู้เล่น "รู้สึก" ตึงเครียดเองโดยไม่ต้องมองคะแนน
  • ธีมเสียงของทีมเอง — ถ้าออกแบบเสียงครบทุกเหตุการณ์ (เก็บของ / ชน / ชนะ / แพ้) ให้เป็นชุดเดียวกัน จะเลือก wave กับชุดโน้ต (scale) ยังไงให้ทุกเสียงเข้าพวกกัน ไม่ใช่ต่างคนต่างมา
  • บริดจ์ไปคาบหน้า — คาบหน้าเรารวม วาด + จอย + เสียง เป็นเกมเต็มใน full_games/ ลองคิดล่วงหน้า: จะวาง ui.sfx / ui.tone ตรงไหนของ game loop ให้เสียงตรงกับภาพและปุ่ม โดยเสียงยาว ๆ ไม่หน่วงจนเฟรมสะดุด — โจทย์แรกที่เกมเต็มต้องแก้

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

fit-css

← Roadmap (TOC)