เรื่องเล่าของนาฬิกา 2 เรือน

การตั้งเวลาเสียงบนเว็บอย่างแม่นยำ

Chris Wilson
Chris Wilson

บทนำ

หนึ่งในความท้าทายที่ใหญ่ที่สุดในการสร้างซอฟต์แวร์เสียงและเพลงที่ยอดเยี่ยมโดยใช้แพลตฟอร์มเว็บคือการจัดการเวลา ไม่เหมือน "เวลาที่ใช้ในการเขียนโค้ด" แต่เหมือนเวลาของนาฬิกา หัวข้อหนึ่งที่คนเข้าใจกันน้อยที่สุดเกี่ยวกับ Web Audio ก็คือวิธีการทำงานอย่างเหมาะสมกับนาฬิกาเสียง ออบเจ็กต์ AudioContext ของ Web Audio มีพร็อพเพอร์ตี้ currentTime ที่แสดงนาฬิกาเสียงนี้

โดยเฉพาะอย่างยิ่งสำหรับแอปพลิเคชันเพลงที่ใช้เสียงบนเว็บ ไม่ใช่เพียงการเขียนลำดับและสังเคราะห์เสียงเท่านั้น แต่ยังรวมถึงการใช้จังหวะของกิจกรรมเสียงด้วย เช่น เครื่องกลอง เกม และแอปพลิเคชันอื่นๆ สิ่งสำคัญคือต้องมีจังหวะเวลาของกิจกรรมเสียงที่สม่ำเสมอและแม่นยำ ไม่ใช่แค่เริ่มต้นและหยุดเสียงเท่านั้น แต่ยังตั้งเวลาการเปลี่ยนแปลงเสียง (เช่น การเปลี่ยนความถี่หรือระดับเสียง) บางครั้งก็ควรให้มีการสุ่มเวลาของเหตุการณ์เล็กน้อย เช่น ในตัวอย่างเสียงปืนกลในการพัฒนาเสียงเกมด้วย Web Audio API แต่โดยปกติแล้ว เราต้องการจังหวะที่แม่นยำและสอดคล้องกันสำหรับโน้ตเพลง

เราได้แสดงวิธีตั้งเวลาโน้ตโดยใช้พารามิเตอร์เวลาของเมธอด noteOn และ noteOff (เปลี่ยนชื่อเป็น start และ stop แล้ว) ของ Web Audio ในเริ่มต้นใช้งาน Web Audio และการพัฒนาเสียงเกมด้วย Web Audio API แล้ว แต่ยังไม่ได้เจาะลึกสถานการณ์ที่ซับซ้อนมากขึ้น เช่น การเล่นลําดับเพลงหรือจังหวะที่ยาว ก่อนอื่นเราจำเป็นต้องมีพื้นหลังเล็กน้อยเกี่ยวกับนาฬิกา

The Best of Times - the Web Audio Clock

Web Audio API แสดงการเข้าถึงนาฬิกาฮาร์ดแวร์ของระบบย่อยเสียง ระบบจะแสดงนาฬิกานี้บนออบเจ็กต์ AudioContext ผ่านพร็อพเพอร์ตี้ .currentTime เป็นจำนวนวินาทีที่เป็นทศนิยมนับตั้งแต่สร้าง AudioContext ซึ่งช่วยให้นาฬิกานี้ (ต่อไปนี้เรียกว่า "นาฬิกาเสียง") มีความแม่นยำสูงมาก ออกแบบมาเพื่อระบุการจัดตำแหน่งในระดับตัวอย่างเสียงแต่ละรายการได้ แม้จะมีอัตราตัวอย่างสูงก็ตาม เนื่องจากทศนิยม 15 หลักในฟังก์ชัน "ดับเบิล" จะมีค่าทศนิยมประมาณ 15 หลัก แม้ว่านาฬิกาเสียงจะทำงานมาหลายวันแล้ว ก็ควรยังมีบิตเหลืออยู่จำนวนมากเพื่อชี้ไปยังตัวอย่างที่เฉพาะเจาะจงแม้ในอัตราการสุ่มตัวอย่างที่สูง

