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
This commit is contained in:
2026-03-26 12:06:41 +01:00
parent ed3a5621f4
commit f221963551
+66
View File
@@ -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)
}