From f2219635516b09d4d224be0dfdb596c9f4a87510 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 12:06:41 +0100 Subject: [PATCH] feat(02-03): implement MP3 encoder package with RunSynthesis orchestrator - EncodeMP3: writes stereo PCM frames to MP3 via go-lame (44100 Hz, 128 kbps, stereo) - RunSynthesis: renders WindowSnapshots through OscillatorBank then encodes to MP3 - Zero-packet guard before file creation returns descriptive error (OUT-03) - InitParams called after all Set* calls per go-lame requirements --- encode/mp3.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 encode/mp3.go diff --git a/encode/mp3.go b/encode/mp3.go new file mode 100644 index 0000000..534c45e --- /dev/null +++ b/encode/mp3.go @@ -0,0 +1,66 @@ +package encode + +import ( + "fmt" + "os" + + lame "github.com/sjzar/go-lame" + + "github.com/netsynth/netsynth/classify" + "github.com/netsynth/netsynth/synth" +) + +// EncodeMP3 writes stereo PCM frames to an MP3 file at the given path. +// Encoding parameters: sampleRate Hz, 128 kbps, stereo, quality 5. +// Per D-13 (44100 Hz CD quality) and D-14 (128 kbps bitrate). +// CRITICAL: InitParams() is called after all Set* calls (go-lame Pitfall 1). +func EncodeMP3(outputPath string, frames [][2]float64, sampleRate int) error { + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create output file: %w", err) + } + defer f.Close() + + wr := lame.NewWriter(f) + wr.Encoder.SetInSamplerate(sampleRate) + wr.Encoder.SetOutSamplerate(sampleRate) + wr.Encoder.SetNumChannels(2) + wr.Encoder.SetBitrate(128) + wr.Encoder.SetQuality(5) + wr.Encoder.InitParams() // MUST be called after all Set* calls + + pcmBytes := synth.StereoFramesToInt16Bytes(frames) + if _, err := wr.Write(pcmBytes); err != nil { + return fmt.Errorf("write PCM to LAME encoder: %w", err) + } + if err := wr.Close(); err != nil { + return fmt.Errorf("finalize MP3: %w", err) + } + return nil +} + +// RunSynthesis consumes a slice of WindowSnapshots, renders audio via OscillatorBank, +// and encodes to MP3 at outputPath. +// Returns an error if zero packets were captured (D-16 / OUT-03). +// The zero-packet guard runs BEFORE file creation to avoid leaving an empty file on disk. +func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error { + // D-16 / OUT-03: Zero-packet guard — check BEFORE creating output file + var totalPackets int64 + for _, snap := range snapshots { + totalPackets += snap.TotalPackets + } + if totalPackets == 0 { + return fmt.Errorf("no packets captured: output MP3 not written (empty capture produces no audio)") + } + + // Render all windows to stereo frames + bank := synth.NewBank(1.0) // tau=1.0s per D-07 + var allFrames [][2]float64 + for _, snap := range snapshots { + frames := bank.RenderWindow(snap) + allFrames = append(allFrames, frames...) + } + + // Encode to MP3 + return EncodeMP3(outputPath, allFrames, synth.SampleRate) +}