feat: add lossless flac to wav converter

This commit is contained in:
Mathis Maquenne
2026-08-05 22:51:43 +02:00
parent eea049aae9
commit b3777bf8eb
5 changed files with 496 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
package audio
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"github.com/mathismqn/godeez/internal/fsutil"
"github.com/mewkiz/flac"
)
const (
headerSize = 44
formatPCM = 1
ctxCheckInterval = 64
maxDataSize = math.MaxUint32 - (headerSize - 8)
)
func FLACToWAV(ctx context.Context, srcPath, dstPath string) error {
stream, err := flac.Open(srcPath)
if err != nil {
return err
}
defer stream.Close()
info := stream.Info
bytesPerSample, err := bytesPerSample(info.BitsPerSample)
if err != nil {
return err
}
if info.NChannels < 1 || info.NChannels > 2 {
return fmt.Errorf("unsupported channel count: %d", info.NChannels)
}
if size := int64(info.NSamples) * int64(info.NChannels) * int64(bytesPerSample); size > maxDataSize {
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", size)
}
file, err := os.CreateTemp(filepath.Dir(dstPath), fsutil.PartPattern)
if err != nil {
return err
}
tmpPath := file.Name()
done := false
defer func() {
if !done {
file.Close()
os.Remove(tmpPath)
}
}()
w := bufio.NewWriter(file)
if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil {
return err
}
dataSize, err := writeSamples(ctx, w, stream, int(info.NChannels), bytesPerSample)
if err != nil {
return err
}
if dataSize > maxDataSize {
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", dataSize)
}
if dataSize%2 != 0 {
if err := w.WriteByte(0); err != nil {
return err
}
}
if err := w.Flush(); err != nil {
return err
}
if err := patchSizes(file, dataSize); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return err
}
done = true
return nil
}
func bytesPerSample(bitsPerSample uint8) (int, error) {
switch bitsPerSample {
case 8, 16, 24:
return int(bitsPerSample) / 8, nil
default:
return 0, fmt.Errorf("unsupported bit depth: %d", bitsPerSample)
}
}
func writeHeader(w io.Writer, sampleRate uint32, nChannels, bitsPerSample uint8, dataSize uint32) error {
blockAlign := uint32(nChannels) * uint32(bitsPerSample) / 8
header := make([]byte, 0, headerSize)
header = append(header, "RIFF"...)
header = binary.LittleEndian.AppendUint32(header, uint32(headerSize-8)+dataSize)
header = append(header, "WAVE"...)
header = append(header, "fmt "...)
header = binary.LittleEndian.AppendUint32(header, 16)
header = binary.LittleEndian.AppendUint16(header, formatPCM)
header = binary.LittleEndian.AppendUint16(header, uint16(nChannels))
header = binary.LittleEndian.AppendUint32(header, sampleRate)
header = binary.LittleEndian.AppendUint32(header, sampleRate*blockAlign)
header = binary.LittleEndian.AppendUint16(header, uint16(blockAlign))
header = binary.LittleEndian.AppendUint16(header, uint16(bitsPerSample))
header = append(header, "data"...)
header = binary.LittleEndian.AppendUint32(header, dataSize)
_, err := w.Write(header)
return err
}
func writeSamples(ctx context.Context, w io.Writer, stream *flac.Stream, nChannels, bytesPerSample int) (int64, error) {
var dataSize int64
buf := make([]byte, 4)
for i := 0; ; i++ {
if i%ctxCheckInterval == 0 {
select {
case <-ctx.Done():
return dataSize, ctx.Err()
default:
}
}
frame, err := stream.ParseNext()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return dataSize, err
}
if len(frame.Subframes) != nChannels {
return dataSize, fmt.Errorf("frame %d has %d channels, want %d", frame.Num, len(frame.Subframes), nChannels)
}
for i := range frame.Subframes[0].Samples {
for _, subframe := range frame.Subframes {
putSample(buf, subframe.Samples[i], bytesPerSample)
if _, err := w.Write(buf[:bytesPerSample]); err != nil {
return dataSize, err
}
dataSize += int64(bytesPerSample)
}
}
}
return dataSize, nil
}
func putSample(buf []byte, sample int32, bytesPerSample int) {
if bytesPerSample == 1 {
buf[0] = byte(sample + 128)
return
}
value := uint32(sample)
for i := range bytesPerSample {
buf[i] = byte(value >> (8 * i))
}
}
func patchSizes(file *os.File, dataSize int64) error {
buf := make([]byte, 4)
binary.LittleEndian.PutUint32(buf, uint32(headerSize-8+dataSize+dataSize%2))
if _, err := file.WriteAt(buf, 4); err != nil {
return err
}
binary.LittleEndian.PutUint32(buf, uint32(dataSize))
_, err := file.WriteAt(buf, headerSize-4)
return err
}
+291
View File
@@ -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)
}
}
}
+2
View File
@@ -5,6 +5,8 @@ import (
"os"
)
const PartPattern = ".godeez-*.part"
func EnsureDir(path string) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {