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

theanh

Administrator
Nhân viên
Tuần trước trong Phần 1, tôi đã giải thích ý tưởng về cách ghi lại đầu vào âm thanh từ người dùng và sau đó chuyển sang hình ảnh hóa. Rốt cuộc, nếu không có hình ảnh hóa, bất kỳ loại giao diện người dùng ghi âm nào cũng không hấp dẫn, đúng không? Hôm nay, chúng ta sẽ đi sâu hơn vào chi tiết về việc thêm các tính năng và bất kỳ loại điểm nhấn bổ sung nào mà bạn thích!

Chúng tôi sẽ đề cập đến những nội dung sau:
  • Cách tạm dừng bản ghi
  • Cách làm đầy hình ảnh
  • Cách hoàn tất bản ghi
  • Xóa các giá trị khi phát lại
  • Phát lại âm thanh từ các nguồn khác
  • Biến ứng dụng này thành ứng dụng React
Xin lưu ý rằng để xem bản demo hoạt động, bạn sẽ cần để mở và kiểm tra trực tiếp chúng trên trang web CodePen.

Tạm dừng bản ghi​

Tạm dừng bản ghi không cần nhiều mã.
Mã:
// Tạm dừng máy ghi âmrecorder.pause()// Tiếp tục bản ghirecorder.resume()
Trên thực tế, phần khó nhất khi tích hợp bản ghi là thiết kế giao diện người dùng của bạn. Sau khi bạn có thiết kế giao diện người dùng, có thể sẽ liên quan nhiều hơn đến những thay đổi bạn cần cho giao diện đó.