นาฬิกาเสียงจะใช้สำหรับการกำหนดเวลาพารามิเตอร์และเหตุการณ์เสียงตลอดทั้ง Web Audio API โดยเฉพาะ start() และ stop() รวมถึงเมธอด set*ValueAtTime() ใน AudioParams ด้วย ซึ่งช่วยให้เราตั้งค่าเหตุการณ์เสียงที่ตรงเวลาได้แม่นยำมากล่วงหน้า ในความเป็นจริงแล้ว คุณอาจอยากตั้งค่าทุกอย่างใน Web Audio ให้เป็นเวลาเริ่ม/หยุด แต่ในความเป็นจริงแล้ว ก็ยังมีปัญหาอยู่

ตัวอย่างเช่น มาดูข้อมูลโค้ดที่ตัดทอนมาจากบทนำเกี่ยวกับเสียงบนเว็บ ซึ่งจะตั้งค่ารูปแบบไฮแฮทโน้ต 8 พยางค์ 2 แท่ง

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

รหัสนี้ใช้ได้ อย่างไรก็ตาม หากต้องการเปลี่ยนจังหวะในช่วงกลางของ 2 บาร์นั้น หรือหยุดเล่นก่อนที่ 2 บาร์จะจบลง คุณก็จะต้องผิดหวัง (เราเคยเห็นนักพัฒนาซอฟต์แวร์ทำสิ่งต่างๆ เช่น แทรกโหนดการเพิ่มระหว่าง AudioBufferSourceNodes ที่กําหนดเวลาไว้ล่วงหน้ากับเอาต์พุตเพื่อปิดเสียงของตัวเอง)

กล่าวโดยย่อคือ คุณไม่ควรส่งเหตุการณ์เสียงจำนวนมากไปยังคิว เนื่องจากจะต้องมีความยืดหยุ่นในการเปลี่ยนจังหวะหรือพารามิเตอร์ เช่น ความถี่หรืออัตราขยาย (หรือหยุดกําหนดเวลาไปเลย) หรือกล่าวให้ถูกต้องคือ คุณไม่ควรมองไปข้างหน้านานเกินไป เนื่องจากอาจต้องการเปลี่ยนการกําหนดเวลานั้นโดยสิ้นเชิง

The Worst of Times - the JavaScript Clock

นอกจากนี้ เรายังมีนาฬิกา JavaScript ที่เรารักและเกลียดมาก ซึ่งแสดงโดย Date.now() และ setTimeout() ข้อดีของนาฬิกา JavaScript คือมีเมธอด call-me-back-later window.setTimeout() และ window.setInterval() ที่มีประโยชน์มาก 2 รายการ ซึ่งช่วยให้ระบบเรียกโค้ดของเรากลับมาในเวลาที่เจาะจงได้

ข้อเสียของนาฬิกา JavaScript คือความแม่นยำไม่มากนัก สำหรับผู้เริ่มต้น Date.now() จะแสดงผลค่าเป็นมิลลิวินาที ซึ่งเป็นจำนวนเต็มของมิลลิวินาที ดังนั้นความแม่นยำที่ดีที่สุดที่คุณคาดหวังได้คือ 1 มิลลิวินาที ซึ่งถือว่าไม่ได้แย่อย่างเหลือเชื่อในบางบริบททางดนตรี ถ้าโน้ตของคุณเริ่มที่เวลาไม่ถึงมิลลิวินาทีก่อนหรือช้าไป คุณอาจไม่ได้สังเกตถึงแม้ว่าอัตราฮาร์ดแวร์เสียงที่ค่อนข้างต่ำที่ 44.1kHz ก็ยังช้าเกินไปที่จะใช้เป็นนาฬิกาตั้งเวลาเสียงประมาณ 44.1 เท่า โปรดทราบว่าการใส่ตัวอย่างเพลงอาจทำให้เสียงขัดข้องได้ ดังนั้นหากเราต่อตัวอย่างเพลงเข้าด้วยกัน เราอาจต้องจัดเรียงตัวอย่างเพลงให้เป็นไปตามลำดับอย่างแม่นยำ

