Hướng dẫn về hình ảnh âm thanh với JavaScript và GSAP (Phần 1)

theanh

Administrator
Nhân viên
Một thời gian trước, tôi được một người bạn Kent C. Dodds tiếp cận để giúp xây dựng lại trang web của anh ấy. Bên cạnh việc thêm một chút kỳ quặc ở đây và ở đó, có một phần, đặc biệt, Kent muốn giúp đỡ. Và đó là hình ảnh hóa âm thanh. Một tính năng của trang web của Kent là có thể "ghi lại cuộc gọi" và sau đó anh ấy sẽ trả lời thông qua một tập podcast.

Vì vậy, hôm nay, chúng ta sẽ xem cách bạn có thể hình ảnh hóa đầu vào âm thanh bằng JavaScript. Mặc dù bản demo đầu ra nằm trong React, nhưng chúng ta sẽ không đi sâu vào khía cạnh React của mọi thứ. Các kỹ thuật cơ bản hoạt động với hoặc không có React. Trong trường hợp này, tôi cần tạo điều này trong React vì trang web của Kent sử dụng Remix. Chúng tôi sẽ tập trung vào cách bạn thu âm thanh từ người dùng và những gì bạn có thể làm với dữ liệu đó.

Lưu ý: Để xem bản demo đang hoạt động, bạn sẽ cần mở và kiểm tra trực tiếp chúng trên trang web CodePen. Thưởng thức nhé!

Chúng ta bắt đầu từ đâu? Vâng, Kent đã vui lòng cung cấp cho tôi một điểm khởi đầu đã được thiết lập và chạy. Bạn có thể thử nghiệm nó ở đây trong ví dụ CodePen này:

