feat: add lossless flac to wav converter
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mewkiz/flac"
|
||||
"github.com/mewkiz/flac/frame"
|
||||
"github.com/mewkiz/flac/meta"
|
||||
)
|
||||
|
||||
func testSamples(nChannels, bitsPerSample, nSamples int) [][]int32 {
|
||||
max := int32(1)<<(bitsPerSample-1) - 1
|
||||
min := -int32(1) << (bitsPerSample - 1)
|
||||
|
||||
channels := make([][]int32, nChannels)
|
||||
for c := range channels {
|
||||
samples := make([]int32, nSamples)
|
||||
for i := range samples {
|
||||
switch i {
|
||||
case 0:
|
||||
samples[i] = min
|
||||
case 1:
|
||||
samples[i] = max
|
||||
case 2:
|
||||
samples[i] = 0
|
||||
default:
|
||||
samples[i] = int32(i*(c+1)) % max
|
||||
if i%3 == 0 {
|
||||
samples[i] = -samples[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
channels[c] = samples
|
||||
}
|
||||
|
||||
return channels
|
||||
}
|
||||
|
||||
func writeTestFLAC(t *testing.T, path string, sampleRate uint32, bitsPerSample uint8, channels [][]int32) {
|
||||
t.Helper()
|
||||
|
||||
nSamples := len(channels[0])
|
||||
info := &meta.StreamInfo{
|
||||
BlockSizeMin: uint16(nSamples),
|
||||
BlockSizeMax: uint16(nSamples),
|
||||
SampleRate: sampleRate,
|
||||
NChannels: uint8(len(channels)),
|
||||
BitsPerSample: bitsPerSample,
|
||||
NSamples: uint64(nSamples),
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create flac: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
enc, err := flac.NewEncoder(file, info)
|
||||
if err != nil {
|
||||
t.Fatalf("new encoder: %v", err)
|
||||
}
|
||||
|
||||
subframes := make([]*frame.Subframe, len(channels))
|
||||
for c, samples := range channels {
|
||||
subframes[c] = &frame.Subframe{
|
||||
SubHeader: frame.SubHeader{Pred: frame.PredVerbatim},
|
||||
Samples: samples,
|
||||
NSamples: nSamples,
|
||||
}
|
||||
}
|
||||
|
||||
channelsLayout := frame.ChannelsMono
|
||||
if len(channels) == 2 {
|
||||
channelsLayout = frame.ChannelsLR
|
||||
}
|
||||
|
||||
f := &frame.Frame{
|
||||
Header: frame.Header{
|
||||
HasFixedBlockSize: true,
|
||||
BlockSize: uint16(nSamples),
|
||||
SampleRate: sampleRate,
|
||||
Channels: channelsLayout,
|
||||
BitsPerSample: bitsPerSample,
|
||||
},
|
||||
Subframes: subframes,
|
||||
}
|
||||
|
||||
if err := enc.WriteFrame(f); err != nil {
|
||||
t.Fatalf("write frame: %v", err)
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
t.Fatalf("close encoder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectedPCM(channels [][]int32, bytesPerSample int) []byte {
|
||||
var buf bytes.Buffer
|
||||
|
||||
for i := range channels[0] {
|
||||
for _, samples := range channels {
|
||||
sample := samples[i]
|
||||
switch bytesPerSample {
|
||||
case 1:
|
||||
buf.WriteByte(byte(sample + 128))
|
||||
case 2:
|
||||
buf.Write([]byte{byte(sample), byte(sample >> 8)})
|
||||
case 3:
|
||||
buf.Write([]byte{byte(sample), byte(sample >> 8), byte(sample >> 16)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestFLACToWAV(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sampleRate uint32
|
||||
bitsPerSample uint8
|
||||
nChannels int
|
||||
nSamples int
|
||||
}{
|
||||
{"16 bit stereo", 44100, 16, 2, 512},
|
||||
{"16 bit mono", 44100, 16, 1, 512},
|
||||
{"24 bit stereo", 48000, 24, 2, 333},
|
||||
{"8 bit mono", 22050, 8, 1, 128},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "in.flac")
|
||||
dst := filepath.Join(dir, "out.wav")
|
||||
|
||||
channels := testSamples(tt.nChannels, int(tt.bitsPerSample), tt.nSamples)
|
||||
writeTestFLAC(t, src, tt.sampleRate, tt.bitsPerSample, channels)
|
||||
|
||||
if err := FLACToWAV(context.Background(), src, dst); err != nil {
|
||||
t.Fatalf("FLACToWAV() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read wav: %v", err)
|
||||
}
|
||||
|
||||
bytesPerSample := int(tt.bitsPerSample) / 8
|
||||
blockAlign := uint16(tt.nChannels * bytesPerSample)
|
||||
want := expectedPCM(channels, bytesPerSample)
|
||||
dataSize := uint32(len(want))
|
||||
pad := dataSize % 2
|
||||
|
||||
if len(got) != headerSize+len(want)+int(pad) {
|
||||
t.Fatalf("file size = %d, want %d", len(got), headerSize+len(want)+int(pad))
|
||||
}
|
||||
if string(got[0:4]) != "RIFF" || string(got[8:12]) != "WAVE" {
|
||||
t.Errorf("magic = %q %q, want \"RIFF\" \"WAVE\"", got[0:4], got[8:12])
|
||||
}
|
||||
if size := binary.LittleEndian.Uint32(got[4:8]); size != headerSize-8+dataSize+pad {
|
||||
t.Errorf("riff size = %d, want %d", size, headerSize-8+dataSize+pad)
|
||||
}
|
||||
if string(got[12:16]) != "fmt " {
|
||||
t.Errorf("fmt chunk id = %q, want \"fmt \"", got[12:16])
|
||||
}
|
||||
if size := binary.LittleEndian.Uint32(got[16:20]); size != 16 {
|
||||
t.Errorf("fmt chunk size = %d, want 16", size)
|
||||
}
|
||||
if format := binary.LittleEndian.Uint16(got[20:22]); format != formatPCM {
|
||||
t.Errorf("format = %d, want %d", format, formatPCM)
|
||||
}
|
||||
if n := binary.LittleEndian.Uint16(got[22:24]); n != uint16(tt.nChannels) {
|
||||
t.Errorf("channels = %d, want %d", n, tt.nChannels)
|
||||
}
|
||||
if rate := binary.LittleEndian.Uint32(got[24:28]); rate != tt.sampleRate {
|
||||
t.Errorf("sample rate = %d, want %d", rate, tt.sampleRate)
|
||||
}
|
||||
if rate := binary.LittleEndian.Uint32(got[28:32]); rate != tt.sampleRate*uint32(blockAlign) {
|
||||
t.Errorf("byte rate = %d, want %d", rate, tt.sampleRate*uint32(blockAlign))
|
||||
}
|
||||
if align := binary.LittleEndian.Uint16(got[32:34]); align != blockAlign {
|
||||
t.Errorf("block align = %d, want %d", align, blockAlign)
|
||||
}
|
||||
if bits := binary.LittleEndian.Uint16(got[34:36]); bits != uint16(tt.bitsPerSample) {
|
||||
t.Errorf("bits per sample = %d, want %d", bits, tt.bitsPerSample)
|
||||
}
|
||||
if string(got[36:40]) != "data" {
|
||||
t.Errorf("data chunk id = %q, want \"data\"", got[36:40])
|
||||
}
|
||||
if size := binary.LittleEndian.Uint32(got[40:44]); size != dataSize {
|
||||
t.Errorf("data chunk size = %d, want %d", size, dataSize)
|
||||
}
|
||||
if !bytes.Equal(got[headerSize:headerSize+len(want)], want) {
|
||||
t.Error("pcm payload does not match the source samples")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFLACToWAVErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
notFLAC := filepath.Join(dir, "not.flac")
|
||||
if err := os.WriteFile(notFLAC, []byte("this is not a flac file"), 0644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
valid := filepath.Join(dir, "valid.flac")
|
||||
writeTestFLAC(t, valid, 44100, 16, testSamples(2, 16, 64))
|
||||
data, err := os.ReadFile(valid)
|
||||
if err != nil {
|
||||
t.Fatalf("read flac: %v", err)
|
||||
}
|
||||
truncated := filepath.Join(dir, "truncated.flac")
|
||||
if err := os.WriteFile(truncated, data[:len(data)/2], 0644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"not a flac file", notFLAC},
|
||||
{"missing file", filepath.Join(dir, "missing.flac")},
|
||||
{"truncated file", truncated},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dst := filepath.Join(t.TempDir(), "out.wav")
|
||||
if err := FLACToWAV(context.Background(), tt.path, dst); err == nil {
|
||||
t.Error("FLACToWAV() error = nil, want error")
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
t.Error("FLACToWAV() left an output file behind")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFLACToWAVCanceled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "in.flac")
|
||||
dst := filepath.Join(dir, "out.wav")
|
||||
writeTestFLAC(t, src, 44100, 16, testSamples(2, 16, 512))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if err := FLACToWAV(ctx, src, dst); err == nil {
|
||||
t.Error("FLACToWAV() error = nil, want context.Canceled")
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
t.Error("FLACToWAV() left an output file behind")
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, ".godeez-*.part"))
|
||||
if len(matches) > 0 {
|
||||
t.Errorf("FLACToWAV() left %d part files behind", len(matches))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesPerSample(t *testing.T) {
|
||||
tests := []struct {
|
||||
bitsPerSample uint8
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{8, 1, false},
|
||||
{16, 2, false},
|
||||
{24, 3, false},
|
||||
{4, 0, true},
|
||||
{12, 0, true},
|
||||
{20, 0, true},
|
||||
{32, 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := bytesPerSample(tt.bitsPerSample)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("bytesPerSample(%d) error = %v, wantErr %v", tt.bitsPerSample, err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("bytesPerSample(%d) = %d, want %d", tt.bitsPerSample, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user