ข้อกําหนดเวลาความละเอียดสูงที่กำลังได้รับความนิยมในปัจจุบันช่วยให้เราทราบเวลาปัจจุบันที่แม่นยํามากขึ้นผ่าน window.performance.now(); และยังมีการใช้งาน (แม้ว่าจะมีคำนำหน้า) ในเบราว์เซอร์ปัจจุบันหลายรุ่น ซึ่งอาจช่วยได้ในกรณีบางกรณี แต่ไม่ได้เกี่ยวข้องกับส่วนที่แย่ที่สุดของ JavaScript Timing API จริงๆ

สิ่งที่แย่ที่สุดเกี่ยวกับ API การกำหนดเวลาของ JavaScript คือแม้ว่าความแม่นยำระดับมิลลิวินาทีของ Date.now() จะฟังดูไม่แย่นัก แต่การเรียกกลับจริงของเหตุการณ์ตัวจับเวลาใน JavaScript (ผ่าน window.setTimeout() หรือ window.setInterval) อาจเบี่ยงเบนได้ง่ายถึงหลายสิบมิลลิวินาทีหรือมากกว่านั้นตามเลย์เอาต์ การแสดงผล การเก็บขยะ และ XMLHTTPRequest รวมถึงการเรียกกลับอื่นๆ กล่าวโดยย่อคือตามจำนวนสิ่งต่างๆ ที่เกิดขึ้นในเธรดการดําเนินการหลัก จำได้ไหมที่เราพูดถึง "เหตุการณ์เสียง" ที่เราสามารถกําหนดเวลาได้โดยใช้ Web Audio API ตัวแฮนเดิลทั้งหมดได้รับการประมวลผลในเทรดแยกต่างหาก ดังนั้นแม้ว่าเทรดหลักจะหยุดทำงานชั่วคราวขณะทำเลย์เอาต์ที่ซับซ้อนหรืองานอื่นๆ ที่ใช้เวลานาน เสียงก็จะยังคงเกิดขึ้น ณ เวลาที่ได้รับแจ้ง อันที่จริงแม้ว่าคุณจะหยุดที่เบรกพอยท์ในโปรแกรมแก้ไขข้อบกพร่อง เทรดเสียงจะยังเล่นเหตุการณ์ที่กำหนดเวลาไว้ต่อไป

การใช้ JavaScript setระยะหมดเวลา() ในแอปเสียง

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

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

คำถามที่พบบ่อยที่สุดอย่างหนึ่งที่เราได้ยินคือ "เหตุใดฉันจึงไม่ได้รับคอลแบ็กจากเหตุการณ์เสียง" แม้ว่าคอลแบ็กประเภทนี้อาจมีประโยชน์ แต่ก็ไม่ได้ช่วยแก้ปัญหาเฉพาะหน้า สิ่งสำคัญคือต้องเข้าใจว่าเหตุการณ์เหล่านั้นจะเริ่มต้นในเธรด JavaScript หลัก ดังนั้นจึงอาจเกิดความล่าช้าเดียวกันกับ setTimeout กล่าวคือ อาจเกิดความล่าช้าเป็นจำนวนมิลลิวินาทีที่ไม่รู้จักและผันแปรจากเวลาที่กําหนดไว้ก่อนที่จะประมวลผลจริง

เราจะช่วยอะไรได้บ้าง วิธีที่ดีที่สุดในการจัดการเวลาคือการตั้งค่าการทำงานร่วมกันระหว่างตัวจับเวลา JavaScript (setTimeout(), setInterval() หรือ requestAnimationFrame() - ดูข้อมูลเพิ่มเติมในภายหลัง) กับการจัดตารางเวลาของฮาร์ดแวร์เสียง

การกำหนดเวลาอย่างแม่นยำด้วยการมองไปข้างหน้า

กลับไปที่การสาธิตเครื่องจับจังหวะกัน จริงๆ แล้วเราเขียนการสาธิตเครื่องจับจังหวะเวอร์ชันแรกนี้อย่างถูกต้องเพื่อสาธิตเทคนิคการจัดตารางเวลาแบบร่วมมือกัน (โค้ดนี้ยังอยู่ใน GitHub ด้วย) การสาธิตนี้จะเล่นเสียงบี๊บ (สร้างโดยออสซิลเลเตอร์) ด้วยความแม่นยำสูงในทุกโน้ต 16 เท่า 8 เท่า หรือ 4 เท่า โดยเปลี่ยนระดับเสียงตามจังหวะ นอกจากนี้ คุณยังเปลี่ยนจังหวะและระยะห่างของโน้ตขณะเล่นหรือหยุดเล่นได้ทุกเมื่อ ซึ่งเป็นฟีเจอร์หลักสำหรับตัวจัดลำดับจังหวะในชีวิตจริง การเพิ่มโค้ดเพื่อเปลี่ยนเสียงที่เครื่องจับจังหวะนี้ใช้ขณะเล่นก็เป็นเรื่องง่ายเช่นกัน

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

