first commit

This commit is contained in:
2026-06-04 18:10:52 +07:00
commit b5c083e06f
105 changed files with 8172 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
package transcode
import (
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"github.com/olivier-w/climp-aac-decoder/aacfile"
)
func decodeAACPath(path, ext string) ([]float64, int, int, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, 0, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, 0, 0, err
}
// aacfile picks the parser from the *name* extension, not file content (.mp4 → .m4a).
name := aacOpenName(path, ext)
size := st.Size()
r, err := aacfile.Open(f, size, name)
if err != nil && isMP4SampleDeltaError(err) {
return decodeMP4AACRelaxed(f, size)
}
if err != nil {
return nil, 0, 0, err
}
defer r.Close()
sr := r.SampleRate()
ch := r.ChannelCount()
pcm, err := io.ReadAll(r)
if err != nil {
return nil, 0, 0, fmt.Errorf("read aac pcm: %w", err)
}
samples := pcm16LEToFloat(pcm, ch)
if ch > 1 {
samples = interleavedToMono(samples, ch)
ch = 1
}
return samples, sr, ch, nil
}
// aacContainerExt maps file extensions to a container name understood by aacfile.
func aacContainerExt(ext string) string {
switch strings.ToLower(ext) {
case ".mp4", ".m4v", ".mov", ".3gp", ".3g2":
return ".m4a"
case ".aac", ".m4a", ".m4b":
return ext
default:
return ".m4a"
}
}
func aacOpenName(path, ext string) string {
containerExt := aacContainerExt(ext)
if containerExt == "" {
containerExt = ".m4a"
}
base := filepath.Base(path)
if e := strings.ToLower(filepath.Ext(base)); e == containerExt {
return base
}
stem := strings.TrimSuffix(base, filepath.Ext(base))
if stem == "" || stem == base {
stem = "audio"
}
return stem + containerExt
}
func pcm16LEToFloat(pcm []byte, channels int) []float64 {
if channels <= 0 {
channels = 1
}
frameBytes := 2 * channels
nFrames := len(pcm) / frameBytes
out := make([]float64, nFrames*channels)
for i := 0; i < nFrames*channels; i++ {
off := i * 2
if off+1 >= len(pcm) {
break
}
v := int16(pcm[off]) | int16(pcm[off+1])<<8
out[i] = float64(v) / 32768.0
}
return out
}
func interleavedToMono(samples []float64, channels int) []float64 {
if channels <= 1 {
return samples
}
nFrames := len(samples) / channels
out := make([]float64, nFrames)
for i := 0; i < nFrames; i++ {
var sum float64
for c := 0; c < channels; c++ {
sum += samples[i*channels+c]
}
out[i] = sum / float64(channels)
}
return out
}
func resampleLinear(samples []float64, fromRate, toRate int) []float64 {
if fromRate <= 0 || toRate <= 0 || fromRate == toRate || len(samples) == 0 {
return samples
}
ratio := float64(fromRate) / float64(toRate)
outLen := int(math.Round(float64(len(samples)) / ratio))
if outLen < 1 {
outLen = 1
}
out := make([]float64, outLen)
for i := 0; i < outLen; i++ {
src := float64(i) * ratio
j := int(src)
if j >= len(samples)-1 {
out[i] = samples[len(samples)-1]
continue
}
frac := src - float64(j)
out[i] = samples[j]*(1-frac) + samples[j+1]*frac
}
return out
}
+31
View File
@@ -0,0 +1,31 @@
package transcode
import "testing"
func TestAacOpenName_mp4(t *testing.T) {
got := aacOpenName("/tmp/cache/input.mp4", ".mp4")
if got != "input.m4a" {
t.Fatalf("got %q want input.m4a", got)
}
}
func TestAacOpenName_noExt(t *testing.T) {
got := aacOpenName("/tmp/input", ".m4a")
if got != "audio.m4a" {
t.Fatalf("got %q", got)
}
}
func TestAacContainerExt(t *testing.T) {
cases := map[string]string{
".mp4": ".m4a",
".mov": ".m4a",
".aac": ".aac",
".m4a": ".m4a",
}
for in, want := range cases {
if got := aacContainerExt(in); got != want {
t.Fatalf("%s: got %q want %q", in, got, want)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package transcode
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/gopxl/beep"
"github.com/gopxl/beep/flac"
"github.com/gopxl/beep/mp3"
beepwav "github.com/gopxl/beep/wav"
)
var probeFormats = []string{
".wav", ".wave", ".mp3", ".flac", ".ogg", ".opus",
".m4a", ".m4b", ".mp4", ".mov", ".m4v", ".3gp", ".3g2", ".aac",
}
func supportedFormatsMessage() string {
return strings.Join(probeFormats, ", ")
}
func resolveFormat(path string) (string, error) {
ext := strings.ToLower(filepath.Ext(path))
if ext != "" {
return ext, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
ext = sniffFormat(f)
if ext == "" {
return "", fmt.Errorf("could not detect audio format (supported: %s)", supportedFormatsMessage())
}
return ext, nil
}
func openDecoder(path string) (beep.Streamer, beep.Format, io.Closer, error) {
ext, err := resolveFormat(path)
if err != nil {
return nil, beep.Format{}, nil, err
}
streamer, format, closer, err := decodeByExt(path, ext)
if err == nil {
return streamer, format, closer, nil
}
for _, try := range probeFormats {
if try == ext {
continue
}
streamer, format, closer, tryErr := decodeByExt(path, try)
if tryErr == nil {
return streamer, format, closer, nil
}
}
return nil, beep.Format{}, nil, fmt.Errorf("unsupported audio format %q (supported: %s): %w", ext, supportedFormatsMessage(), err)
}
func decodeByExt(path, ext string) (beep.Streamer, beep.Format, io.Closer, error) {
switch ext {
case ".wav", ".wave":
return decodeBeepFile(path, ext)
case ".mp3":
return decodeBeepFile(path, ext)
case ".flac":
return decodeBeepFile(path, ext)
case ".ogg", ".opus":
return decodeOggFile(path)
case ".m4a", ".m4b", ".mp4", ".mov", ".m4v", ".aac":
return decodeAACAsStreamer(path, ext)
case ".webm":
return nil, beep.Format{}, nil, fmt.Errorf("webm is not supported yet")
default:
return nil, beep.Format{}, nil, fmt.Errorf("unsupported extension %q", ext)
}
}
func decodeBeepFile(path, ext string) (beep.Streamer, beep.Format, io.Closer, error) {
f, err := os.Open(path)
if err != nil {
return nil, beep.Format{}, nil, err
}
var (
streamer beep.StreamSeekCloser
format beep.Format
decErr error
)
switch ext {
case ".wav", ".wave":
streamer, format, decErr = beepwav.Decode(f)
case ".mp3":
streamer, format, decErr = mp3.Decode(f)
case ".flac":
streamer, format, decErr = flac.Decode(f)
default:
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("internal: beep decode for %q", ext)
}
if decErr != nil {
f.Close()
return nil, beep.Format{}, nil, decErr
}
return streamer, format, f, nil
}
func decodeAACAsStreamer(path, ext string) (beep.Streamer, beep.Format, io.Closer, error) {
samples, sr, ch, err := decodeAACPath(path, ext)
if err != nil {
return nil, beep.Format{}, nil, err
}
if ch <= 0 {
ch = 1
}
return newSamplesStreamer(samples, sr), beep.Format{
SampleRate: beep.SampleRate(sr),
NumChannels: ch,
Precision: 2,
}, noopCloser{}, nil
}
type noopCloser struct{}
func (noopCloser) Close() error { return nil }
+119
View File
@@ -0,0 +1,119 @@
package transcode
import (
"context"
"fmt"
"os"
)
// Engine converts input audio to PCM WAV using pure Go decoders (no ffmpeg).
type Engine struct{}
// NewEngine creates a transcoder. The ffmpegPath argument is ignored (kept for config compatibility).
func NewEngine(_ string) *Engine {
return &Engine{}
}
func (e *Engine) Available() error {
return nil
}
func (e *Engine) Transcode(ctx context.Context, src, dst string, opt Options) error {
if err := opt.Validate(); err != nil {
return err
}
spec, err := ResolveFormat(opt.Format)
if err != nil {
return err
}
dst, err = OutputPath(dst, spec.ID)
if err != nil {
return err
}
streamer, format, closer, err := openDecoder(src)
if err != nil {
return err
}
defer closer.Close()
s, format := buildPipeline(streamer, format, opt)
samples, err := drainSamples(ctx, s)
if err != nil {
return err
}
ch := format.NumChannels
if opt.Channels > 0 {
ch = opt.Channels
}
sr := int(format.SampleRate)
if opt.SampleRate > 0 {
sr = opt.SampleRate
}
if err := writePCM16WAV(dst, sr, ch, samples); err != nil {
return fmt.Errorf("write wav: %w", err)
}
return nil
}
func Transcode(ctx context.Context, src, dst string, opt Options) error {
return NewEngine("").Transcode(ctx, src, dst, opt)
}
func ToWhisperWAV(ctx context.Context, src, dst string) error {
return Transcode(ctx, src, dst, WhisperOptions())
}
// SupportedInputFormats lists file extensions decoded without external tools.
func SupportedInputFormats() []string {
return append([]string(nil), probeFormats...)
}
func (e *Engine) Probe(ctx context.Context, path string) (MediaInfo, error) {
_ = ctx
ext, err := resolveFormat(path)
if err != nil {
return MediaInfo{}, err
}
streamer, format, closer, err := openDecoder(path)
if err != nil {
return MediaInfo{}, err
}
defer closer.Close()
info := MediaInfo{
Format: ext,
Streams: []StreamInfo{{
Index: 0,
Codec: extTrim(ext),
Type: "audio",
SampleRate: int(format.SampleRate),
Channels: format.NumChannels,
}},
}
if st, err := os.Stat(path); err == nil {
info.BitRate = st.Size() * 8
}
_ = streamer
return info, nil
}
func extTrim(ext string) string {
if len(ext) > 0 && ext[0] == '.' {
return ext[1:]
}
return ext
}
// MediaInfo describes decoded input (for optional diagnostics).
type MediaInfo struct {
Format string `json:"format"`
Duration float64 `json:"duration_seconds"`
BitRate int64 `json:"bit_rate"`
Streams []StreamInfo `json:"streams"`
}
type StreamInfo struct {
Index int `json:"index"`
Codec string `json:"codec"`
Type string `json:"type"`
SampleRate int `json:"sample_rate,omitempty"`
Channels int `json:"channels,omitempty"`
}
+85
View File
@@ -0,0 +1,85 @@
package transcode
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/go-audio/wav"
)
func samplePath(t *testing.T, name string) string {
t.Helper()
p := filepath.Join("..", "third_party", "whisper.cpp", "samples", name)
if _, err := os.Stat(p); err != nil {
t.Skip("sample not found:", p)
}
return p
}
func TestToWhisperWAV_mp3(t *testing.T) {
src := samplePath(t, "jfk.mp3")
dst := filepath.Join(t.TempDir(), "out.wav")
if err := ToWhisperWAV(context.Background(), src, dst); err != nil {
t.Fatal(err)
}
assertWhisperWAV(t, dst)
}
func TestToWhisperWAV_wav(t *testing.T) {
src := samplePath(t, "jfk.wav")
dst := filepath.Join(t.TempDir(), "out.wav")
if err := ToWhisperWAV(context.Background(), src, dst); err != nil {
t.Fatal(err)
}
assertWhisperWAV(t, dst)
}
func TestResolveFormat_noExtension_mp3(t *testing.T) {
src := samplePath(t, "jfk.mp3")
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "upload")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
ext, err := resolveFormat(path)
if err != nil || ext != ".mp3" {
t.Fatalf("ext=%q err=%v", ext, err)
}
}
func TestEngine_Available(t *testing.T) {
if err := NewEngine("").Available(); err != nil {
t.Fatal(err)
}
}
func assertWhisperWAV(t *testing.T, path string) {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
dec := wav.NewDecoder(f)
if !dec.IsValidFile() {
t.Fatal("invalid wav")
}
buf, err := dec.FullPCMBuffer()
if err != nil {
t.Fatal(err)
}
if dec.SampleRate != 16000 {
t.Fatalf("sample rate %d", dec.SampleRate)
}
if dec.NumChans != 1 {
t.Fatalf("channels %d", dec.NumChans)
}
if len(buf.Data) == 0 {
t.Fatal("empty audio")
}
}
+47
View File
@@ -0,0 +1,47 @@
package transcode
import (
"fmt"
"path/filepath"
"strings"
)
const (
FormatWAV = "wav"
FormatPCM = "pcm"
)
type FormatSpec struct {
ID string
Extension string
Codec string
}
var formats = map[string]FormatSpec{
FormatWAV: {ID: FormatWAV, Extension: ".wav", Codec: "pcm_s16le"},
FormatPCM: {ID: FormatPCM, Extension: ".wav", Codec: "pcm_s16le"},
}
func ResolveFormat(name string) (FormatSpec, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
name = FormatWAV
}
spec, ok := formats[name]
if !ok {
return FormatSpec{}, fmt.Errorf("unsupported format %q (supported: wav)", name)
}
return spec, nil
}
func OutputPath(dst, format string) (string, error) {
spec, err := ResolveFormat(format)
if err != nil {
return "", err
}
ext := filepath.Ext(dst)
if ext == "" {
return dst + spec.Extension, nil
}
return dst, nil
}
+288
View File
@@ -0,0 +1,288 @@
package transcode
import (
"errors"
"fmt"
"io"
"github.com/Eyevinn/mp4ff/mp4"
"github.com/olivier-w/climp-aac-decoder/aacfile"
aacdec "github.com/skrashevich/go-aac/pkg/decoder"
)
type mp4AACSample struct {
offset int64
size int
}
func isMP4SampleDeltaError(err error) bool {
var uf *aacfile.UnsupportedFeatureError
if !errors.As(err, &uf) {
return false
}
return uf.Feature == "MP4 sample delta" || uf.Feature == "MP4 sample delta layout"
}
// decodeMP4AACRelaxed demuxes MP4/M4A with mp4ff (ignoring stts sample deltas) and
// decodes raw AAC frames with go-aac. Used when climp-aac-decoder rejects stts
// entries whose delta is not exactly 1024 (common in ffmpeg/phone muxers).
func decodeMP4AACRelaxed(r io.ReaderAt, size int64) ([]float64, int, int, error) {
asc, samples, leading, err := demuxMP4AAC(r, size)
if err != nil {
return nil, 0, 0, err
}
dec := aacdec.New()
if err := dec.SetASC(asc); err != nil {
return nil, 0, 0, fmt.Errorf("aac config: %w", err)
}
ch := dec.Config.ChanConfig
if ch < 1 {
return nil, 0, 0, fmt.Errorf("aac config: invalid channel count %d", ch)
}
sr := dec.Config.SampleRate
if sr <= 0 {
return nil, 0, 0, fmt.Errorf("aac config: invalid sample rate %d", sr)
}
maxSize := 0
for _, s := range samples {
if s.size > maxSize {
maxSize = s.size
}
}
buf := make([]byte, maxSize)
var pcm []float32
for i, s := range samples {
if cap(buf) < s.size {
buf = make([]byte, s.size)
}
frame := buf[:s.size]
if _, err := r.ReadAt(frame, s.offset); err != nil {
return nil, 0, 0, fmt.Errorf("read mp4 aac sample %d: %w", i, err)
}
out, err := dec.DecodeFrame(frame)
if err != nil {
return nil, 0, 0, fmt.Errorf("decode mp4 aac sample %d: %w", i, err)
}
pcm = append(pcm, out...)
}
skipSamples := leading * ch
if skipSamples > len(pcm) {
skipSamples = len(pcm)
}
pcm = pcm[skipSamples:]
samplesF64 := make([]float64, len(pcm))
for i, v := range pcm {
samplesF64[i] = float64(v)
}
if ch > 1 {
samplesF64 = float32InterleavedToMono(samplesF64, ch)
ch = 1
}
return samplesF64, sr, ch, nil
}
func float32InterleavedToMono(samples []float64, channels int) []float64 {
if channels <= 1 {
return samples
}
nFrames := len(samples) / channels
out := make([]float64, nFrames)
for i := 0; i < nFrames; i++ {
var sum float64
for c := 0; c < channels; c++ {
sum += samples[i*channels+c]
}
out[i] = sum / float64(channels)
}
return out
}
func demuxMP4AAC(r io.ReaderAt, size int64) (asc []byte, samples []mp4AACSample, leading int, err error) {
file, err := mp4.DecodeFile(io.NewSectionReader(r, 0, size), mp4.WithDecodeMode(mp4.DecModeLazyMdat))
if err != nil {
return nil, nil, 0, fmt.Errorf("mp4 decode: %w", err)
}
if file.IsFragmented() {
return nil, nil, 0, fmt.Errorf("fragmented mp4 is not supported")
}
if file.Moov == nil {
return nil, nil, 0, fmt.Errorf("mp4: missing moov")
}
var audioTracks []*mp4.TrakBox
for _, trak := range file.Moov.Traks {
if trak != nil && trak.Mdia != nil && trak.Mdia.Hdlr != nil && trak.Mdia.Hdlr.HandlerType == "soun" {
audioTracks = append(audioTracks, trak)
}
}
if len(audioTracks) != 1 {
return nil, nil, 0, fmt.Errorf("mp4: expected one audio track, found %d", len(audioTracks))
}
trak := audioTracks[0]
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil || trak.Mdia.Minf.Stbl.Stsd == nil {
return nil, nil, 0, fmt.Errorf("mp4: incomplete audio track")
}
stsd := trak.Mdia.Minf.Stbl.Stsd
if len(stsd.Children) != 1 {
return nil, nil, 0, fmt.Errorf("mp4: multiple sample descriptions")
}
if stsd.Enca != nil {
return nil, nil, 0, fmt.Errorf("mp4: encrypted audio")
}
sampleEntry := stsd.Mp4a
if sampleEntry == nil {
return nil, nil, 0, fmt.Errorf("mp4: unsupported audio sample entry %s", stsd.Children[0].Type())
}
if sampleEntry.Sinf != nil {
return nil, nil, 0, fmt.Errorf("mp4: encrypted audio")
}
if sampleEntry.Esds == nil ||
sampleEntry.Esds.DecConfigDescriptor == nil ||
sampleEntry.Esds.DecConfigDescriptor.DecSpecificInfo == nil ||
len(sampleEntry.Esds.DecConfigDescriptor.DecSpecificInfo.DecConfig) == 0 {
return nil, nil, 0, fmt.Errorf("mp4: missing AudioSpecificConfig")
}
asc = append([]byte(nil), sampleEntry.Esds.DecConfigDescriptor.DecSpecificInfo.DecConfig...)
leading, _ = mp4LeadingTrimRelaxed(trak)
samples, err = buildMP4AACSamples(trak, size)
if err != nil {
return nil, nil, 0, err
}
if len(samples) == 0 {
return nil, nil, 0, fmt.Errorf("mp4: no audio samples")
}
return asc, samples, leading, nil
}
func mp4LeadingTrimRelaxed(trak *mp4.TrakBox) (int, error) {
if trak.Edts == nil || len(trak.Edts.Elst) == 0 {
return 0, nil
}
if len(trak.Edts.Elst) != 1 || len(trak.Edts.Elst[0].Entries) != 1 {
return 0, nil
}
entry := trak.Edts.Elst[0].Entries[0]
if entry.MediaRateInteger != 1 || entry.MediaRateFraction != 0 {
return 0, nil
}
if entry.MediaTime < 0 {
return 0, nil
}
return int(entry.MediaTime), nil
}
func buildMP4AACSamples(trak *mp4.TrakBox, size int64) ([]mp4AACSample, error) {
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
return nil, fmt.Errorf("mp4: incomplete sample table")
}
stbl := trak.Mdia.Minf.Stbl
if stbl.Stsc == nil || stbl.Stsz == nil {
return nil, fmt.Errorf("mp4: incomplete sample table")
}
if stbl.Stco == nil && stbl.Co64 == nil {
return nil, fmt.Errorf("mp4: missing chunk offsets")
}
if len(stbl.Stsc.Entries) == 0 {
return nil, fmt.Errorf("mp4: empty chunk map")
}
totalSamples := int(trak.GetNrSamples())
if totalSamples <= 0 {
return nil, fmt.Errorf("mp4: empty sample table")
}
sampleSizes, err := mp4AACSampleSizes(stbl.Stsz, totalSamples)
if err != nil {
return nil, err
}
chunkOffsets, err := mp4AACChunkOffsets(stbl)
if err != nil {
return nil, err
}
out := make([]mp4AACSample, 0, totalSamples)
sampleIndex := 0
entryIndex := 0
entry := stbl.Stsc.Entries[entryIndex]
for chunkIndex := 0; chunkIndex < len(chunkOffsets) && sampleIndex < totalSamples; chunkIndex++ {
chunkNr := uint32(chunkIndex + 1)
for entryIndex+1 < len(stbl.Stsc.Entries) && chunkNr >= stbl.Stsc.Entries[entryIndex+1].FirstChunk {
entryIndex++
entry = stbl.Stsc.Entries[entryIndex]
}
if entry.SamplesPerChunk == 0 {
return nil, fmt.Errorf("mp4: zero samples per chunk")
}
offset := chunkOffsets[chunkIndex]
samplesPerChunk := int(entry.SamplesPerChunk)
for i := 0; i < samplesPerChunk && sampleIndex < totalSamples; i++ {
sampleSize := sampleSizes[sampleIndex]
end := offset + int64(sampleSize)
if offset < 0 || end < offset || end > size {
return nil, fmt.Errorf("mp4: invalid sample bounds at sample %d", sampleIndex+1)
}
out = append(out, mp4AACSample{offset: offset, size: sampleSize})
offset = end
sampleIndex++
}
}
if sampleIndex != totalSamples {
return nil, fmt.Errorf("mp4: sample table mismatch")
}
return out, nil
}
func mp4AACSampleSizes(stsz *mp4.StszBox, totalSamples int) ([]int, error) {
if stsz == nil {
return nil, fmt.Errorf("mp4: missing sample sizes")
}
if int(stsz.GetNrSamples()) != totalSamples {
return nil, fmt.Errorf("mp4: sample size count mismatch")
}
sizes := make([]int, totalSamples)
if stsz.SampleUniformSize != 0 {
sz := int(stsz.SampleUniformSize)
for i := range sizes {
sizes[i] = sz
}
return sizes, nil
}
if len(stsz.SampleSize) != totalSamples {
return nil, fmt.Errorf("mp4: sample size table mismatch")
}
for i, sz := range stsz.SampleSize {
sizes[i] = int(sz)
}
return sizes, nil
}
func mp4AACChunkOffsets(stbl *mp4.StblBox) ([]int64, error) {
switch {
case stbl == nil:
return nil, fmt.Errorf("mp4: incomplete sample table")
case stbl.Stco != nil:
offsets := make([]int64, len(stbl.Stco.ChunkOffset))
for i, off := range stbl.Stco.ChunkOffset {
offsets[i] = int64(off)
}
return offsets, nil
case stbl.Co64 != nil:
offsets := make([]int64, len(stbl.Co64.ChunkOffset))
for i, off := range stbl.Co64.ChunkOffset {
if off > uint64(^uint64(0)>>1) {
return nil, fmt.Errorf("mp4: invalid chunk offset")
}
offsets[i] = int64(off)
}
return offsets, nil
default:
return nil, fmt.Errorf("mp4: missing chunk offsets")
}
}
+154
View File
@@ -0,0 +1,154 @@
package transcode
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/gopxl/beep"
"github.com/gopxl/beep/vorbis"
"github.com/pion/opus"
"github.com/pion/opus/pkg/oggreader"
)
func decodeOggFile(path string) (beep.Streamer, beep.Format, io.Closer, error) {
switch sniffOggCodec(path) {
case "opus":
return decodeOggOpus(path)
case "vorbis":
return decodeOggVorbis(path)
default:
streamer, format, closer, err := decodeOggVorbis(path)
if err == nil {
return streamer, format, closer, nil
}
if isVorbisInvalidHeader(err) {
return decodeOggOpus(path)
}
return nil, beep.Format{}, nil, err
}
}
func sniffOggCodec(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
buf := make([]byte, 8192)
n, _ := io.ReadFull(f, buf)
buf = buf[:n]
if len(buf) < 4 || !bytes.HasPrefix(buf, []byte("OggS")) {
return ""
}
if bytes.Contains(buf, []byte("OpusHead")) {
return "opus"
}
// Vorbis ID packet: 0x01 + "vorbis"
if bytes.Contains(buf, []byte{0x01, 'v', 'o', 'r', 'b', 'i', 's'}) {
return "vorbis"
}
return ""
}
func isVorbisInvalidHeader(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "invalid header") || strings.Contains(msg, "vorbis:")
}
func decodeOggVorbis(path string) (beep.Streamer, beep.Format, io.Closer, error) {
f, err := os.Open(path)
if err != nil {
return nil, beep.Format{}, nil, err
}
streamer, format, decErr := vorbis.Decode(f)
if decErr != nil {
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("ogg/vorbis: %w", decErr)
}
return streamer, format, f, nil
}
func decodeOggOpus(path string) (beep.Streamer, beep.Format, io.Closer, error) {
f, err := os.Open(path)
if err != nil {
return nil, beep.Format{}, nil, err
}
ogg, header, err := oggreader.NewWith(f)
if err != nil {
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("ogg/opus: %w", err)
}
sr := int(header.SampleRate)
if sr <= 0 {
sr = 48000
}
ch := int(header.Channels)
if ch <= 0 {
ch = 1
}
dec, err := opus.NewDecoderWithOutput(sr, ch)
if err != nil {
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("ogg/opus decoder: %w", err)
}
const maxFrameSamples = 5760
pcmBuf := make([]float32, maxFrameSamples*ch)
var samples []float64
for {
pkt, _, err := ogg.ParseNextPacket()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("ogg/opus read: %w", err)
}
if len(pkt) == 0 || bytes.HasPrefix(pkt, []byte("OpusHead")) || bytes.HasPrefix(pkt, []byte("OpusTags")) {
continue
}
n, err := dec.DecodeToFloat32(pkt, pcmBuf)
if err != nil {
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("ogg/opus decode: %w", err)
}
if n <= 0 {
continue
}
total := n * ch
if total > len(pcmBuf) {
total = len(pcmBuf)
}
for i := 0; i < total; i++ {
samples = append(samples, float64(pcmBuf[i]))
}
}
if len(samples) == 0 {
f.Close()
return nil, beep.Format{}, nil, fmt.Errorf("ogg/opus: no audio samples")
}
outCh := ch
if outCh > 1 {
samples = interleavedToMono(samples, outCh)
outCh = 1
}
return newSamplesStreamer(samples, sr), beep.Format{
SampleRate: beep.SampleRate(sr),
NumChannels: outCh,
Precision: 2,
}, f, nil
}
+52
View File
@@ -0,0 +1,52 @@
package transcode
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestSniffOggCodec_opus(t *testing.T) {
path := pionTinyOggPath(t)
if got := sniffOggCodec(path); got != "opus" {
t.Fatalf("sniffOggCodec() = %q want opus", got)
}
}
func TestDecodeOggOpus_pionTiny(t *testing.T) {
path := pionTinyOggPath(t)
streamer, format, closer, err := decodeOggOpus(path)
if err != nil {
t.Fatal(err)
}
defer closer.Close()
if format.SampleRate == 0 {
t.Fatal("zero sample rate")
}
buf := make([][2]float64, 4096)
n, ok := streamer.Stream(buf)
if !ok || n == 0 {
t.Fatal("expected pcm samples")
}
}
func pionTinyOggPath(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
modRoot := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
cache := os.Getenv("GOMODCACHE")
if cache == "" {
home, _ := os.UserHomeDir()
cache = filepath.Join(home, "go", "pkg", "mod")
}
path := filepath.Join(cache, "github.com/pion/opus@v0.0.0-20260601214817-71d58474cec8/testdata/tiny.ogg")
if _, err := os.Stat(path); err != nil {
_ = modRoot
t.Skipf("pion testdata not in module cache: %v", err)
}
return path
}
+43
View File
@@ -0,0 +1,43 @@
package transcode
import "fmt"
type Options struct {
Format string
SampleRate int
Channels int
Codec string
}
func WhisperOptions() Options {
return Options{
Format: FormatWAV,
SampleRate: 16000,
Channels: 1,
Codec: "pcm_s16le",
}
}
func (o *Options) ApplyDefaults() error {
spec, err := ResolveFormat(o.Format)
if err != nil {
return err
}
if o.Codec == "" {
o.Codec = spec.Codec
}
return nil
}
func (o *Options) Validate() error {
if err := o.ApplyDefaults(); err != nil {
return err
}
if o.SampleRate < 0 || o.Channels < 0 {
return fmt.Errorf("sample_rate and channels must be >= 0")
}
if o.Channels > 8 {
return fmt.Errorf("channels must be <= 8")
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package transcode
import "testing"
func TestWhisperOptions(t *testing.T) {
o := WhisperOptions()
if err := o.Validate(); err != nil {
t.Fatal(err)
}
if o.SampleRate != 16000 || o.Channels != 1 {
t.Fatalf("unexpected whisper opts: %+v", o)
}
}
func TestResolveFormat_unknown(t *testing.T) {
_, err := ResolveFormat("xyz")
if err == nil {
t.Fatal("expected error")
}
}
+38
View File
@@ -0,0 +1,38 @@
package transcode
import "github.com/gopxl/beep"
type samplesStreamer struct {
samples []float64
pos int
sampleRate beep.SampleRate
}
func newSamplesStreamer(samples []float64, sampleRate int) *samplesStreamer {
return &samplesStreamer{
samples: samples,
sampleRate: beep.SampleRate(sampleRate),
}
}
func (s *samplesStreamer) Stream(buf [][2]float64) (int, bool) {
if s.pos >= len(s.samples) {
return 0, false
}
n := 0
for i := range buf {
if s.pos >= len(s.samples) {
return n, n > 0
}
v := s.samples[s.pos]
buf[i][0] = v
buf[i][1] = v
s.pos++
n++
}
return n, true
}
func (s *samplesStreamer) Err() error {
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package transcode
import (
"bytes"
"io"
)
// sniffFormat detects container/codec from file header when the path has no extension.
func sniffFormat(r io.Reader) string {
head := make([]byte, 32)
n, _ := io.ReadFull(r, head)
head = head[:n]
if len(head) < 4 {
return ""
}
if bytes.HasPrefix(head, []byte("RIFF")) && len(head) >= 12 && bytes.Equal(head[8:12], []byte("WAVE")) {
return ".wav"
}
if bytes.HasPrefix(head, []byte("ID3")) {
return ".mp3"
}
if len(head) >= 2 && head[0] == 0xFF && (head[1]&0xE0) == 0xE0 {
return ".mp3"
}
if bytes.HasPrefix(head, []byte("fLaC")) {
return ".flac"
}
if bytes.HasPrefix(head, []byte("OggS")) {
return ".ogg"
}
if len(head) >= 8 && bytes.Equal(head[4:8], []byte("ftyp")) {
return ".m4a"
}
if bytes.HasPrefix(head, []byte{0xFF, 0xF1}) || bytes.HasPrefix(head, []byte{0xFF, 0xF9}) {
return ".aac"
}
if bytes.HasPrefix(head, []byte("FORM")) && len(head) >= 12 && bytes.Equal(head[8:12], []byte("AIFF")) {
return ".aiff"
}
if bytes.HasPrefix(head, []byte{0x1A, 0x45, 0xDF, 0xA3}) {
return ".webm"
}
return ""
}
+42
View File
@@ -0,0 +1,42 @@
package transcode
import (
"context"
"github.com/gopxl/beep"
"github.com/gopxl/beep/effects"
)
func buildPipeline(streamer beep.Streamer, format beep.Format, opt Options) (beep.Streamer, beep.Format) {
out := streamer
if opt.SampleRate > 0 && format.SampleRate != beep.SampleRate(opt.SampleRate) {
out = beep.Resample(4, format.SampleRate, beep.SampleRate(opt.SampleRate), out)
format.SampleRate = beep.SampleRate(opt.SampleRate)
}
if opt.Channels == 1 {
out = effects.Mono(out)
format.NumChannels = 1
}
return out, format
}
func drainSamples(ctx context.Context, s beep.Streamer) ([]float64, error) {
buf := make([][2]float64, 4096)
var samples []float64
for {
if err := ctx.Err(); err != nil {
return nil, err
}
n, ok := s.Stream(buf)
if !ok {
if err := s.Err(); err != nil {
return nil, err
}
break
}
for i := 0; i < n; i++ {
samples = append(samples, buf[i][0])
}
}
return samples, nil
}
+53
View File
@@ -0,0 +1,53 @@
package transcode
import (
"os"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
)
func writePCM16WAV(path string, sampleRate int, channels int, samples []float64) error {
if channels <= 0 {
channels = 1
}
if err := os.MkdirAll(dirOf(path), 0o755); err != nil && dirOf(path) != "." {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := wav.NewEncoder(f, sampleRate, 16, channels, 1)
data := make([]int, len(samples))
for i, s := range samples {
data[i] = floatToInt16(s)
}
if err := enc.Write(&audio.IntBuffer{
Format: &audio.Format{SampleRate: sampleRate, NumChannels: channels},
Data: data,
}); err != nil {
return err
}
return enc.Close()
}
func floatToInt16(f float64) int {
if f > 1 {
f = 1
}
if f < -1 {
f = -1
}
return int(f * 32767)
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return path[:i]
}
}
return "."
}