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

การตั้งเวลา Web Audio อย่างแม่นยำ

คริส วิลสัน
คริส วิลสัน

เกริ่นนำ

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

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

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

ช่วงเวลาที่ดีที่สุด - นาฬิกา Web Audio

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

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

ตัวอย่างเช่น ลองดูข้อมูลโค้ดสั้นๆ นี้จาก Web Audio Intro ซึ่งจะตั้งค่าแท่งกราฟที่มีรูปแบบโน้ตตัวที่ 8 เป็นโน้ตตัวที่ 8 ดังนี้

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 แท่งจะดังขึ้น ก็ถือว่าโชคไม่ดีเลย (ฉันเคยเห็นนักพัฒนาซอฟต์แวร์ทำหลายอย่าง เช่น แทรกโหนด GET ระหว่าง AudioBufferSourceNodes ที่ตั้งไว้ล่วงหน้ากับเอาต์พุต เพื่อให้พวกเขาปิดเสียงของตัวเองได้)

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

เวลาที่แย่ที่สุด - นาฬิกา JavaScript

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

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

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

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

การใช้ JavaScript setTimeout() ในแอปเสียง

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

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

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

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

การได้รับช่วงเวลาที่มั่นคงจากหินโดยมองไปข้างหน้า

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

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

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

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

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

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

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

กำหนดเวลาที่มีการทับซ้อนกันแบบยาว
การกำหนดเวลาที่มีการซ้อนทับกันแบบยาว

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

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

setTimeout() ที่มี Lookahead แบบยาวและช่วงเวลายาว
setTimeout() ที่มี Lookahead แบบยาวและช่วงเวลาแบบยาว

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

ในทางปฏิบัติ คุณจะต้องปรับช่วงเวลากำหนดเวลาและ Lookahead เพื่อดูว่าเกิดปัญหาจากเลย์เอาต์ การรวบรวมขยะ และสิ่งอื่นๆ ที่เกิดขึ้นในเทรดการดำเนินการ JavaScript หลักอย่างไร ตลอดจนปรับรายละเอียดของการควบคุมจังหวะความเร็ว เป็นต้น หากคุณมีเลย์เอาต์ที่ซับซ้อนมากซึ่งเกิดขึ้นบ่อย เช่น คุณอาจต้องทำให้ Lookahead ใหญ่ขึ้น ประเด็นหลักคือเราต้องการให้จำนวน "กำหนดเวลาล่วงหน้า" ที่เราทำอยู่มีขนาดใหญ่พอที่จะหลีกเลี่ยงความล่าช้าได้ แต่ต้องไม่มากจนทำให้เกิดความล่าช้าอย่างเห็นได้ชัดเมื่อปรับเปลี่ยนการควบคุมจังหวะ แม้แต่กรณีข้างต้นก็มีการทับซ้อนกันน้อยมาก ดังนั้น ระบบจึงไม่ทนทานต่อการใช้งานเครื่องที่ช้าซึ่งมีเว็บแอปพลิเคชันที่ซับซ้อน จุดเริ่มต้นที่ดีคือช่วงเวลา " Lookahead" ที่ 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 ที่สนุกมาก และตัวอย่างเสียงที่เจาะลึกยิ่งขึ้น เช่น การสาธิตเอฟเฟกต์แบบละเอียด

ระบบจับเวลาอื่น

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

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

เราติดตามบีทที่อยู่ในคิวของเครื่องจัดตารางเวลา ดังนี้

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 เลยจริงๆ เนื่องจากเราใช้นาฬิกาของระบบเสียงในการพิจารณาเวลาที่เราอยู่

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

บทสรุป

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