setระยะหมดเวลา() และการโต้ตอบของเหตุการณ์เสียง
การโต้ตอบกับเหตุการณ์เสียงและ setTimeout()

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

อันที่จริง เราไม่ได้ต้องการมองไปข้างหน้าตามช่วงเวลาที่แน่นอนระหว่างการเรียกใช้ setTimeout() แต่เราต้องการการกําหนดเวลาบางส่วนที่ทับซ้อนกันระหว่างการเรียกใช้ตัวจับเวลานี้กับการเรียกใช้ครั้งถัดไป เพื่อรองรับลักษณะการทํางานที่เลวร้ายที่สุดของเธรดหลัก ซึ่งก็คือกรณีที่เลวร้ายที่สุดของการรวบรวมขยะ เลย์เอาต์ การแสดงผล หรือโค้ดอื่นๆ ที่เกิดขึ้นในเธรดหลักซึ่งทำให้การเรียกใช้ตัวจับเวลาครั้งถัดไปล่าช้า นอกจากนี้ เราต้องคำนึงถึงเวลาในการจัดตารางเวลาบล็อกเสียง กล่าวคือ ปริมาณเสียงที่ระบบปฏิบัติการเก็บไว้ในบัฟเฟอร์การประมวลผล ซึ่งจะแตกต่างกันไปในแต่ละระบบปฏิบัติการและฮาร์ดแวร์ ตั้งแต่ตัวเลขหลักเดียวในระดับต่ำไปจนถึงประมาณ 50 มิลลิวินาที การเรียกใช้ setTimeout() แต่ละรายการที่แสดงด้านบนมีช่วงเวลาสีน้ำเงินซึ่งแสดงช่วงทั้งหมดของเวลาที่ระบบจะพยายามตั้งเวลาเหตุการณ์ เช่น เหตุการณ์เสียงบนเว็บที่ 4 ซึ่งกำหนดเวลาไว้ในแผนภาพด้านบนอาจเล่น "ช้า" หากเรารอเล่นจนกว่าการเรียกใช้ setTimeout ครั้งถัดไปจะเกิดขึ้น หากการเรียกใช้ setTimeout นั้นเกิดขึ้นช้ากว่าเพียงไม่กี่มิลลิวินาที ในชีวิตจริง เสียงรบกวนในช่วงเวลาเหล่านี้อาจรุนแรงกว่านั้น และการซ้อนทับนี้จะยิ่งมีความสำคัญมากขึ้นเมื่อแอปซับซ้อนยิ่งขึ้น

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

แผนภาพลำดับเวลาต่อไปนี้แสดงสิ่งที่โค้ดเดโมของเมโทรโนมทำจริง โดยจะมีช่วงเวลา 25 มิลลิวินาทีสำหรับ setTimeout แต่มีการซ้อนทับที่ยืดหยุ่นกว่ามาก แต่ละการเรียกจะกำหนดเวลาไว้สำหรับ 100 มิลลิวินาทีถัดไป ข้อเสียของการมองไปข้างหน้าระยะยาวนี้คือ การเปลี่ยนแปลงจังหวะเพลง ฯลฯ จะใช้เวลา 1/10 วินาทีจึงจะมีผล อย่างไรก็ตาม เรามีความยืดหยุ่นมากขึ้นเมื่อเกิดการหยุดชะงัก

การจัดกำหนดการที่ทับซ้อนกันเป็นเวลานาน
การกำหนดเวลาที่มีการซ้อนทับกันนาน