Xem Pen [1. [Điểm khởi đầu của Kent](https://codepen.io/smashingmag/pen/MWOZgWb) của jh3y.
Xem Bút 1. Điểm khởi đầu của Kent của jh3y.
Bạn có thể chọn thiết bị đầu vào và bắt đầu ghi âm. Và bạn sẽ thấy hình ảnh sóng âm khá thú vị. Bạn có thể tạm dừng và dừng bản ghi âm rồi ghi lại. Trên thực tế, Kent đã thiết lập rất nhiều chức năng ở đây cho tôi bằng cách sử dụng XState (XState là một bài viết khác).

Nhưng, phần mà anh ấy không hài lòng là hình ảnh. Anh ấy muốn có một hình ảnh âm thanh giống như trên Zencastr hoặc Google Recorder. Kiểu thanh âm thanh cuộn ngang. Thành thật mà nói, điều này thực sự dễ làm việc hơn vì những lý do chúng tôi sẽ đề cập sau.



Trước khi bắt đầu tạo hình ảnh trực quan, chúng ta hãy phân tích điểm khởi đầu đó.

Bây giờ, tại điểm khởi đầu, Kent sử dụng XState để xử lý các trạng thái khác nhau của máy ghi âm. Tuy nhiên, chúng ta có thể chọn lọc những phần quan trọng mà bạn cần biết. API chính đang hoạt động là API MediaRecorder và sử dụng navigator.mediaDevices.

Chúng ta hãy bắt đầu với navigator.mediaDevices. Điều này cho phép chúng ta truy cập vào bất kỳ thiết bị phương tiện nào được kết nối như webcam và micrô. Trong bản demo, chúng ta đang lọc và trả về các đầu vào âm thanh được trả về từ enumerateDevices. Sau đó, chúng được lưu trữ trong trạng thái demo và hiển thị dưới dạng các nút nếu chúng ta chọn thay đổi từ đầu vào âm thanh mặc định. Nếu chúng ta chọn sử dụng một thiết bị khác với thiết bị mặc định, thì thiết bị này sẽ được lưu trữ trong trạng thái demo.
Mã:
getDevices: async () => { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(({ kind }) => kind === "audioinput");},
Khi đã có thiết bị đầu vào âm thanh, đã đến lúc thiết lập MediaRecorder để chúng ta có thể thu âm thanh đó. Thiết lập MediaRecorder mới yêu cầu MediaStream mà chúng ta có thể lấy bằng cách sử dụng navigator.mediaDevices.
Mã:
// deviceId được lưu trữ trong trạng thái nếu chúng ta chọn một giá trị khác ngoài mặc định// Chúng ta lấy danh sách thiết bị đó từ "enumerateDevices"const audio = deviceId ? { deviceId: { exact: deviceId } } : true;const stream = await navigator.mediaDevices.getUserMedia({ audio })const recorder = new MediaRecorder(stream)
Bằng cách truyền audio: true cho getUserMedia, chúng ta sẽ quay lại sử dụng thiết bị đầu vào âm thanh "mặc định". Nhưng chúng ta có thể truyền một deviceId cụ thể nếu chúng ta muốn sử dụng một thiết bị khác.

Sau khi tạo một MediaRecorder, chúng ta đã sẵn sàng! Chúng ta có một thể hiện MediaRecorder và quyền truy cập vào một số phương thức tự giải thích.
  • start
  • stop
  • pause
  • resume
Tất cả đều tốt nhưng chúng ta cần làm gì đó với dữ liệu được ghi lại. Để xử lý dữ liệu này, chúng ta sẽ tạo một Mảng để lưu trữ các "khối" dữ liệu âm thanh.
Mã:
const chunks = []
Sau đó, chúng ta sẽ đẩy các khối vào Mảng đó khi có dữ liệu. Để móc vào sự kiện đó, chúng ta sử dụng ondataavailable. Sự kiện này kích hoạt khi MediaStream dừng hoặc kết thúc.
Mã:
recorder.ondataavailable = event => { chunks.push(event.data)}
Lưu ý: MediaRecorder có thể cung cấp trạng thái hiện tại của nó bằng thuộc tính state. Trạng thái có thể là tạm dừng, không hoạt động hoặc tạm dừng. Điều này hữu ích để đưa ra quyết định tương tác trong UI.

Có một điều cuối cùng chúng ta cần làm. Khi dừng bản ghi, chúng ta cần tạo một Blob âm thanh. Đây sẽ là mp3 của bản ghi âm của chúng ta. Trong bản demo của chúng ta, blob âm thanh được lưu trữ trong trạng thái demo được xử lý bằng XState. Nhưng phần quan trọng là phần này.
Mã:
new Blob(chunks, { type: 'audio/mp3' })
Với Blob này, chúng ta có thể phát lại bản ghi âm của mình bằng phần tử audio.

Hãy xem bản demo này, trong đó tất cả mã React và XState đều bị loại bỏ. Đây là tất cả những gì chúng ta cần để ghi âm bằng thiết bị đầu vào âm thanh mặc định.
Mã:
const TOGGLE = document.querySelector('#toggle')const AUDIO = document.querySelector('audio')let recorderconst RECORD = () => { const toggleRecording = async () => { if (!recorder) { // Đặt lại thẻ âm thanh AUDIO.removeAttribute('src') const CHUNKS = [] const MEDIA_STREAM = await window.navigator.mediaDevices.getUserMedia({ audio: true }) recorder = new MediaRecorder(MEDIA_STREAM) recorder.ondataavailable = event => { // Cập nhật giao diện người dùng TOGGLE.innerText = 'Bắt đầu ghi' recorder = null // Tạo blob và hiển thị phần tử âm thanh CHUNKS.push(event.data) const AUDIO_BLOB = new Blob(CHUNKS, {type: "audio/mp3"}) AUDIO.setAttribute('src', window.URL.createObjectURL(AUDIO_BLOB)) } TOGGLE.innerText = 'Dừng ghi' recorder.start() } else { recorder.stop() } } toggleRecording()}TOGGLE.addEventListener('click', RECORD)
Xem Bút [2. [Barebones Audio Input](https://codepen.io/smashingmag/pen/rNYoNMQ) của jh3y.
Xem Bút 2. Barebones Audio Input của jh3y.
Lưu ý: Để biết thêm thông tin chi tiết về cách thiết lập MediaRecorder và sử dụng nó, hãy xem bài viết MDN này: “Sử dụng MediaStream Recording API”.

Hình ảnh hóa ✨

Được rồi. Bây giờ chúng ta đã có ý tưởng về cách ghi lại đầu vào âm thanh từ người dùng, chúng ta có thể bắt đầu vào phần thú vị! Nếu không có bất kỳ hình ảnh hóa nào, giao diện người dùng ghi âm của chúng ta sẽ không hấp dẫn lắm. Ngoài ra, không có gì báo hiệu cho người dùng biết rằng bản ghi đang hoạt động. Ngay cả một vòng tròn đỏ nhấp nháy cũng tốt hơn là không có gì! Nhưng chúng ta có thể làm tốt hơn thế.

Đối với hình ảnh âm thanh, chúng ta sẽ sử dụng HTML5 Canvas. Nhưng trước khi đến giai đoạn đó, chúng ta cần hiểu cách lấy dữ liệu âm thanh thời gian thực và làm cho nó có thể sử dụng được. Sau khi tạo MediaRecorder, chúng ta có thể truy cập MediaStream của nó bằng thuộc tính stream.

Sau khi có MediaStream, chúng ta muốn phân tích nó bằng API AudioContext.
Mã:
const STREAM = recorder.streamconst CONTEXT = new AudioContext() // Đóng sauconst ANALYSER = CONTEXT.createAnalyser() // Ngắt kết nối trình phân tíchconst SOURCE = CONTEXT.createMediaStreamSource(STREAM) // ngắt kết nối nguồnSOURCE.connect(ANALYSER)
Chúng ta bắt đầu bằng cách tạo AudioContext mới. Sau đó, chúng ta tạo AnalyserNode. Đây là thứ cho phép chúng ta truy cập dữ liệu thời gian và tần số âm thanh. Điều cuối cùng chúng ta cần là một nguồn để kết nối. Chúng ta có thể sử dụng createMediaStreamSource để tạo MediaStreamAudioSourceNode. Việc cuối cùng cần làm là kết nối nút này với bộ phân tích, biến nó thành đầu vào cho bộ phân tích.

Bây giờ chúng ta đã thiết lập xong mẫu đó, chúng ta có thể bắt đầu chơi với dữ liệu thời gian thực. Để làm điều này, chúng ta có thể sử dụng window.requestAnimationFrame để thu thập dữ liệu từ bộ phân tích. Điều này có nghĩa là chúng ta sẽ có thể xử lý dữ liệu nói chung theo tốc độ làm mới của màn hình.

Trong mỗi lần phân tích, chúng tôi lấy dữ liệu của máy phân tích và sử dụng getByteFrequencyData. Phương pháp đó cho phép chúng tôi sao chép dữ liệu vào Uint8Array có kích thước bằng frequencyBinCount. frequencyBinCount là gì? Đó là thuộc tính chỉ đọc có giá trị bằng một nửa giá trị của fftSize của máy phân tích. fftSize là gì? Tôi không phải là kỹ sư âm thanh. Nhưng hãy nghĩ về điều này như số lượng mẫu được lấy khi thu thập dữ liệu. fftSize phải là lũy thừa của 2 và theo mặc định là 2048 (Bạn còn nhớ trò chơi đó không? Có thể có bài viết trong tương lai không?). Điều đó có nghĩa là mỗi lần chúng ta gọi getByteFrequencyData, chúng ta sẽ nhận được 2048 mẫu dữ liệu tần số. Và điều đó có nghĩa là chúng ta có khoảng 1024 giá trị để chơi với hình ảnh trực quan của mình ✨

Lưu ý :Bạn có thể đã nhận thấy ở điểm bắt đầu của Kent, chúng tôi sử dụng getByteTimeDomainData. Điều này là do bản demo gốc sử dụng hình ảnh trực quan dạng sóng. getByteTimeDomainData sẽ trả về dữ liệu dạng sóng(miền thời gian). Trong khi getByteFrequencyData trả về các giá trị decibel cho tần số trong một mẫu. Điều này phù hợp hơn với hình ảnh trực quan kiểu bộ cân bằng, trong đó chúng ta trực quan hóa âm lượng đầu vào.

OK. Vậy mã trông như thế nào để xử lý dữ liệu tần số của chúng ta? Hãy cùng tìm hiểu sâu hơn. Chúng ta có thể tách các mối quan tâm ở đây bằng cách tạo một hàm lấy MediaStream.
Mã:
const ANALYSE = stream => { // Tạo AudioContext const CONTEXT = new AudioContext() // Tạo Analyser const ANALYSER = CONTEXT.createAnalyser() // Kết nối nguồn luồng phương tiện để kết nối với máy phân tích const SOURCE = CONTEXT.createMediaStreamSource(stream) // Tạo Uint8Array dựa trên frequencyBinCount(fftSize / 2) const DATA_ARR = new Uint8Array(ANALYSER.frequencyBinCount) // Kết nối máy phân tích SOURCE.connect(ANALYSER) // REPORT là hàm chạy trên mỗi khung hình hoạt ảnh cho đến khi ghi === false const REPORT = () => { // Sao chép dữ liệu tần số vào DATA_ARR ANALYSER.getByteFrequencyData(DATA_ARR) // Nếu chúng ta vẫn đang ghi, hãy chạy REPORT một lần nữa trong khung hình khả dụng tiếp theo if (recorder) requestAnimationFrame(REPORT) else { // Nếu không, hãy đóng ngữ cảnh và xóa nó. CONTEXT.close() } } // Khởi tạo báo cáo REPORT()}
Đó là mẫu chúng ta cần để bắt đầu chơi với dữ liệu âm thanh. Nhưng hiện tại, điều này không có tác dụng gì nhiều ngoài việc chạy ở chế độ nền. Bạn có thể ném console.info hoặc debugger vào REPORT để xem điều gì đang xảy ra.

Xem Bút [3. [Lấy mẫu dữ liệu đầu vào](https://codepen.io/smashingmag/pen/PoOXoWp) của jh3y.
Xem Bút 3. Lấy mẫu dữ liệu đầu vào của jh3y.
Người tinh mắt có thể nhận thấy điều gì đó. Ngay cả khi chúng ta dừng ghi, biểu tượng ghi vẫn nằm trong tab trình duyệt của chúng ta. Điều này không lý tưởng. Mặc dù MediaRecorder đã dừng, MediaStream vẫn hoạt động. Chúng ta cần dừng tất cả các bản nhạc khả dụng khi dừng.
Mã:
// Xé bỏ sau khi ghi.recorder.stream.getTracks().forEach(t => t.stop())recorder = null
Chúng ta có thể thêm điều này vào hàm gọi lại ondataavailable mà chúng ta đã định nghĩa trước đó.

Gần xong rồi. Đã đến lúc chuyển đổi dữ liệu tần suất của chúng ta thành một khối lượng và trực quan hóa nó. Hãy bắt đầu bằng cách hiển thị khối lượng ở định dạng có thể đọc được cho người dùng.
Mã:
const REPORT = () => { ANALYSER.getByteFrequencyData(DATA_ARR) const VOLUME = Math.floor((Math.max(...DATA_ARR) / 255) * 100) LABEL.innerText = `${VOLUME}%` if (recorder) requestAnimationFrame(REPORT) else { CONTEXT.close() LABEL.innerText = '0%' }}
Tại sao chúng ta chia giá trị lớn nhất cho 255. Bởi vì đó là thang dữ liệu tần suất được trả về bởi getByteFrequencyData. Mỗi giá trị trong mẫu của chúng ta có thể từ 0 đến 255.

Làm tốt lắm! Bạn đã tạo hình ảnh âm thanh đầu tiên của mình 🎉 Sau khi vượt qua mã mẫu, bạn không cần nhiều mã để bắt đầu phát.

Xem Bút [4. Xử lý Dữ liệu](https://codepen.io/smashingmag/pen/LYOMYyY) của jh3y.
Xem Bút 4. Xử lý Dữ liệu của jh3y.
Chúng ta hãy bắt đầu làm cho nó "sang trọng" hơn. 💅

Chúng ta sẽ đưa GSAP vào hỗn hợp. Điều này mang lại nhiều lợi ích. Điều tuyệt vời với GSAP là nó không chỉ hoạt hình hóa các thứ trực quan. Nó liên quan đến hoạt hình hóa các giá trị và cũng cung cấp rất nhiều tiện ích tuyệt vời. Nếu bạn chưa từng thấy GSAP trước đây, đừng lo lắng. Chúng tôi sẽ hướng dẫn bạn cách sử dụng nó ở đây.

Chúng ta hãy cập nhật bản demo của mình bằng cách làm cho nhãn tỷ lệ theo kích thước dựa trên khối lượng. Đồng thời, chúng ta có thể thay đổi màu sắc bằng cách hoạt hình hóa giá trị thuộc tính tùy chỉnh của CSS.
Mã:
let recorderlet reportlet audioContextconst CONFIG = { DURATION: 0.1,}const ANALYSE = stream => { audioContext = new AudioContext() const ANALYSER = audioContext.createAnalyser() const SOURCE = audioContext.createMediaStreamSource(stream) const DATA_ARR = new Uint8Array(ANALYSER.frequencyBinCount) SOURCE.connect(ANALYSER) report = () => { ANALYSER.getByteFrequencyData(DATA_ARR) const VOLUME = Math.floor((Math.max(...DATA_ARR) / 255) * 100) LABEL.innerText = `${VOLUME}%` gsap.to(LABEL, { scale: 1 + ((VOLUME * 2) / 100), '--hue': 100 - VOLUME, duration: CONFIG.DURATION, }) } gsap.ticker.add(report)}
Trên mỗi khung hình của mã GSAP, chúng ta đang tạo hiệu ứng hoạt hình cho phần tử LABEL bằng cách sử dụng gsap.to. Chúng tôi đang yêu cầu GSAP tạo hiệu ứng động cho scale--hue của phần tử với duration được cấu hình.
Mã:
gsap.to(LABEL, { scale: 1 + ((VOLUME * 2) / 100), '--hue': 100 - VOLUME, duration: CONFIG.DURATION,})
Bạn cũng sẽ nhận thấy requestAnimationFrame đã biến mất. Nếu bạn định sử dụng GSAP cho bất kỳ thứ gì sử dụng khung hình động. Bạn nên chuyển sang sử dụng các hàm tiện ích riêng của GSAP. Điều này áp dụng cho HTML Canvas (Chúng ta sẽ tìm hiểu về điều này), Three JS, v.v.

GSAP cung cấp ticker, đây là trình bao bọc tuyệt vời cho requestAnimationFrame. Trình bao bọc này chạy đồng bộ với công cụ GSAP và có API ngắn gọn, đẹp mắt. Nó cũng cung cấp các tính năng gọn gàng như khả năng cập nhật tốc độ khung hình. Điều đó có thể trở nên phức tạp nếu bạn tự viết và nếu bạn đang sử dụng GSAP, bạn nên sử dụng các công cụ mà nó cung cấp.
Mã:
gsap.ticker.add(REPORT) // Thêm chức năng báo cáo cho mỗi khung hìnhgsap.ticker.remove(REPORT) // Dừng chạy REPORT trên mỗi khung hìnhgsap.ticker.fps(24) // Sẽ cập nhật các khung hình của chúng ta để chạy ở tốc độ 24fps (Cinematic)
Bây giờ, chúng ta có bản demo trực quan thú vị hơn và mã sạch hơn với GSAP.

Bạn cũng có thể tự hỏi mã phân tích đã đi đâu. Chúng tôi đã chuyển mã đó vào else của RECORD. Điều này sẽ giúp việc này dễ dàng hơn sau này nếu chúng ta chọn hoạt hình hóa mọi thứ sau khi hoàn tất bản ghi. Ví dụ: trả về trạng thái ban đầu của một phần tử. Chúng ta có thể giới thiệu các giá trị trạng thái để theo dõi nếu cần.
Mã:
const RECORD = () => { const toggleRecording = async () => { if (!recorder) { // Thiết lập mã ghi âm... } else { recorder.stop() LABEL.innerText = '0%' gsap.to(LABEL, { duration: CONFIG.DURATION, scale: 1, hue: 100, onComplete: () => { gsap.ticker.remove(report) audioContext.close() } }) } } toggleRecording()}
Khi chúng ta teardown, chúng ta sẽ chuyển nhãn của mình về trạng thái ban đầu. Và bằng cách sử dụng phương thức onComplete, chúng ta có thể xóa hàm report của mình khỏi ticker. Đồng thời, chúng ta có thể đóng AudioContext của mình.

Xem Bút [5. Làm cho “sang chảnh” với GSAP](https://codepen.io/smashingmag/pen/yLPGLbq) của jh3y.
Xem Bút 5. Làm cho “sang chảnh” với GSAP của jh3y.
Để tạo hình ảnh trực quan cho các thanh EQ, chúng ta cần bắt đầu sử dụng HTML Canvas. Đừng lo lắng nếu bạn chưa có kinh nghiệm về Canvas. Chúng tôi sẽ hướng dẫn những điều cơ bản về cách kết xuất hình dạng và cách sử dụng GreenSock với canvas của chúng tôi. Trên thực tế, trước tiên chúng tôi sẽ xây dựng một số hình ảnh trực quan cơ bản.

Chúng ta hãy bắt đầu với một phần tử canvas.
Mã:
Để hiển thị mọi thứ trên canvas, chúng ta cần lấy một ngữ cảnh vẽ, đó là thứ chúng ta vẽ lên. Chúng ta cũng cần xác định kích thước cho canvas của mình. Theo mặc định, chúng có kích thước là 300 x 150 pixel. Điều thú vị là canvas có hai kích thước. Nó có kích thước "vật lý" và kích thước "canvas". Ví dụ, chúng ta có thể có một canvas có kích thước vật lý là 300 x 150 pixel. Nhưng kích thước "canvas" vẽ là 100 x 100 pixel. Hãy thử bản demo này, bản demo này vẽ một hình vuông màu đỏ có kích thước 40 x 40 pixel ở giữa canvas.

Xem Bút [6. Điều chỉnh kích thước vật lý và kích thước canvas cho Canvas](https://codepen.io/smashingmag/pen/QWOzWgm) của jh3y.
Xem Bút 6. Điều chỉnh kích thước vật lý và kích thước canvas cho Canvas của jh3y.
Làm thế nào để chúng ta vẽ mọi thứ lên canvas? Hãy xem bản demo ở trên và xem xét một canvas có kích thước 200 x 200 pixel.
Mã:
// Lấy canvas của chúng taconst CANVAS = document.querySelector('canvas')// Đặt kích thước canvasCANVAS.width = 200CANVAS.height = 200// Lấy bối cảnh canvasconst CONTEXT = CANVAS.getContext('2d')// Xóa toàn bộ canvas bằng hình chữ nhật có kích thước "CANVAS.width" x "CANVAS.height"// bắt đầu từ (0, 0)CONTEXT.clearRect(0, 0, CANVAS.width, CANVAS.height)// Đặt màu tô thành "đỏ"CONTEXT.fillStyle = 'đỏ'// Tô hình chữ nhật tại (80, 80) với chiều rộng và chiều cao là 40CONTEXT.fillRect(80, 80, 40, 40)
Chúng ta bắt đầu bằng cách thiết lập kích thước canvas và lấy ngữ cảnh. Sau đó, sử dụng ngữ cảnh, chúng ta sử dụng fillRect để vẽ một hình vuông tại các tọa độ đã cho. Hệ tọa độ trong canvas bắt đầu từ góc trên cùng bên trái. Vì vậy, [0, 0] là góc trên cùng bên trái. Đối với canvas của chúng ta, [200, 200] sẽ là góc dưới bên phải.

Đối với hình vuông của chúng ta, tọa độ là một nửa chiều rộng và chiều cao của canvas trừ đi một nửa kích thước hình vuông.
Mã:
// Chiều rộng/Chiều cao của Canvas = 200// Kích thước hình vuông = 40CONTEXT.fillRect((200 / 2) - (40 / 2), (200 / 2) - (40 / 2), 40, 40)
Điều này sẽ vẽ hình vuông của chúng ta ở giữa.
Mã:
context.fillRect(x, y, width, height)
Vì chúng ta bắt đầu với một canvas trống, nên clearRect là không cần thiết. Nhưng mỗi lần chúng ta vẽ trên một canvas, nó không xóa đối với chúng ta. Với hoạt ảnh, rất có thể mọi thứ sẽ di chuyển. Vì vậy, xóa toàn bộ canvas trước khi vẽ lại là một cách tiếp cận tốt.

Hãy xem bản demo này để tạo hiệu ứng động cho một hình vuông từ bên này sang bên kia. Hãy thử bật và tắt clearRect để xem điều gì xảy ra. Không xóa canvas trong một số trường hợp có thể tạo ra một số hiệu ứng thú vị.

Xem Bút [7. Xóa Canvas mỗi khung hình](https://codepen.io/smashingmag/pen/MWOZWBL) của jh3y.
Xem Bút 7. Xóa Canvas mỗi khung hình bằng jh3y.
Bây giờ chúng ta đã có ý tưởng cơ bản về việc vẽ mọi thứ lên canvas, hãy kết hợp ý tưởng đó với GSAP để trực quan hóa dữ liệu âm thanh của chúng ta. Chúng ta sẽ trực quan hóa một hình vuông thay đổi màu sắc và kích thước giống như label của chúng ta.

Chúng ta có thể bắt đầu bằng cách xóa label và tạo canvas. Sau đó, trong vùng JavaScript, chúng ta cần lấy canvas đó và ngữ cảnh hiển thị của nó. Sau đó, chúng ta có thể thiết lập kích thước của canvas để phù hợp với kích thước vật lý của nó.
Mã:
const CANVAS = document.querySelector('canvas')const CONTEXT = CANVAS.getContext('2d')// Phù hợp với kích thước canvas với kích thước vật lýCANVAS.width = CANVAS.height = CANVAS.offsetHeight
Chúng ta cần một Đối tượng để biểu diễn hình vuông của mình. Đối tượng này sẽ xác định kích thước, sắc độ và tỷ lệ của hình vuông. Bạn còn nhớ cách chúng ta đề cập GSAP rất tuyệt vì nó làm động các giá trị không? Điều này sẽ sớm được áp dụng.
Mã:
const SQUARE = { sắc độ: 100, tỷ lệ: 1, kích thước: 40,}
Để vẽ hình vuông, chúng ta sẽ xác định một hàm giữ mã đó ở một nơi. Hàm này xóa canvas và sau đó hiển thị hình vuông ở giữa dựa trên tỷ lệ hiện tại của nó.
Mã:
const drawSquare = () => { const SQUARE_SIZE = SQUARE.scale * SQUARE.size const SQUARE_POINT = CANVAS.width / 2 - SQUARE_SIZE / 2 CONTEXT.clearRect(0, 0, CANVAS.width, CANVAS.height) CONTEXT.fillStyle = `hsl(${SQUARE.hue}, 80%, 50%)` CONTEXT.fillRect(SQUARE_POINT, SQUARE_POINT, SQUARE_SIZE, SQUARE_SIZE)}
Ban đầu, chúng ta sẽ render hình vuông sao cho canvas không bị trống khi bắt đầu:
Mã:
drawSquare()
Bây giờ. Đây là phần kỳ diệu. Chúng ta chỉ cần mã để tạo hiệu ứng động cho các giá trị hình vuông của mình. Chúng ta có thể cập nhật hàm report của mình thành hàm sau:
Mã:
report = () => { if (recorder) { ANALYSER.getByteFrequencyData(DATA_ARR) const VOLUME = Math.max(...DATA_ARR) / 255 gsap.to(SQUARE, { duration: CONFIG.duration, hue: gsap.utils.mapRange(0, 1, 100, 0)(VOLUME), scale: gsap.utils.mapRange(0, 1, 1, 5)(VOLUME) }) } // render square drawSquare()}
Bất kể thế nào, report phải render hình vuông của chúng ta. Nhưng nếu chúng ta đang ghi, chúng ta có thể trực quan hóa thể tích đã tính toán. Giá trị âm lượng của chúng ta sẽ nằm giữa 01. Và chúng ta có thể sử dụng GSAP utils để ánh xạ giá trị đó thành một phạm vi sắc độ và thang độ mong muốn với mapRange.
Có nhiều cách khác nhau để xử lý âm lượng trong dữ liệu âm thanh của chúng ta. Đối với các bản demo này, tôi đang sử dụng giá trị lớn nhất từ Mảng dữ liệu để dễ dàng. Một giải pháp thay thế có thể là xử lý giá trị đọc trung bình bằng cách sử dụng reduce.

Ví dụ:
Mã:
const VOLUME = Math.floor(((DATA_ARR.reduce((acc, a) => acc + a, 0) / DATA_ARR.length) / 255) * 100)
Sau khi hoàn tất quá trình ghi, chúng tôi sẽ chuyển các giá trị bình phương trở về giá trị ban đầu của chúng.
Mã:
gsap.to(SQUARE, { duration: CONFIG.duration, scale: 1, hue: 100, onComplete: () => { audioContext.close() gsap.ticker.remove(report) }})
Hãy đảm bảo rằng bạn đã xóa reportaudioContext trong hàm gọi lại onComplete của bạn. Bạn có để ý thấy mã GSAP tách biệt với mã kết xuất không? Đó là điều tuyệt vời khi sử dụng GSAP để tạo hoạt ảnh cho các giá trị Đối tượng. Hàm drawSquare của chúng ta chạy mọi khung hình bất kể điều gì xảy ra với các hình vuông, nó lấy các giá trị và kết xuất hình vuông. Điều này có nghĩa là GSAP có thể điều chỉnh các giá trị đó ở bất kỳ đâu trong mã của chúng ta. Các bản cập nhật sẽ được kết xuất bởi drawSquare.

Và đây là kết quả! ✨ Hình ảnh GSAP đầu tiên của chúng ta.

Xem Bút [8. Hình ảnh hóa Canvas đầu tiên ✨](https://codepen.io/smashingmag/pen/NWweWLM) của jh3y.
Xem Bút 8. Hình ảnh hóa Canvas đầu tiên ✨ của jh3y.
Nếu chúng ta mở rộng điều đó thì sao? Tại sao không tạo một hình vuông ngẫu nhiên cho mỗi mẫu từ dữ liệu của chúng ta? Nó có thể trông như thế này? Nó có thể trông như thế này!

Xem Bút [9. [Hình ảnh âm thanh được tạo ngẫu nhiên 🚀](https://codepen.io/smashingmag/pen/podqoOQ) của jh3y.
Xem Bút 9. Hình ảnh âm thanh được tạo ngẫu nhiên 🚀 của jh3y.
Trong bản demo này, chúng tôi sử dụng fftSize nhỏ hơn và tạo một hình vuông cho mỗi mẫu. Mỗi hình vuông có các đặc điểm ngẫu nhiên và cập nhật sau mỗi lần ghi âm. Bản demo này tiến xa hơn một chút và cho phép bạn cập nhật kích thước mẫu. Điều đó có nghĩa là bạn có thể có nhiều hoặc ít ô vuông tùy thích!

Xem Bút [10. Hình ảnh hóa đầu vào âm thanh ngẫu nhiên với Kích thước mẫu có thể định cấu hình ✨](https://codepen.io/smashingmag/pen/mdqadzm) của jh3y.
Xem Bút 10. Hình ảnh hóa đầu vào âm thanh ngẫu nhiên với kích thước mẫu có thể định cấu hình ✨ của jh3y.
"Thử thách Canvas
Bạn có thể tạo lại hình ảnh hóa ngẫu nhiên này nhưng hiển thị hình tròn thay vì hình vuông không? Màu sắc khác nhau thì sao? Chia nhánh bản demo và chơi với chúng. Hãy liên hệ nếu bạn gặp khó khăn!"
Bây giờ chúng ta đã biết cách trực quan hóa đầu vào âm thanh của mình bằng canvas HTML sử dụng GSAP. Nhưng trước khi lạc đề tạo trực quan hóa được tạo ngẫu nhiên, chúng ta cần quay lại với bản tóm tắt của mình!

Chúng ta muốn tạo các thanh EQ di chuyển từ phải sang trái. Chúng ta đã thiết lập đầu vào âm thanh. Tất cả những gì chúng ta cần làm là thay đổi cách trực quan hóa hoạt động. Thay vì các ô vuông, chúng ta sẽ làm việc với các thanh. Mỗi thanh có vị trí "x" và sẽ được căn giữa trên trục "y". Mỗi thanh có một "kích thước" sẽ là chiều cao. Vị trí "x" bắt đầu sẽ là cực phải của canvas.
Mã:
// Mảng để chứa các thanh của chúng taconst BARS = []// Tạo một thanh mớiconst NEW_BAR = { x: CANVAS.width, size: VOLUME, // Âm lượng cho khung đó}
Điểm khác biệt giữa các trực quan hóa trước đó và trực quan hóa này là chúng ta cần thêm một thanh mới vào mỗi khung. Điều này xảy ra bên trong hàm ticker. Đồng thời, chúng ta cần tạo một hoạt ảnh mới cho các giá trị của thanh đó. Một tính năng trong bản tóm tắt của chúng tôi là chúng ta cần có khả năng "tạm dừng" và "tiếp tục" bản ghi. Việc tạo hoạt ảnh mới cho mỗi thanh sẽ không hoạt động theo cùng một cách. Chúng ta cần tạo một dòng thời gian mà chúng ta có thể tham chiếu và sau đó thêm hoạt ảnh vào. Sau đó, chúng ta có thể tạm dừng và tiếp tục tất cả các hoạt ảnh thanh cùng một lúc. Chúng ta có thể giải quyết việc tạm dừng hoạt ảnh sau khi đã làm cho nó hoạt động. Hãy bắt đầu bằng cách cập nhật hình ảnh trực quan của chúng ta.

Sau đây là một số mẫu để vẽ các thanh và biến mà chúng ta sử dụng để giữ tham chiếu.
Mã:
// Giữ tham chiếu đến dòng thời gian GSAPlet timeline = gsap.timeline()// Tạo mảng cho THANHconst BARS = []// Xác định chiều rộng Thanh trên canvasconst BAR_WIDTH = 4// Chúng ta có thể khai báo kiểu tô bên ngoài vòng lặp.// Hãy bắt đầu với màu đỏ!DRAWING_CONTEXT.fillStyle = 'red'// Cập nhật hàm vẽ của chúng ta để vẽ một thanh ở đúng "x" tính đến chiều rộng// Hiển thị thanh theo chiều dọc ở giữaconst drawBar = ({ x, size }) => { const POINT_X = x - BAR_WIDTH / 2 const POINT_Y = CANVAS.height / 2 - size / 2 DRAWING_CONTEXT.fillRect(POINT_X, POINT_Y, BAR_WIDTH, size)}// drawBars được cập nhật để lặp qua các biến mớiconst drawBars = () => { DRAWING_CONTEXT.clearRect(0, 0, CANVAS.width, CANVAS.height) for (const BAR of BARS) { drawBar(BAR) }}
Khi chúng ta dừng trình ghi, chúng ta có thể xóa dòng thời gian để sử dụng lại. Điều này phụ thuộc vào hành vi mong muốn (Thông tin chi tiết về điều này sau):
Mã:
timeline.clear()
Điều cuối cùng cần cập nhật là hàm báo cáo của chúng ta:
Mã:
REPORT = () => { if (recorder) { ANALYSER.getByteFrequencyData(DATA_ARR) const VOLUME = Math.floor((Math.max(...DATA_ARR) / 255) * 100) // Tại thời điểm này, hãy tạo một thanh và thêm nó vào dòng thời gian const BAR = { x: CANVAS.width + BAR_WIDTH / 2, size: gsap.utils.mapRange(0, 100, 5, CANVAS.height * 0.8)(VOLUME) } // Thêm vào mảng thanh BARS.push(BAR) // Thêm hoạt ảnh thanh vào dòng thời gian dòng thời gian .to(BAR, { x: `-=${CANVAS.width + BAR_WIDTH}`, ease: 'none' duration: CONFIG.duration, }) } if (recorder || visualizing) { drawBars() }}
Điều đó có nghĩa là gì nhìn này?

Xem Bút [11. Thử Thanh EQ](https://codepen.io/smashingmag/pen/qBVLBQb) của jh3y.
Xem Bút 11. Thử Thanh EQ của jh3y.
Hoàn toàn sai… Nhưng tại sao? Vâng. Hiện tại chúng tôi đang thêm một hình ảnh động mới vào mỗi khung hình vào dòng thời gian của mình. Nhưng các hình ảnh động này chạy theo trình tự. Một thanh phải hoàn thành trước khi thanh tiếp theo tiến hành, đây không phải là điều chúng ta muốn. Vấn đề của chúng ta liên quan đến thời gian. Và thời gian của chúng ta cần phải tương đối với kích thước của canvas. Theo cách đó, nếu kích thước của canvas thay đổi, hoạt ảnh vẫn sẽ trông giống nhau.

Lưu ý: Hình ảnh của chúng ta sẽ bị méo nếu canvas có kích thước phản hồi và bị thay đổi kích thước. Mặc dù có thể cập nhật khi thay đổi kích thước, nhưng điều đó khá phức tạp. Chúng ta sẽ không đi sâu vào vấn đề đó ngày hôm nay.

Giống như cách chúng ta định nghĩa BAR_WIDTH, chúng ta có thể định nghĩa một số cấu hình khác cho hình ảnh của mình. Ví dụ: chiều cao tối thiểu và tối đa của một thanh. Chúng ta có thể dựa vào chiều cao của canvas.
Mã:
const VIZ_CONFIG = { bar: { width: 4, min_height: 0.04, max_height: 0.8 }}
Nhưng, điều chúng ta cần là quyết định số pixel mà các thanh của chúng ta sẽ di chuyển mỗi giây. Giả sử chúng ta di chuyển một thanh với tốc độ 100 pixel mỗi giây. Điều đó có nghĩa là thanh tiếp theo của chúng ta có thể nhập 4 pixel sau đó. Và theo thời gian, đó là 1 / 100 * 4 giây.
Mã:
const BAR_WIDTH = 4const PIXELS_PER_SECOND = 100const VIZ_CONFIG = { bar: { width: 4, min_height: 0.04, max_height: 0.8 }, pixelsPerSecond: PIXELS_PER_SECOND, barDelay: (1 / PIXELS_PER_SECOND) * BAR_WIDTH,}
Với GSAP, chúng ta có thể chèn hoạt ảnh vào dòng thời gian tại một dấu thời gian nhất định. Đây là tham số thứ hai tùy chọn của add. Nếu chúng ta biết chỉ số của thanh mà chúng ta đang thêm, điều đó có nghĩa là chúng ta có thể tính toán dấu thời gian để chèn.
Mã:
timeline .to(BAR, { x: `-=${CANVAS.width + VIZ_CONFIG.bar.width}`, ease: 'none', // Thời lượng sẽ giống nhau cho tất cả các thanh duration: CANVAS.width / VIZ_CONFIG.pixelsPerSecond, }, // Thời gian chèn hoạt ảnh. Dựa trên độ dài THANH mới. BARS.length * VIZ_CONFIG.barDelay )
Trông thế nào?

Xem Bút [12. [Càng gần hơn](https://codepen.io/smashingmag/pen/KKybKrr) của jh3y.
Xem cây bút 12. Càng gần hơn của jh3y.
Tốt hơn nhiều. Nhưng vẫn còn quá xa. Nó bị trễ quá nhiều và không đồng bộ đủ với dữ liệu đầu vào của chúng ta. Và đó là vì chúng ta cần phải chính xác hơn với các phép tính của mình. Chúng ta cần dựa thời gian vào tốc độ khung hình thực tế của hoạt ảnh. Đây là nơi gsap.ticker.fps có thể phát huy tác dụng. Hãy nhớ rằng gsap.ticker là nhịp đập của những gì đang diễn ra trong vùng đất GSAP.
Mã:
gsap.ticker.fps(DESIRED_FPS)
Nếu chúng ta đã xác định fps "mong muốn", thời lượng thực tế để một thanh di chuyển có thể được tính toán. Và chúng ta có thể dựa trên khoảng thời gian chúng ta muốn một thanh di chuyển trước khi thanh tiếp theo đi vào. Chúng tôi tính toán chính xác “Pixel trên giây”:
Mã:
(Chiều rộng thanh + Khoảng cách thanh) * Fps
Ví dụ, nếu chúng ta có fps là 50, chiều rộng thanh là 4 và khoảng cách là 0.
Mã:
(4 + 0) * 50 === 200
Các thanh của chúng ta cần di chuyển ở tốc độ 200 pixel trên giây. Sau đó, thời lượng của hoạt ảnh có thể được tính toán dựa trên chiều rộng của canvas.

Lưu ý: Bạn nên chọn FPS mà bạn biết người dùng của mình có thể sử dụng. Ví dụ, một số màn hình có thể chỉ hoạt động ở tốc độ 30 khung hình trên giây. Chỉ 24 khung hình trên giây được coi là cảm giác "điện ảnh".

Bản demo được cập nhật mang đến cho chúng ta hiệu ứng mong muốn! 🚀

Xem Bút [13. Quay số thời gian và khoảng cách](https://codepen.io/smashingmag/pen/Vwrqwqm) của jh3y.
Xem Bút 13. Quay số thời gian và khoảng cách của jh3y.
Bạn có thể tinh chỉnh thời gian và cách thanh EQ di chuyển trên canvas để có được hiệu ứng mong muốn. Đối với dự án cụ thể này, chúng tôi đang tìm kiếm càng gần với thời gian thực càng tốt. Ví dụ, bạn có thể nhóm các thanh và tính trung bình chúng nếu muốn. Có rất nhiều khả năng.

Bạn có thể nhận thấy rằng các thanh của chúng ta cũng đã thay đổi màu sắc và giờ chúng ta có hiệu ứng chuyển màu này. Điều này là do chúng ta đã cập nhật fillStyle để sử dụng linearGradient. Điều tuyệt vời về kiểu tô trong Canvas là chúng ta có thể áp dụng kiểu phủ lên canvas. Chuyển màu của chúng ta bao phủ toàn bộ canvas. Điều này có nghĩa là thanh càng lớn (đầu vào càng to), màu sắc sẽ thay đổi càng nhiều.
Mã:
const fillStyle = DRAWING_CONTEXT.createLinearGradient( CANVAS.width / 2, 0, CANVAS.width / 2, CANVAS.height)// Điểm dừng màu là hai màufillStyle.addColorStop(0.2, 'hsl(10, 80%, 50%)')fillStyle.addColorStop(0.8, 'hsl(10, 80%, 50%)')fillStyle.addColorStop(0.5, 'hsl(120, 80%, 50%)')DRAWING_CONTEXT.fillStyle = fillStyle
Bây giờ chúng ta đã đạt được một số thành quả với các thanh EQ của mình. Bản demo này cho phép bạn thay đổi hành vi của hình ảnh trực quan bằng cách cập nhật độ rộng và khoảng cách của thanh:

Xem Bút [14. Thời gian có thể cấu hình](https://codepen.io/smashingmag/pen/BamvavY) của jh3y.
Xem Bút 14. Thời gian có thể cấu hình của jh3y.
Nếu bạn chơi bản demo này, bạn có thể tìm ra cách để phá vỡ hoạt ảnh. Ví dụ, nếu bạn chọn tốc độ khung hình cao hơn tốc độ trên thiết bị của mình. Tất cả phụ thuộc vào mức độ chính xác mà chúng ta có thể có được thời gian của mình. Việc chọn tốc độ khung hình thấp hơn có xu hướng đáng tin cậy hơn.

Ở cấp độ cao, giờ đây bạn đã có các công cụ cần thiết để tạo hình ảnh âm thanh từ đầu vào của người dùng. Trong Phần 2 của loạt bài này, tôi sẽ giải thích cách bạn có thể thêm các tính năng và bất kỳ điểm nhấn bổ sung nào mà bạn thích. Hãy theo dõi vào tuần tới!

Đọc thêm​

  • Phân tích pháp y của các thành phần máy chủ React (RSC)
  • Tích hợp: Từ truyền dữ liệu đơn giản đến kiến trúc có thể cấu hình hiện đại
  • Tạo biểu mẫu nhiều bước hiệu quả để có trải nghiệm người dùng tốt hơn
  • Chuyển đổi các mục nhập lớp trên cùng và thuộc tính hiển thị trong CSS
 
Back
Bên trên