Codec âm thanh ULC: Lời giải cho âm thanh trên N64

Công nghệ05 tháng 11, 2026·7 phút đọc

Một codec âm thanh ít người biết đến có tên ULC đang chứng minh sự phù hợp bất ngờ với Nintendo 64: nó xử lý âm thanh stereo 44100 Hz với bitrate gần bằng Opus mà chỉ tốn khoảng 4% thời gian CPU của máy. Nhờ vậy, các tựa game homebrew như bản port Half-Life cho N64 có thể nhét trọn 2.500 hiệu ứng âm thanh và 27 bản nhạc vào một băng ROM 64MB.

Codec âm thanh ULC: Lời giải cho âm thanh trên N64
Ảnh minh hoạ do AI tạo

Trên Nintendo 64, việc lưu trữ và phát âm thanh từng là một cơn ác mộng. Băng ROM đắt đỏ và dung lượng hạn chế, còn CPU 93 MHz thì không có nhiều thời gian rảnh. Một codec tên là ULC (Ultra Low Complexity Codec) đang cho thấy nó có thể thay đổi cuộc chơi: âm thanh stereo 44100 Hz với bitrate gần bằng Opus, mà chỉ chiếm khoảng 4% thời gian CPU và 5% thời gian của RSP.

Vì sao âm thanh trên N64 lại khó?

PlayStation có thể phát trực tiếp âm thanh redbook không nén từ đĩa CD. N64 thì không có đặc quyền đó. Tệ hơn, quyền truy cập vào bộ đồng xử lý vector RSP bị Nintendo kiểm soát chặt trong một thời gian dài. Thư viện nền tảng Libultra của Nintendo có dùng RSP cho đồ họa và âm thanh, nhưng lập trình viên bên thứ ba không được trực tiếp viết code cho nó.

Libultra cung cấp vài hàm phát lại âm thanh nén bằng sơ đồ ADPCM (adaptive differential pulse code modulation). RSP giải nén ngay lúc chạy, trước khi âm thanh được đưa vào bộ đệm đầu ra. Cách này giảm dung lượng mẫu xuống 4 lần, nhưng một bản nhạc stereo 5 phút ở 44100 Hz vẫn chiếm khoảng 12MB – quá lớn với băng ROM N64, kể cả khi giảm một nửa tần số lấy mẫu. Toàn bộ ROM của Mario 64 chỉ vỏn vẹn 8MB, còn những game N64 lớn nhất cũng chỉ 64MB.

Vì thế, các game N64 thường tạo nhạc theo kiểu tracker: các mẫu âm ngắn được lên lịch và phát lại với vài hiệu ứng hạn chế. Âm thanh "thu sẵn" hoàn chỉnh gần như không khả thi.

Về sau, một số nhà phát triển may mắn được Nintendo cho truy cập trực tiếp RSP. Nổi tiếng nhất là Conker's Bad Fur Day, dùng RSP để giải nén MP3 cho vô số mẫu giọng nói. Nhưng giải mã MP3 khá đắt, khiến tốc độ khung hình của game bị ảnh hưởng.

Thư viện mã nguồn mở Libdragon cũng có bộ giải mã ADPCM thời gian thực chạy trên RSP. Với Xibalba 64, tác giả nén toàn bộ nhạc bằng ADPCM: khoảng 31MB trong ROM 32MB dành cho âm thanh và nhạc, dù game chỉ có 15 phút nhạc. Libdragon sau đó có thêm bộ giải mã Opus tăng tốc bằng RSP do Giovanni Bajo thực hiện – chất lượng và mức nén tuyệt vời, nhưng vẫn quá chậm để dùng trong lúc chơi.

ULC – "MP3 không rườm rà"

Những lựa chọn thay thế đều có vấn đề: MP3 tăng tốc bằng RSP chỉ cải thiện hiệu năng không đáng kể so với Opus mà chất lượng lại kém hơn; QOA dù tốt hơn ADPCM một chút nhưng khó vector hóa vì mỗi mẫu phụ thuộc vào mẫu trước đó.

Bước ngoặt đến từ một bản demo Game Boy Advance phát nhạc thu sẵn chỉ với 70kbps, chất lượng tương đương MP3 nhưng chạy trên CPU ARM 16 MHz không có lệnh vector. Người tạo ra nó, Aikku93, không chỉ viết bộ giải mã bằng hợp ngữ ARM mà còn tạo ra một codec hoàn toàn mới: ULC.

ULC là codec miền biến đổi, tương tự MP3 ở ý tưởng cốt lõi. Nó dùng MDCT (Modified Discrete Cosine Transform) để chuyển các lát PCM sang miền tần số. Nhưng khác MP3, ULC không chia tín hiệu thành nhiều băng – MDCT của nó là "toàn băng". Và thay vì mã hóa entropy kiểu Huffman, ULC lưu hệ số đã biến đổi bằng một cú pháp đơn giản dựa trên nibble (4 bit).