Ngoài ra, việc tạm dừng bản ghi không tạm dừng hoạt ảnh của chúng ta. Vì vậy, chúng ta cần đảm bảo rằng chúng ta cũng dừng hoạt ảnh đó. Chúng ta chỉ muốn thêm các thanh mới trong khi đang ghi âm. Để xác định trạng thái của máy ghi âm, chúng ta có thể sử dụng thuộc tính state đã đề cập trước đó. Đây là chức năng chuyển đổi được cập nhật của chúng tôi:
Mã:
const RECORDING = recorder.state === 'recording'// Tạm dừng hoặc tiếp tục ghi âm dựa trên trạng thái.TOGGLE.style.setProperty('--active', RECORDING ? 0 : 1)timeline[RECORDING ? 'pause' : 'play']()recorder[RECORDING ? 'pause' : 'resume']()
Và đây là cách chúng ta có thể xác định có nên thêm thanh mới vào trình báo cáo hay không.
Mã:
REPORT = () => { if (recorder && recorder.state === 'recording') {
Thử thách: Chúng ta cũng có thể xóa chức năng báo cáo khỏi gsap.ticker để tăng thêm hiệu suất không? Hãy thử xem.

Đối với bản demo của chúng tôi, chúng tôi đã thay đổi nút ghi âm thành nút tạm dừng. Và khi bản ghi âm bắt đầu, nút dừng sẽ xuất hiện. Điều này sẽ cần một số mã bổ sung để xử lý trạng thái đó. React phù hợp với điều này nhưng chúng tôi có thể dựa vào giá trị recorder.state.

Xem Bút [15. Tạm dừng Bản ghi âm](https://codepen.io/smashingmag/pen/BamgQEP) của Jhey.
Xem Bút 15. Tạm dừng bản ghi của Jhey.

Đệm hình ảnh​

Tiếp theo, chúng ta cần đệm hình ảnh. Ý chúng ta là gì? Vâng, chúng ta chuyển từ một khung vẽ trống sang các thanh phát trực tiếp. Đây là một sự tương phản khá lớn và sẽ thật tuyệt nếu khung vẽ được lấp đầy bằng các thanh âm lượng bằng không khi bắt đầu. Không có lý do gì chúng ta không thể làm điều này dựa trên cách chúng ta tạo các thanh của mình. Hãy bắt đầu bằng cách tạo một hàm đệm, padTimeline:
Mã:
// Di chuyển BAR_DURATION ra khỏi phạm vi để nó trở thành một biến được chia sẻ.const BAR_DURATION = CANVAS.width / ((CONFIG.barWidth + CONFIG.barGap) * CONFIG.fps)const padTimeline = () => { // Không quan trọng nếu chúng ta có nhiều thanh hơn chiều rộng. Chúng ta sẽ dịch chuyển chúng đến đúng vị trí const padCount = Math.floor(CANVAS.width / CONFIG.barWidth) for (let p = 0; p padCount; p++) { const BAR = { x: CANVAS.width + CONFIG.barWidth / 2, // Lưu ý rằng thể tích là 0 size: gsap.utils.mapRange( 0, 100, CANVAS.height * CONFIG.barMinHeight, CANVAS.height * CONFIG.barMaxHeight )(volume), } // Thêm vào mảng thanh BARS.push(BAR) // Thêm hoạt ảnh thanh vào dòng thời gian // Số pixel thực tế trên giây là (1 / fps * shift) * fps // nếu chúng ta có 50fps, thanh cần phải di chuyển chiều rộng thanh trước khi thanh tiếp theo xuất hiện // 1/50 = 4 === 50 * 4 = 200 timeline.to( BAR, { x: `-=${CANVAS.width + CONFIG.barWidth}`, ease: 'none', duration: BAR_DURATION, }, BARS.length * (1 / CONFIG.fps) ) } // Đặt dòng thời gian vào đúng vị trí để thêm vào dòng thời gian.totalTime(timeline.totalDuration() - BAR_DURATION)}
Bí quyết ở đây là thêm các thanh mới rồi đặt playhead của dòng thời gian vào vị trí các thanh lấp đầy canvas. Tại thời điểm đệm dòng thời gian, chúng ta biết rằng chúng ta chỉ có các thanh đệm nên có thể sử dụng totalDuration.
Mã:
timeline.totalTime(timeline.totalDuration() - BAR_DURATION)
Bạn có để ý thấy chức năng đó rất giống với những gì chúng ta làm bên trong hàm REPORT không? Chúng ta có cơ hội tốt để tái cấu trúc ở đây. Hãy tạo một hàm mới có tên là addBar. Hàm này sẽ thêm một thanh mới dựa trên volume đã truyền.
Mã:
const addBar = (volume = 0) => { const BAR = { x: CANVAS.width + CONFIG.barWidth / 2, size: gsap.utils.mapRange( 0, 100, CANVAS.height * CONFIG.barMinHeight, CANVAS.height * CONFIG.barMaxHeight )(volume), } BARS.push(BAR) timeline.to( BAR, { x: `-=${CANVAS.width + CONFIG.barWidth}`, ease: 'none', duration: BAR_DURATION, }, BARS.length * (1 / CONFIG.fps) )}
Bây giờ các hàm padTimelineREPORT của chúng ta có thể sử dụng điều này:
Mã:
const padTimeline = () => { const padCount = Math.floor(CANVAS.width / CONFIG.barWidth) cho (let p = 0; p padCount; p++) { addBar() } dòng thời gian.totalTime(timeline.totalDuration() - BAR_DURATION)}BÁO CÁO = () => { if (recorder && recorder.state === 'recording') { ANALYSER.getByteFrequencyData(DATA_ARR) const VOLUME = Math.floor((Math.max(...DATA_ARR) / 255) * 100) addBar(VOLUME) } if (recorder || visualizing) { drawBars() }}
Bây giờ, khi tải, chúng ta có thể thực hiện kết xuất ban đầu bằng cách gọi padTimeline theo sau là drawBars.
Mã:
padTimeline()drawBars()
Kết hợp tất cả lại với nhau và đó là một tính năng tuyệt vời khác!

Xem Bút [16. Làm đầy Dòng thời gian](https://codepen.io/smashingmag/pen/OJOebYE) của Jhey.
Xem Bút 16. Làm đầy Dòng thời gian của Jhey.

Chúng tôi Hoàn thiện như thế nào​

Bạn muốn kéo thành phần xuống hay tua lại, có thể là triển khai? Điều này ảnh hưởng đến hiệu suất như thế nào? Triển khai dễ dàng hơn. Nhưng tua lại thì khó hơn và có thể có những tác động đến hiệu suất.

Kết thúc bản ghi​

Bạn có thể kết thúc bản ghi theo bất kỳ cách nào bạn thích. Bạn có thể dừng hoạt ảnh và để nguyên ở đó. Hoặc, nếu chúng ta dừng hoạt ảnh, chúng ta có thể quay lại hoạt ảnh ban đầu. Điều này thường được sử dụng trong nhiều thiết kế UI/UX khác nhau. Và GSAP API cung cấp cho chúng ta một cách gọn gàng để thực hiện điều này. Thay vì xóa dòng thời gian khi dừng, chúng ta có thể di chuyển đến nơi chúng ta bắt đầu bản ghi để đặt lại dòng thời gian. Nhưng sau khi hoàn tất bản ghi, hãy giữ lại hoạt ảnh để có thể sử dụng.
Mã:
STOP.addEventListener('click', () => { if (recorder) recorder.stop() AUDIO_CONTEXT.close() // Tạm dừng dòng thời gian timeline.pause() // Làm hoạt ảnh đầu phát trở lại START_POINT gsap.to(timeline, { totalTime: START_POINT, onComplete: () => { gsap.ticker.remove(REPORT) } })})
Trong mã này, chúng ta chuyển totalTime trở lại vị trí chúng ta đặt đầu phát trong padTimeline.Điều đó có nghĩa là chúng ta cần tạo một biến để chia sẻ điều đó.
Mã:
let START_POINT
Và chúng ta có thể đặt biến đó trong padTimeline.
Mã:
const padTimeline = () => { const padCount = Math.floor(CANVAS.width / CONFIG.barWidth) for (let p = 0; p padCount; p++) { addBar() } START_POINT = timeline.totalDuration() - BAR_DURATION // Đặt dòng thời gian thành vị trí chính xác để được thêm vào timeline.totalTime(START_POINT)}
Chúng ta có thể xóa dòng thời gian bên trong hàm RECORD khi bắt đầu ghi âm:
Mã:
// Đặt lại dòng thời giantimeline.clear()
Và điều này cung cấp cho chúng ta thứ đang trở thành trình trực quan hóa âm thanh khá gọn gàng:

Xem Bút [17. Tua lại khi dừng](https://codepen.io/smashingmag/pen/LYOKbKW) của Jhey.
Xem Bút 17. Tua lại khi dừng của Jhey.

Xóa các giá trị khi phát lại​

Bây giờ chúng ta đã có bản ghi âm, chúng ta có thể phát lại bằng phần tử . Nhưng chúng ta muốn đồng bộ hóa hình ảnh trực quan của mình với bản phát lại bản ghi âm. Với API của GSAP, điều này dễ hơn nhiều so với bạn mong đợi.
Mã:
const SCRUB = (time = 0, trackTime = 0) => { gsap.to(timeline, { totalTime: time, onComplete: () => { AUDIO.currentTime = trackTime gsap.ticker.remove(REPORT) }, })}const UPDATE = e => { switch (e.type) { case 'play': timeline.totalTime(AUDIO.currentTime + START_POINT) timeline.play() gsap.ticker.add(REPORT) break case 'seeking': case 'seeked': timeline.totalTime(AUDIO.currentTime + START_POINT) break case 'pause': timeline.pause() break case 'ended': timeline.pause() SCRUB(START_POINT) break }}// Thiết lập chức năng xóa AUDIO['play', 'seeking', 'seeked', 'pause', 'ended'] .forEach(event => AUDIO.addEventListener(event, UPDATE))
Chúng tôi đã cấu trúc lại chức năng mà chúng tôi sử dụng khi dừng để xóa dòng thời gian. Và sau đó là trường hợp lắng nghe các sự kiện khác nhau trên phần tử . Mỗi sự kiện yêu cầu cập nhật đầu phát dòng thời gian. Chúng ta có thể thêm và xóa REPORT vào ticker dựa trên thời điểm chúng ta phát và dừng âm thanh. Nhưng điều này có một trường hợp ngoại lệ. Nếu bạn tìm kiếm sau khi âm thanh đã "kết thúc", hình ảnh trực quan sẽ không hiển thị các bản cập nhật. Và đó là vì chúng ta xóa REPORT khỏi ticker trong SCRUB. Bạn có thể chọn không xóa REPORT cho đến khi bản ghi mới bắt đầu hoặc bạn chuyển sang trạng thái khác trong ứng dụng của mình. Vấn đề là theo dõi hiệu suất và điều gì cảm thấy phù hợp.

Tuy nhiên, phần thú vị ở đây là nếu bạn thực hiện bản ghi, bạn có thể xóa hình ảnh trực quan khi bạn tìm kiếm 😎

Xem Bút [18. Đồng bộ hóa với Phát lại](https://codepen.io/smashingmag/pen/qBVzRaj) của Jhey.
Xem Bút 18. Đồng bộ hóa với Phát lại của Jhey.
Đến thời điểm này, bạn đã biết mọi thứ cần biết. Nhưng nếu bạn muốn tìm hiểu thêm một số điều, hãy tiếp tục đọc.

Phát lại âm thanh từ các nguồn khác​

Một điều chúng ta chưa xem xét là cách bạn hình dung âm thanh từ nguồn khác ngoài thiết bị đầu vào. Ví dụ: tệp mp3. Và điều này đưa ra một thách thức hoặc vấn đề thú vị để suy nghĩ.

Hãy xem xét một bản demo trong đó chúng ta có một URL tệp âm thanh và chúng ta muốn trực quan hóa nó bằng trực quan hóa của mình. Chúng ta có thể thiết lập rõ ràng src của phần tử AUDIO trước khi trực quan hóa.
Mã:
AUDIO.src = 'https://assets.codepen.io/605876/lobo-loco-spencer-bluegrass-blues.mp3'// LƯU Ý:: Điều này là bắt buộc trong một số trường hợp do CORSAUDIO.crossOrigin = 'anonymous'
Chúng ta không cần phải suy nghĩ về việc thiết lập máy ghi âm hoặc sử dụng các điều khiển để kích hoạt nó nữa. Vì chúng ta có một phần tử âm thanh, chúng ta có thể thiết lập trực quan hóa để móc vào nguồn trực tiếp.
Mã:
const ANALYSE = stream => { if (AUDIO_CONTEXT) return AUDIO_CONTEXT = new AudioContext() ANALYSER = AUDIO_CONTEXT.createAnalyser() ANALYSER.fftSize = CONFIG.fft const DATA_ARR = new Uint8Array(ANALYSER.frequencyBinCount) SOURCE = AUDIO_CONTEXT.createMediaElementSource(AUDIO) const GAIN_NODE = AUDIO_CONTEXT.createGain() GAIN_NODE.value = 0.5 GAIN_NODE.connect(AUDIO_CONTEXT.destination) SOURCE.connect(GAIN_NODE) SOURCE.connect(ANALYSER) // Đặt lại các thanh và đệm chúng ra... if (BARS && BARS.length > 0) { BARS.length = 0 padTimeline() } REPORT = () => { if (!AUDIO.paused || !played) { ANALYSER.getByteFrequencyData(DATA_ARR) const VOLUME = Math.floor((Math.max(...DATA_ARR) / 255) * 100) addBar(VOLUME) drawBars() } } gsap.ticker.add(REPORT)}
Bằng cách này, chúng ta có thể kết nối AudioContext với phần tử audio. Chúng ta thực hiện việc này bằng cách sử dụng createMediaElementSource(AUDIO) thay vì createMediaStreamSource(stream). Sau đó, các điều khiển của phần tử audio sẽ kích hoạt dữ liệu được truyền đến trình phân tích. Trên thực tế, chúng ta chỉ cần tạo AudioContext một lần. Bởi vì sau khi phát bản âm thanh, chúng ta sẽ không làm việc với bản âm thanh khác sau đó. Do đó, return nếu AUDIO_CONTEXT tồn tại.
Mã:
nếu (AUDIO_CONTEXT) return
Một điều khác cần lưu ý ở đây. Vì chúng ta đang kết nối phần tử audio với AudioContext, chúng ta cần tạo một nút gain. Nút gain này cho phép chúng ta nghe bản âm thanh.
Mã:
SOURCE = AUDIO_CONTEXT.createMediaElementSource(AUDIO)const GAIN_NODE = AUDIO_CONTEXT.createGain()GAIN_NODE.value = 0.5GAIN_NODE.connect(AUDIO_CONTEXT.destination)SOURCE.connect(GAIN_NODE)SOURCE.connect(ANALYSER)
Mọi thứ có thay đổi một chút trong cách chúng ta xử lý các sự kiện trên phần tử âm thanh. Trên thực tế, đối với ví dụ này, khi chúng ta hoàn thành bản nhạc âm thanh, chúng ta có thể xóa REPORT khỏi ticker. Nhưng chúng ta thêm drawBars vào ticker. Điều này có nghĩa là nếu chúng ta phát lại bản nhạc hoặc tìm kiếm, v.v., chúng ta không cần phải xử lý lại âm thanh. Điều này giống như cách chúng ta xử lý phát lại hình ảnh bằng máy ghi âm.

Bản cập nhật này diễn ra bên trong hàm SCRUB và bạn cũng có thể thấy một biến played mới. Chúng ta có thể sử dụng biến này để xác định xem chúng ta đã xử lý toàn bộ bản âm thanh hay chưa.
Mã:
const SCRUB = (time = 0, trackTime = 0) => { gsap.to(timeline, { totalTime: time, onComplete: () => { AUDIO.currentTime = trackTime if (!played) { played = true gsap.ticker.remove(REPORT) gsap.ticker.add(drawBars) } }, })}
Tại sao không thêm và xóa drawBars khỏi ticker dựa trên những gì chúng ta đang làm với phần tử âm thanh? Chúng ta có thể làm điều này. Chúng ta có thể xem gsap.ticker._listeners và xác định xem drawBars đã được sử dụng hay chưa. Chúng ta có thể chọn thêm và xóa khi phát và tạm dừng. Và sau đó, chúng ta cũng có thể thêm và xóa khi tìm kiếm và kết thúc tìm kiếm. Bí quyết là đảm bảo rằng chúng ta không thêm quá nhiều vào ticker khi "tìm kiếm". Và đây sẽ là nơi để kiểm tra xem drawBars đã là một phần của ticker chưa. Tất nhiên, điều này phụ thuộc vào hiệu suất. Liệu việc tối ưu hóa đó có đáng để tăng hiệu suất tối thiểu không? Điều đó phụ thuộc vào chính xác những gì ứng dụng của bạn cần làm. Đối với bản demo này, sau khi âm thanh được xử lý, chúng ta sẽ chuyển đổi hàm ticker. Đó là vì chúng ta không cần phải xử lý âm thanh một lần nữa. Và việc để drawBars chạy trong ticker không làm giảm hiệu suất.
Mã:
const UPDATE = e => { switch (e.type) { case 'play': if (!played) ANALYSE() timeline.totalTime(AUDIO.currentTime + START_POINT) timeline.play() break case 'seeking': case 'seeked': timeline.totalTime(AUDIO.currentTime + START_POINT) break case 'pause': timeline.pause() break case 'ended': timeline.pause() SCRUB(START_POINT) break }}
Câu lệnh switch của chúng ta cũng tương tự như vậy nhưng thay vào đó, chúng ta chỉ ANALYSE nếu chúng ta chưa phát bản nhạc.

Và điều này cung cấp cho chúng ta bản demo sau:

Xem Bút [19. [Xử lý tệp âm thanh](https://codepen.io/smashingmag/pen/rNYEjWe) của Jhey.
Xem Bút 19. Xử lý tệp âm thanh của Jhey.
Thử thách: Bạn có thể mở rộng bản demo này để hỗ trợ các bản nhạc khác nhau không? Hãy thử mở rộng bản demo để chấp nhận các bản nhạc khác nhau. Có thể người dùng có thể chọn từ danh sách thả xuống hoặc nhập URL.

Bản demo này dẫn đến một vấn đề thú vị phát sinh khi làm việc trên "Ghi âm cuộc gọi" cho Kent C. Dodds. Đây không phải là vấn đề tôi cần giải quyết trước đây. Trong bản demo ở trên, hãy bắt đầu phát âm thanh và tìm kiếm chuyển tiếp trong bản nhạc trước khi phát xong. Tìm kiếm chuyển tiếp sẽ phá vỡ hình ảnh hóa vì chúng ta đang bỏ qua trước thời hạn. Và điều đó có nghĩa là chúng ta đang bỏ qua việc xử lý một số phần nhất định của âm thanh.

Bạn có thể giải quyết vấn đề này như thế nào? Đây là một vấn đề thú vị. Bạn muốn xây dựng dòng thời gian hoạt ảnh trước khi phát âm thanh. Nhưng để xây dựng nó, trước tiên bạn cần phát qua âm thanh. Bạn có thể tắt "tìm kiếm" cho đến khi phát qua một lần không? Bạn có thể. Tại thời điểm này, bạn có thể bắt đầu trôi vào thế giới của trình phát âm thanh tùy chỉnh. Chắc chắn là nằm ngoài phạm vi của bài viết này. Trong một tình huống thực tế, bạn có thể đưa xử lý phía máy chủ vào vị trí. Điều này có thể cung cấp cho bạn một cách để lấy dữ liệu âm thanh trước thời hạn trước khi phát.

Đối với "Ghi âm cuộc gọi" của Kent, chúng ta có thể áp dụng một cách tiếp cận khác. Chúng ta đang xử lý âm thanh khi nó được ghi lại. Và mỗi thanh được biểu thị bằng một số. Nếu chúng ta tạo một Mảng số biểu diễn các thanh, chúng ta đã có dữ liệu để xây dựng hoạt ảnh. Khi bản ghi được gửi đi, dữ liệu có thể đi kèm. Sau đó, khi chúng ta yêu cầu âm thanh, chúng ta cũng có thể lấy dữ liệu đó và xây dựng hình ảnh trước khi phát lại.

Chúng ta có thể sử dụng hàm addBar đã định nghĩa trước đó trong khi lặp qua Mảng dữ liệu âm thanh.
Mã:
// Cho ví dụ về Mảng dữ liệu âm thanhconst AUDIO_DATA = [100, 85, 43, 12, 36, 0, 0, 0, 200, 220, 130]const buildViz = DATA => { DATA.forEach(bar => addBar(bar))}buildViz(AUDIO_DATA)
Việc xây dựng hình ảnh trực quan mà không cần xử lý lại âm thanh là một chiến thắng tuyệt vời về hiệu suất.

Hãy xem xét bản demo mở rộng này về bản demo ghi âm của chúng ta. Mỗi bản ghi âm được lưu trữ trong localStorage. Và chúng ta có thể tải bản ghi âm để phát. Tuy nhiên, thay vì xử lý âm thanh để phát, chúng tôi xây dựng một hoạt ảnh thanh mới và đặt phần tử âm thanh src.

Xem Bút [20. Bản ghi đã lưu ✨](https://codepen.io/smashingmag/pen/KKyjaaP) của Jhey.
Xem Bút 20. Bản ghi đã lưu ✨ của Jhey.
Những gì cần xảy ra ở đây để lưu trữ và phát lại bản ghi? Vâng, không cần nhiều vì chúng ta đã có phần lớn chức năng tại chỗ. Và vì chúng ta đã sắp xếp lại mọi thứ thành các hàm tiện ích nhỏ, điều này giúp mọi thứ dễ dàng hơn.

Chúng ta hãy bắt đầu với cách chúng ta sẽ lưu trữ các bản ghi trong localStorage. Khi tải trang, chúng ta sẽ hydrate một biến từ localStorage. Nếu không có gì để hydrate, chúng ta có thể khởi tạo biến với giá trị mặc định.
Mã:
const INITIAL_VALUE = { recordings: []}const KEY = 'recordings'const RECORDINGS = window.localStorage.getItem(KEY) ? JSON.parse(window.localStorage.getItem(KEY)) : INITIAL_VALUE
Bây giờ. Cần lưu ý rằng hướng dẫn này không phải về việc xây dựng một ứng dụng hoặc trải nghiệm được trau chuốt. Nó cung cấp cho bạn các công cụ bạn cần để bắt đầu và biến nó thành của riêng bạn. Tôi nói điều này vì một số UX, bạn có thể muốn đưa vào theo một cách khác.

Để lưu bản ghi, chúng ta có thể kích hoạt lưu trong phương thức ondataavailable mà chúng ta đã sử dụng.
Mã:
recorder.ondataavailable = (event) => { // Tất cả các mã xử lý khác // lưu bản ghi if (confirm('Save Recording?')) { saveRecording() }}
Quá trình lưu bản ghi yêu cầu một "mẹo" nhỏ. Chúng ta cần chuyển đổi AudioBlob của mình thành một String. Theo cách đó, chúng ta có thể lưu nó vào localStorage. Để thực hiện điều này, chúng ta sử dụng API FileReader để chuyển đổi AudioBlob thành URL dữ liệu. Khi đã có, chúng ta có thể tạo đối tượng ghi âm mới và lưu nó vào localStorage.
Mã:
const saveRecording = async () => { const reader = new FileReader() reader.onload = e => { const audioSafe = e.target.result const timestamp = new Date() RECORDINGS.recordings = [ ...RECORDINGS.recordings, { audioBlob: audioSafe, metadata: METADATA, name: timestamp.toUTCString(), id: timestamp.getTime(), }, ] window.localStorage.setItem(KEY, JSON.stringify(RECORDINGS)) renderRecordings() alert('Recording Saved') } await reader.readAsDataURL(AUDIO_BLOB)}
Bạn có thể tạo bất kỳ loại định dạng nào bạn thích ở đây. Để dễ dàng, tôi sử dụng thời gian làm id. Trường metadataArray mà chúng ta sử dụng để xây dựng hoạt ảnh của mình. Trường timestamp đang được sử dụng như một "tên". Nhưng bạn có thể làm gì đó như đặt tên dựa trên số lượng bản ghi. Sau đó, bạn có thể cập nhật UI để cho phép người dùng đổi tên bản ghi. Hoặc bạn thậm chí có thể thực hiện thông qua bước lưu với window.prompt.

Trên thực tế, bản demo này sử dụng UX window.prompt để bạn có thể thấy cách thức hoạt động của nó.

Xem Bút [21. Yêu cầu Tên Bản ghi 🚀](https://codepen.io/smashingmag/pen/oNorBwp) của Jhey.
Xem Bút 21. Yêu cầu ghi tên bản ghi 🚀 của Jhey.
Bạn có thể thắc mắc renderRecordings có tác dụng gì. Vâng, vì chúng ta không sử dụng một khuôn khổ, nên chúng ta cần tự cập nhật giao diện người dùng. Chúng ta gọi hàm này khi tải và mỗi lần chúng ta lưu hoặc xóa bản ghi.

Ý tưởng là nếu chúng ta có bản ghi, chúng ta lặp lại chúng và tạo các mục danh sách để thêm vào danh sách bản ghi của mình. Nếu chúng ta không có bất kỳ bản ghi nào, chúng ta sẽ hiển thị một thông báo cho người dùng.

Đối với mỗi bản ghi, chúng ta tạo hai nút. Một nút để phát bản ghi và một nút khác để xóa bản ghi.
Mã:
const renderRecordings = () => { RECORDINGS_LIST.innerHTML = '' if (RECORDINGS.recordings.length > 0) { RECORDINGS_MESSAGE.style.display = 'none' RECORDINGS.recordings.reverse().forEach(recording = > { const LI = document.createElement('li') LI.className = 'recordings__recording' LI.innerHTML = `${recording.name}` const BTN = document.createElement('button') BTN.className = 'recordings__play recordings__control' BTN.setAttribute('data-recording', recording.id) BTN.title = 'Phát bản ghi' BTN.innerHTML = SVGIconMarkup LI.appendChild(BTN) const DEL = document.createElement('button') DEL.setAttribute('data-recording', recording.id) DEL.className = 'recordings__delete recordings__control' DEL.title = 'Delete Recording' DEL.innerHTML = SVGIconMarkup LI.appendChild(DEL) BTN.addEventListener('click', playRecording) DEL.addEventListener('click', deleteRecording) RECORDINGS_LIST.appendChild(LI) }) } else { RECORDINGS_MESSAGE.style.display = 'block' }}
Phát bản ghi có nghĩa là đặt phần tử AUDIO src và tạo hình ảnh trực quan. Trước khi phát bản ghi hoặc khi xóa bản ghi, chúng tôi đặt lại trạng thái của UI bằng hàm reset.
Mã:
const reset = () => { AUDIO.src = null BARS.length = 0 gsap.ticker.remove(BÁO CÁO) BÁO CÁO = null timeline.clear() padTimeline() drawBars()}const playRecording = (e) => { const idToPlay = parseInt(e.currentTarget.getAttribute('data-recording'), 10) reset() const RECORDING = RECORDINGS.recordings.filter(recording => recording.id === idToPlay)[0] RECORDING.metadata.forEach(bar => addBar(bar)) REPORT = drawBars AUDIO.src = RECORDING.audioBlob AUDIO.play()}
Phương pháp phát lại và hiển thị hình ảnh thực tế chỉ gồm bốn dòng.
Mã:
RECORDING.metadata.forEach(bar => addBar(bar))REPORT = drawBarsAUDIO.src = RECORDING.audioBlobAUDIO.play()
  1. Lặp lại Mảng siêu dữ liệu để xây dựng timeline.
  2. Đặt hàm REPORT thành drawBars.
  3. Đặt src AUDIO.
  4. Phát âm thanh, sau đó kích hoạt timeline hoạt ảnh để phát.
Thách thức: Bạn có thể phát hiện ra bất kỳ trường hợp ngoại lệ nào trong UX không? Có vấn đề nào có thể phát sinh không? Nếu chúng ta đang ghi âm rồi chọn phát bản ghi âm thì sao? Chúng ta có thể tắt điều khiển khi đang ở chế độ ghi âm không?

Để xóa bản ghi âm, chúng ta sử dụng cùng phương thức reset nhưng chúng ta đặt giá trị mới trong localStorage cho bản ghi âm của mình. Sau khi thực hiện xong, chúng ta cần renderRecordings để hiển thị các bản cập nhật.
Mã:
const deleteRecording = (e) => { if (confirm('Xóa bản ghi?')) { const idToDelete = parseInt(e.currentTarget.getAttribute('data-recording'), 10) RECORDINGS.recordings = [...RECORDINGS.recordings.filter(recording => recording.id !== idToDelete)] window.localStorage.setItem(KEY, JSON.stringify(RECORDINGS)) reset() renderRecordings() }}
Ở giai đoạn này, chúng ta có một ứng dụng ghi âm giọng nói chức năng sử dụng localStorage. Đây là điểm khởi đầu thú vị mà bạn có thể sử dụng và thêm các tính năng mới cũng như cải thiện UX. Ví dụ, tại sao không tạo điều kiện cho người dùng tải xuống bản ghi âm của họ? Hoặc nếu những người dùng khác nhau có thể có các chủ đề khác nhau để trực quan hóa? Bạn có thể lưu trữ màu sắc, tốc độ, v.v. so với bản ghi âm. Sau đó, bạn sẽ cần cập nhật các thuộc tính của canvas và đáp ứng các thay đổi trong bản dựng dòng thời gian. Đối với “Ghi âm cuộc gọi”, chúng tôi hỗ trợ nhiều màu canvas khác nhau dựa trên nhóm mà người dùng tham gia.

Bản demo này hỗ trợ tải xuống các bản nhạc ở định dạng .ogg.

Xem Bút [22. Bản ghi có thể tải xuống 🚀](https://codepen.io/smashingmag/pen/bGYPgqJ) của Jhey.
Xem Bút 22. Bản ghi âm có thể tải xuống 🚀 của Jhey.
Nhưng bạn có thể sử dụng ứng dụng này theo nhiều hướng khác nhau. Sau đây là một số ý tưởng để bạn suy nghĩ:
  • Thay đổi giao diện ứng dụng với "giao diện và cảm nhận" khác
  • Hỗ trợ nhiều tốc độ phát lại khác nhau
  • Tạo nhiều kiểu trực quan hóa khác nhau. Ví dụ: bạn có thể ghi lại siêu dữ liệu cho trực quan hóa dạng sóng như thế nào?
  • Hiển thị số lượng bản ghi cho người dùng
  • Cải thiện UX bắt các trường hợp ngoại lệ như kịch bản ghi âm để phát lại từ trước đó.
  • Cho phép người dùng chọn thiết bị đầu vào âm thanh của họ
  • Chuyển trực quan hóa của bạn thành 3D với một thứ gì đó như ThreeJS
  • Giới hạn thời gian ghi. Điều này sẽ rất quan trọng trong một ứng dụng thực tế. Bạn sẽ muốn giới hạn kích thước dữ liệu được gửi đến máy chủ. Nó cũng sẽ buộc các bản ghi phải ngắn gọn.
  • Hiện tại, việc tải xuống chỉ hoạt động ở định dạng .ogg. Chúng tôi không thể mã hóa bản ghi thành mp3 trong trình duyệt. Nhưng bạn có thể sử dụng serverless với ffmpeg để chuyển đổi âm thanh thành .mp3 cho người dùng và trả về.

Biến điều này thành ứng dụng React​

Vâng. Nếu bạn đã đi đến đây, bạn đã có tất cả các nguyên tắc cơ bản cần thiết để bắt đầu và vui vẻ tạo ứng dụng ghi âm. Nhưng, tôi đã đề cập ở đầu bài viết, chúng tôi đã sử dụng React trong dự án. Vì bản demo của chúng tôi phức tạp hơn và chúng tôi đã giới thiệu "trạng thái", nên việc sử dụng một khuôn khổ là hợp lý. Chúng tôi sẽ không đi sâu vào việc xây dựng ứng dụng bằng React nhưng chúng tôi có thể đề cập đến cách tiếp cận. Nếu bạn mới làm quen với React, hãy xem "Hướng dẫn bắt đầu" này để biết cách thực hiện.

Vấn đề chính mà chúng tôi gặp phải khi chuyển sang React là suy nghĩ về cách chúng tôi chia nhỏ mọi thứ. Không có đúng hay sai. Và sau đó, điều đó dẫn đến vấn đề về cách chúng ta truyền dữ liệu qua các prop, v.v. Đối với ứng dụng này, điều đó không quá khó. Chúng ta có thể có một thành phần để trực quan hóa, phát lại âm thanh và ghi âm. Và sau đó, chúng ta có thể chọn gói tất cả chúng bên trong một thành phần cha.

Để truyền dữ liệu và truy cập các thứ trong DOM, React.useRef đóng vai trò quan trọng. Đây là "phiên bản" React của ứng dụng mà chúng tôi đã xây dựng.

Xem Bút [23. Đưa nó đến React Land 🚀](https://codepen.io/smashingmag/pen/ZEadLyW) của Jhey.
Xem Bút 23. Đưa nó đến React Land 🚀 của Jhey.
Như đã nêu trước đó, có nhiều cách khác nhau để đạt được cùng một mục tiêu và chúng tôi sẽ không đào sâu vào mọi thứ. Tuy nhiên, chúng tôi có thể nêu bật một số quyết định mà bạn có thể phải đưa ra hoặc suy nghĩ.

Về cơ bản, logic chức năng vẫn giữ nguyên. Tuy nhiên, chúng ta có thể sử dụng tham chiếu để theo dõi một số thứ nhất định. Và thường thì chúng ta cần truyền các tham chiếu này trong props cho các thành phần khác nhau.
Mã:
return (      )
Ví dụ, hãy xem xét cách chúng ta truyền timeline xung quanh trong một prop. Đây là ref cho timeline của GreenSock.
Mã:
const timeline = React.useRef(gsap.timeline())
Và điều này là do một số thành phần cần truy cập vào dòng thời gian trực quan hóa. Nhưng chúng ta có thể tiếp cận theo cách khác. Giải pháp thay thế là truyền xử lý sự kiện dưới dạng prop và có quyền truy cập vào timeline trong phạm vi. Mỗi cách đều hiệu quả. Nhưng mỗi cách đều có sự đánh đổi.

Vì chúng ta đang làm việc trong vùng "React", chúng ta có thể chuyển một số mã của mình sang "Reactive". Tôi đoán manh mối nằm ở tên. 😅 Ví dụ, thay vì cố gắng đệm dòng thời gian và vẽ các thứ từ phần tử cha. Chúng ta có thể làm cho thành phần canvas phản ứng với các thay đổi âm thanh src. Bằng cách sử dụng React.useEffect, chúng ta có thể xây dựng lại dòng thời gian dựa trên siêu dữ liệu có sẵn:
Mã:
React.useEffect(() => { barsRef.current.length = 0 padTimeline() drawRef.current = DRAW DRAW() if (src === null) { metadata.current.length = 0 } else if (src && metadata.current.length) { metadata.current.forEach(bar => addBar(bar)) gsap.ticker.add(drawRef.current) }}, [src])
Phần cuối cùng cần đề cập đến là cách chúng ta lưu trữ bản ghi vào localStorage bằng React. Đối với điều này, chúng ta đang sử dụng một hook tùy chỉnh mà chúng ta đã xây dựng trước đó trong hướng dẫn "Bắt đầu".
Mã:
const usePersistentState = (key, initialValue) => { const [state, setState] = React.useState( window.localStorage.getItem(key) ? JSON.parse(window.localStorage.getItem(key)) : initialValue ) React.useEffect(() => { // Stringify để chúng ta có thể đọc lại window.localStorage.setItem(key, JSON.stringify(state)) }, [key, state]) return [state, setState]}
Điều này thật tuyệt vì chúng ta có thể sử dụng nó giống như React.useState và chúng ta được trừu tượng hóa khỏi logic lưu trữ.
Mã:
// Xóa bản ghisetRecordings({ recordings: [ ...recordings.filter(recording => recording.id !== idToDelete), ],})// Lưu bản ghiconst audioSafe = e.target.resultconst timestamp = new Date()const name = prompt('Recording name?')setRecordings({ recordings: [ ...recordings, { audioBlob: audioSafe, metadata: metadata.current, name: name || timestamp.toUTCString(), id: timestamp.getTime(), }, ],})
Tôi khuyên bạn nên tìm hiểu một số mã React và thử nghiệm nếu bạn quan tâm. Một số thứ hoạt động hơi khác một chút trong React. Bạn có thể mở rộng ứng dụng và làm cho trình trực quan hỗ trợ các hiệu ứng trực quan khác nhau không? Ví dụ, tại sao không truyền màu qua props cho kiểu tô?

Thế là xong!​

Ồ. Bạn đã đến cuối rồi! Đây là một bài dài.

Những gì bắt đầu như một nghiên cứu điển hình đã trở thành hướng dẫn trực quan hóa âm thanh bằng JavaScript. Chúng tôi đã đề cập rất nhiều ở đây. Nhưng bây giờ bạn đã có những kiến thức cơ bản để tiến hành và tạo hình ảnh âm thanh như tôi đã làm với Kent.

Cuối cùng nhưng không kém phần quan trọng, đây là một hình ảnh trực quan về dạng sóng bằng cách sử dụng @react-three/fiber:

Xem Bút [24. Đến với 3D React Land 🚀](https://codepen.io/smashingmag/pen/oNoredR) của Jhey.
Xem Bút 24. Sắp đến 3D React Land 🚀 của Jhey.
Đó là ReactJS, ThreeJS và GreenSock cùng nhau làm việc! 💪

Có rất nhiều thứ để khám phá với ứng dụng này. Tôi rất muốn xem bạn đưa ứng dụng demo đến đâu hoặc bạn có thể làm gì với nó!

Như thường lệ, nếu bạn có bất kỳ câu hỏi nào, bạn biết tìm tôi ở đâu.

Luôn tuyệt vời! ʕ •ᴥ•ʔ

P.S. Có một Bộ sưu tập CodePen chứa tất cả các bản demo được thấy trong các bài viết cùng với một số bản demo thưởng. 🚀

Đọc thêm​

  • Giới thiệu về khả năng kết hợp toàn bộ ngăn xếp
  • Tích hợp: Từ truyền dữ liệu đơn giản đến kiến trúc kết hợp 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
  • AI trên thiết bị: Xây dựng các ứng dụng thông minh hơn, nhanh hơn và riêng tư hơn
 
Back
Bên trên