FLAC encoder now tracks frame numbers and reuses per-channel sample buffers to reduce allocations. Client dialer always frees the active slot on release so mDNS re-emissions can reconnect cleanly. Server send buffer uses drop-oldest instead of hard failure, with lateness tracking. Added engine stats logging (chunks/sec, kbps) and writer diagnostics. UI simplified to a single play/pause toggle that auto-starts playback on preset selection. Added --preset CLI flag for headless startup. Removed stale systemd service files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
156 lines
4.5 KiB
Go
156 lines
4.5 KiB
Go
// ABOUTME: FLAC audio encoder for lossless streaming
|
|
// ABOUTME: Wraps mewkiz/flac to encode PCM int32 samples to FLAC frames
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
|
|
"github.com/mewkiz/flac"
|
|
"github.com/mewkiz/flac/frame"
|
|
"github.com/mewkiz/flac/meta"
|
|
)
|
|
|
|
// FLACEncoder wraps a mewkiz/flac encoder for real-time FLAC frame
|
|
// generation. Each Encode call produces one FLAC frame from a block
|
|
// of interleaved int32 PCM samples. Prediction analysis is enabled
|
|
// so the encoder picks optimal Fixed/LPC prediction per block for
|
|
// real compression (not just verbatim framing).
|
|
type FLACEncoder struct {
|
|
encoder *flac.Encoder
|
|
buf *bytes.Buffer
|
|
codecHeader []byte
|
|
sampleRate int
|
|
channels int
|
|
bitDepth int
|
|
blockSize int
|
|
frameNum uint64
|
|
chanBufs [][]int32 // reused per-channel sample buffers
|
|
}
|
|
|
|
// NewFLACEncoder creates a FLAC encoder with prediction analysis enabled.
|
|
// blockSize is samples per channel per frame (e.g., 960 for 20ms at 48kHz).
|
|
func NewFLACEncoder(sampleRate, channels, bitDepth, blockSize int) (*FLACEncoder, error) {
|
|
buf := &bytes.Buffer{}
|
|
|
|
info := &meta.StreamInfo{
|
|
BlockSizeMin: uint16(blockSize),
|
|
BlockSizeMax: uint16(blockSize),
|
|
SampleRate: uint32(sampleRate),
|
|
NChannels: uint8(channels),
|
|
BitsPerSample: uint8(bitDepth),
|
|
}
|
|
|
|
enc, err := flac.NewEncoder(buf, info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create FLAC encoder: %w", err)
|
|
}
|
|
|
|
// Prediction analysis is enabled by default in mewkiz/flac v1.0.13.
|
|
// Explicit call for clarity: WriteFrame will analyze subframes marked
|
|
// PredVerbatim and pick the optimal prediction method (Constant/Fixed).
|
|
enc.EnablePredictionAnalysis(true)
|
|
|
|
// The encoder writes fLaC + STREAMINFO to the buffer immediately.
|
|
codecHeader := make([]byte, buf.Len())
|
|
copy(codecHeader, buf.Bytes())
|
|
buf.Reset()
|
|
|
|
chanBufs := make([][]int32, channels)
|
|
for ch := range chanBufs {
|
|
chanBufs[ch] = make([]int32, blockSize)
|
|
}
|
|
|
|
return &FLACEncoder{
|
|
encoder: enc,
|
|
buf: buf,
|
|
codecHeader: codecHeader,
|
|
sampleRate: sampleRate,
|
|
channels: channels,
|
|
bitDepth: bitDepth,
|
|
blockSize: blockSize,
|
|
chanBufs: chanBufs,
|
|
}, nil
|
|
}
|
|
|
|
// CodecHeader returns the FLAC codec header (fLaC + STREAMINFO metadata
|
|
// block). Base64-encode this for stream/start messages.
|
|
func (e *FLACEncoder) CodecHeader() []byte {
|
|
return e.codecHeader
|
|
}
|
|
|
|
// Encode converts a block of interleaved int32 PCM samples into a FLAC
|
|
// frame. len(samples) must equal blockSize * channels.
|
|
func (e *FLACEncoder) Encode(samples []int32) ([]byte, error) {
|
|
expected := e.blockSize * e.channels
|
|
if len(samples) != expected {
|
|
return nil, fmt.Errorf("expected %d samples (%d x %d), got %d",
|
|
expected, e.blockSize, e.channels, len(samples))
|
|
}
|
|
|
|
// Sendspin's AudioSource contract delivers int32 samples right-justified
|
|
// to 24 bits (see encodePCM / convertToInt16 in audio_engine.go). When
|
|
// the encoder's configured bit depth differs (e.g., 16-bit FLAC for an
|
|
// ESP32 that doesn't advertise 24-bit), down-shift so values fit the
|
|
// target range — otherwise the encoder emits frames with out-of-range
|
|
// samples that decoders reject, the client wedges, and TCP back-pressure
|
|
// stalls the server's WebSocket writer.
|
|
shift := uint(0)
|
|
if e.bitDepth < 24 {
|
|
shift = uint(24 - e.bitDepth)
|
|
}
|
|
|
|
// De-interleave into pre-allocated per-channel buffers.
|
|
subframes := make([]*frame.Subframe, e.channels)
|
|
for ch := 0; ch < e.channels; ch++ {
|
|
buf := e.chanBufs[ch]
|
|
for i := 0; i < e.blockSize; i++ {
|
|
buf[i] = samples[i*e.channels+ch] >> shift
|
|
}
|
|
subframes[ch] = &frame.Subframe{
|
|
SubHeader: frame.SubHeader{
|
|
Pred: frame.PredVerbatim,
|
|
},
|
|
Samples: buf,
|
|
NSamples: e.blockSize,
|
|
}
|
|
}
|
|
|
|
var channelAssign frame.Channels
|
|
switch e.channels {
|
|
case 1:
|
|
channelAssign = frame.ChannelsMono
|
|
case 2:
|
|
channelAssign = frame.ChannelsLR
|
|
default:
|
|
channelAssign = frame.Channels(e.channels)
|
|
}
|
|
|
|
f := &frame.Frame{
|
|
Header: frame.Header{
|
|
HasFixedBlockSize: true,
|
|
BlockSize: uint16(e.blockSize),
|
|
SampleRate: uint32(e.sampleRate),
|
|
Channels: channelAssign,
|
|
BitsPerSample: uint8(e.bitDepth),
|
|
Num: e.frameNum,
|
|
},
|
|
Subframes: subframes,
|
|
}
|
|
e.frameNum++
|
|
|
|
e.buf.Reset()
|
|
if err := e.encoder.WriteFrame(f); err != nil {
|
|
return nil, fmt.Errorf("FLAC encode failed: %w", err)
|
|
}
|
|
|
|
result := make([]byte, e.buf.Len())
|
|
copy(result, e.buf.Bytes())
|
|
return result, nil
|
|
}
|
|
|
|
// Close finalizes the encoder.
|
|
func (e *FLACEncoder) Close() error {
|
|
return e.encoder.Close()
|
|
}
|