จริงๆ แล้ว คุณจะเห็นได้ว่าในตัวอย่างนี้เรามีการขัดจังหวะของ setTimeout ตรงกลาง ซึ่งเราควรมีการเรียกกลับ setTimeout ที่ประมาณ 270 มิลลิวินาที แต่กลับล่าช้าด้วยเหตุผลบางอย่างจนประมาณ 320 มิลลิวินาที ซึ่งช้ากว่าที่ควรจะเป็นประมาณ 50 มิลลิวินาที อย่างไรก็ตาม เวลาในการตอบสนองแบบมองจากด้านบนก็ดำเนินตามเดิมโดยไม่มีปัญหา และเราก็ยังไม่พลาดจังหวะแม้สักจังหวะเดียว แม้ว่าก่อนหน้านี้เราจะเพิ่มจังหวะให้เร็วขึ้นเป็นโน้ตตัวที่ 16 ที่ความเร็ว 240 bpm (เกินจังหวะดรัมแอนด์เบสแบบฮาร์ดคอร์!)

นอกจากนี้ การเรียกใช้ตัวจัดตารางแต่ละครั้งอาจกำหนดเวลาโน้ตหลายรายการได้ มาดูกันว่าจะเกิดอะไรขึ้นหากเราใช้ช่วงเวลาการกําหนดเวลานานขึ้น (มองไปข้างหน้า 250 มิลลิวินาที โดยเว้นระยะห่าง 200 มิลลิวินาที) และเพิ่มจังหวะในช่วงกลาง

setTimeout() ที่มีช่วงมองไปข้างหน้านานและช่วงเวลานาน
setTimeout() ที่มีช่วงมองไปข้างหน้านานและช่วงเวลานาน

กรณีนี้แสดงให้เห็นว่าการเรียกใช้ setTimeout() แต่ละครั้งอาจจบลงด้วยการกําหนดเวลาเหตุการณ์เสียงหลายรายการ อันที่จริงแล้วเครื่องจับจังหวะนี้เป็นแอปพลิเคชันแบบเล่นโน้ตทีละตัว แต่คุณจะเห็นได้ง่ายๆ ว่าวิธีการนี้ทํางานอย่างไรกับเครื่องตีกลอง (ซึ่งมีโน้ตหลายตัวที่เล่นพร้อมกันบ่อยครั้ง) หรือเครื่องจัดเรียง (ซึ่งมักจะมีช่วงเวลาที่ไม่สม่ำเสมอระหว่างโน้ต)

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

โค้ดหลักของกระบวนการกําหนดเวลาอยู่ในฟังก์ชัน scheduler()

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

