first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "."
|
||||
}
|
||||
Reference in New Issue
Block a user