Điểm tinh tế nhất của ULC nằm ở cách đóng gói này. Mỗi hệ số chỉ chiếm 4 bit, hiểu như số nguyên có dấu. Một số giá trị được dành riêng để báo hiệu zero fill (làm đầy bằng 0), noise fill (làm đầy bằng nhiễu) hoặc thay đổi bộ lượng tử hóa. Mọi thứ căn theo nibble, nghĩa là toàn bộ codec không cần đọc bit tùy ý hay giải mã entropy. Đọc và giải nén một khối hệ số chỉ tốn khoảng 150 dòng C.

Aikku93 phát triển ULC từ năm 2019 và liên tục cải thiện chất lượng mã hóa. Toàn bộ mã nguồn dùng giấy phép mở.

Đưa ULC lên N64

Tác giả bài viết đã thử biên dịch bộ giải mã tham chiếu ULC cho N64 và cho ra một demo nhỏ. Nó chạy trơn tru, nhưng ngốn CPU rất nhiều vì chưa có gì được đẩy sang RSP. Để dùng cho nhạc nền và lời thoại trong lúc chơi, phần IMDCT (MDCT nghịch đảo) buộc phải chuyển sang bộ đồng xử lý.

Đó không phải việc dễ. RSP chỉ có 4096 byte DMEM cho dữ liệu và 4096 byte IMEM cho lệnh, hoạt động với số nguyên cố định 16 bit hoặc 32 bit, không có số thực. Với kích thước khối mặc định 2048 mẫu của ULC, dữ liệu không đủ chỗ nếu dùng 32 bit. Chọn 16 bit thì tiết kiệm DMA và tăng tốc tính toán, đổi lại nền nhiễu cao hơn – sau khi refactor sang số cố định 16 bit (Q1.14) và thử nghiệm, tác giả kết luận mức nhiễu đó không đáng kể.

Việc port sang hợp ngữ MIPS cho RSP được thực hiện theo hướng lặp: dùng RSPL, một ngôn ngữ giống C do Max Bebök tạo riêng cho RSP, rồi nhờ Codex chuyển từng phần từ C sang RSPL và kiểm tra kết quả. Sau đó là các bước tối ưu tiếp, gồm viết lại một số đoạn trực tiếp bằng hợp ngữ và giảm pipeline stall. Kết quả là khoảng 1400 dòng hợp ngữ MIPS xử lý IMDCT, lapping, xen kẽ stereo và chia tỉ lệ đầu ra, vừa khít 4096 byte IMEM.

Để tua nhanh dễ dàng, tác giả thêm một bảng thưa ở cuối file chứa vị trí byte của mỗi khung thứ tám, tốn thêm khoảng 20 byte mỗi giây.

Kết quả thực tế

ULC được chứng minh là lựa chọn thay thế rất khả thi cho ADPCM và Opus trên N64:

  • Chất lượng và bitrate gần với Opus, nhưng độ phức tạp chỉ gấp 4 lần ADPCM
  • Một bản nhạc stereo 44100 Hz tốn khoảng 4% thời gian CPU và 5% thời gian RSP
  • Đủ tốt cho nhạc nền, lời thoại, âm thanh môi trường và hiệu ứng dài
  • Hoạt động minh bạch trong Libdragon như mọi codec âm thanh khác, đã được merge vào nhánh preview

Mới đây, một bản port Half-Life cho N64 được công bố và ghi rõ trong README rằng chính codec ULC của Libdragon, dùng cho 2.500 âm thanh và 27 bản nhạc, là thứ giúp mọi thứ vừa vặn trong một ảnh ROM 64MB.

Góc nhìn

ULC là ví dụ điển hình cho việc tối ưu không phải lúc nào cũng nằm ở thuật toán phức tạp hơn. Đôi khi chỉ cần bỏ đi những lớp rườm rà không cần thiết và đóng gói dữ liệu thật gọn, hiệu quả có thể tăng vọt. Với những kỹ sư làm việc trên hệ thống nhúng, thiết bị IoT hoặc phần cứng đời cũ có giới hạn bộ nhớ và chu kỳ CPU, đây là bài học đáng tham khảo: hãy đo lường kỹ yêu cầu thực tế trước khi mặc định chọn codec hay thư viện "hiện đại".

Quá trình port cũng cho thấy cách dùng công cụ hỗ trợ sinh mã một cách có kiểm soát – chia nhỏ công việc, xác minh từng bước và tối ưu thủ công ở những chỗ then chốt – hiệu quả hơn nhiều so với giao trọn bài toán cho mô hình lớn. Với các dự án cần độ tin cậy cao, đây vẫn là cách tiếp cận an toàn và thực tế.

Nguồn: phoboslab.org · Quy trình biên tập

Chia sẻ:FacebookX