ฟังก์ชันนี้จะรับเวลาปัจจุบันของฮาร์ดแวร์เสียง แล้วเปรียบเทียบกับเวลาของโน้ตถัดไปในลำดับ โดยส่วนใหญ่* ในสถานการณ์ที่แน่นอนเช่นนี้ ฟังก์ชันนี้จะไม่ทำอะไรเลย (เนื่องจากไม่มี "โน้ต" ของเมโทรโนมรอกำหนดเวลา แต่หากกำหนดเวลาสำเร็จ ก็จะกำหนดเวลาโน้ตนั้นโดยใช้ Web Audio API และไปยังโน้ตถัดไป

ฟังก์ชัน scheduleNote() มีหน้าที่กำหนดเวลาให้เล่น "โน้ต" ถัดไปของ Web Audio ในกรณีนี้ ฉันใช้ออสซิลเลเตอร์เพื่อทำให้เสียงบี๊บเป็นความถี่ต่างๆ คุณสามารถสร้างโหนด AudioBufferSource อย่างง่ายดาย และตั้งค่าบัฟเฟอร์ของโหนดให้เป็นเสียงกลอง หรือเสียงอื่นๆ ตามต้องการ

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

เมื่อตั้งเวลาและเชื่อมต่อตัวสั่นแล้ว โค้ดนี้จะลืมตัวสั่นเหล่านั้นไปเลย ตัวสั่นจะเริ่มต้น หยุด แล้วระบบจะรวบรวมขยะโดยอัตโนมัติ

เมธอด nextNote() มีหน้าที่รับผิดชอบในการไปยังโน้ต 16 เสียงถัดไป ซึ่งก็คือการตั้งค่าตัวแปร nextNoteTime และ current16thNote เป็นโน้ตถัดไป

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

การดำเนินการนี้ค่อนข้างตรงไปตรงมา แต่สิ่งสำคัญที่ควรทราบคือในตัวอย่างการตั้งเวลานี้ เราจะไม่ติดตาม "เวลาตามลำดับ" ซึ่งก็คือเวลานับตั้งแต่เริ่มจับเวลา สิ่งเดียวที่เราต้องทำคือ จดเวลาที่เล่นโน้ตตัวสุดท้าย และคิดหาเวลาที่มีกำหนดการเล่นโน้ตถัดไป วิธีนี้ช่วยให้เราเปลี่ยนจังหวะ (หรือหยุดเล่น) ได้ง่ายมาก

แอปพลิเคชันเสียงอื่นๆ จำนวนมากบนเว็บใช้เทคนิคการจัดตารางเวลานี้ เช่น Web Audio Drum Machine, เกม Acid Defender ที่สนุกมาก และตัวอย่างเสียงเชิงลึกอื่นๆ อีกมากมาย เช่น เดโมเอฟเฟกต์แบบละเอียด

Yet Another Timing System

อย่างที่นักดนตรีทุกคนทราบดีว่าแอปพลิเคชันเสียงทุกแอปต้องใช้ตัวจับเวลามากขึ้น โปรดทราบว่าวิธีที่เหมาะสมในการแสดงภาพคือการใช้ระบบจับเวลาระบบที่ 3

แล้วทำไม ตายจริง ทำไมเราต้องมีระบบเวลาอื่นด้วย การดำเนินการนี้จะซิงค์กับการแสดงภาพ ซึ่งก็คืออัตราการรีเฟรชของกราฟิก ผ่าน requestAnimationFrame API สําหรับการวาดกล่องในตัวอย่างเครื่องจับจังหวะ การใช้ requestAnimationFrame() อาจดูไม่สำคัญมากนัก แต่การใช้ requestAnimationFrame() เพื่อซิงค์กับอัตราการรีเฟรชภาพจะยิ่งสําคัญมากขึ้นเมื่อกราฟิกมีความซับซ้อนมากขึ้น และการใช้งานก็ง่ายดายตั้งแต่ต้นไม่ต่างจากการใช้ setTimeout() เลย เมื่อใช้กราฟิกที่ซับซ้อนมากซึ่งมีการซิงค์ (เช่น การแสดงโน้ตเพลงที่หนาแน่นอย่างแม่นยำขณะเล่นในแพ็กเกจโน้ตเพลง) requestAnimationFrame() จะช่วยให้กราฟิกและเสียงซิงค์กันได้อย่างราบรื่นและแม่นยำที่สุด

เราติดตามจังหวะในคิวในเครื่องมือตั้งเวลาดังนี้

notesInQueue.push( { note: beatNumber, time: time } );

คุณสามารถดูการโต้ตอบกับเวลาปัจจุบันของเมโทรนอมได้ในเมธอดDraw() ซึ่งเรียกว่า (โดยใช้ requestAnimationFrame) เมื่อระบบกราฟิกพร้อมอัปเดต ดังนี้

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

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

แน่นอนว่าเราอาจข้ามการใช้ Callback ของ setTimeout() ไปเลย และใส่ตัวจัดตารางเวลาโน้ตไว้ใน Callback ของ requestAnimationFrame แล้วเราก็กลับมาใช้ตัวจับเวลา 2 ตัวอีกครั้ง ซึ่งก็ทำได้เช่นกัน แต่คุณควรทราบว่า requestAnimationFrame เป็นเพียงตัวสํารองของ setTimeout() ในกรณีนี้ คุณยังคงต้องการการกําหนดเวลาที่มีความแม่นยําของจังหวะของ Web Audio สําหรับโน้ตจริง

บทสรุป

เราหวังว่าบทแนะนำนี้จะมีประโยชน์ต่อการอธิบายนาฬิกา ตัวจับเวลา และวิธีสร้างช่วงเวลาที่เหมาะสมลงในแอปพลิเคชันเสียงบนเว็บ เทคนิคเหล่านี้สามารถนำไปใช้สร้างโปรแกรมเล่นซีเควนซ์ ดรีมแมชชีน และอื่นๆ อีกมากมายได้ง่ายๆ ไว้มาเล่นใหม่นะคะ…