Skip to content

Commit 88258bf

Browse files
authored
Add support for AMR-WB codec. (#58)
Use opencore-amr for decoding and vo-amrwbenc for encoding.
1 parent dd334c5 commit 88258bf

11 files changed

Lines changed: 344 additions & 44 deletions

File tree

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- name: Set up Go
4343
uses: actions/setup-go@v5
4444
with:
45-
go-version: 1.24.2
45+
go-version: 1.26
4646

4747
- name: Set up gotestfmt
4848
run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest

all/all_cgo.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ package all
44

55
// Register all supported codecs that use CGo.
66
import (
7+
_ "github.com/livekit/media-sdk/amrwb"
78
_ "github.com/livekit/media-sdk/opus"
89
)

amrwb/amrwb.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package amrwb
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"io"
21+
22+
"github.com/dennwc/amrwb-cgo"
23+
"github.com/livekit/media-sdk"
24+
)
25+
26+
const (
27+
SDPName = "AMR-WB/16000"
28+
SampleRate = 16000
29+
)
30+
31+
func init() {
32+
media.RegisterCodec(media.NewAudioCodec(media.CodecInfo{
33+
SDPName: SDPName,
34+
SampleRate: SampleRate,
35+
RTPIsStatic: false,
36+
Priority: -4,
37+
FileExt: "amrwb",
38+
Disabled: true,
39+
}, Decode, Encode))
40+
}
41+
42+
type Sample []byte
43+
44+
func (s Sample) Size() int {
45+
return len(s)
46+
}
47+
48+
func (s Sample) CopyTo(dst []byte) (int, error) {
49+
if len(dst) < len(s) {
50+
return 0, io.ErrShortBuffer
51+
}
52+
n := copy(dst, s)
53+
return n, nil
54+
}
55+
56+
type Writer = media.WriteCloser[Sample]
57+
58+
func Decode(w media.PCM16Writer) Writer {
59+
return &Decoder{
60+
w: w,
61+
d: amrwb.NewDecoder(),
62+
}
63+
}
64+
65+
type Decoder struct {
66+
w media.PCM16Writer
67+
d *amrwb.Decoder
68+
frame amrwb.PCMFrame
69+
buf media.PCM16Sample
70+
}
71+
72+
func (d *Decoder) String() string {
73+
return fmt.Sprintf("AMR-WB(decode) -> %s", d.w)
74+
}
75+
76+
func (d *Decoder) SampleRate() int {
77+
return SampleRate
78+
}
79+
80+
func (d *Decoder) Close() error {
81+
d.d.Close()
82+
return d.w.Close()
83+
}
84+
85+
func (d *Decoder) WriteSample(in Sample) error {
86+
d.buf = d.buf[:0]
87+
var blockErr error
88+
for len(in) > 0 {
89+
n, err := d.d.Decode(&d.frame, in)
90+
if err != nil {
91+
blockErr = err
92+
break
93+
}
94+
in = in[n:]
95+
d.buf = append(d.buf, d.frame[:]...)
96+
}
97+
if len(d.buf) != 0 {
98+
if err := d.w.WriteSample(d.buf); err != nil {
99+
return err
100+
}
101+
}
102+
return blockErr
103+
}
104+
105+
func Encode(w Writer) media.PCM16Writer {
106+
return &Encoder{
107+
w: w,
108+
e: amrwb.NewEncoder(amrwb.Best),
109+
}
110+
}
111+
112+
type Encoder struct {
113+
w Writer
114+
e *amrwb.Encoder
115+
buf []byte
116+
done bool
117+
}
118+
119+
func (e *Encoder) String() string {
120+
return fmt.Sprintf("AMR-WB(encode) -> %s", e.w)
121+
}
122+
123+
func (e *Encoder) SampleRate() int {
124+
return SampleRate
125+
}
126+
127+
func (e *Encoder) Close() error {
128+
return e.w.Close()
129+
}
130+
131+
func (e *Encoder) WriteSample(in media.PCM16Sample) error {
132+
if len(in) == 0 {
133+
return nil
134+
}
135+
var blockErr error
136+
if e.done {
137+
// We zero-padded previous frame, but we still got data after that.
138+
// The stream must be normalized with FullFrames by the caller instead.
139+
blockErr = errors.New("amrwb: writing frame after a short previous frame")
140+
}
141+
e.buf = e.buf[:0]
142+
for len(in) > 0 {
143+
const n = amrwb.PCMFrameSize
144+
if len(in) < n {
145+
// Zero pad, it's okay for the last frame only.
146+
// We'll return the error if we get another frame after this.
147+
e.done = true
148+
var buf amrwb.PCMFrame
149+
copy(buf[:], in)
150+
in = buf[:]
151+
}
152+
frame := (*amrwb.PCMFrame)(in[:n])
153+
in = in[n:]
154+
e.buf = e.e.Encode(e.buf, frame)
155+
}
156+
if len(e.buf) != 0 {
157+
if err := e.w.WriteSample(e.buf); err != nil {
158+
return err
159+
}
160+
}
161+
return blockErr
162+
}

amrwb/amrwb_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package amrwb
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"testing"
7+
8+
"github.com/livekit/media-sdk"
9+
"github.com/livekit/media-sdk/res"
10+
"github.com/livekit/media-sdk/res/testdata"
11+
)
12+
13+
func TestAMRWB(t *testing.T) {
14+
const rate = 16000
15+
frames := res.ReadOggAudioFile(testdata.TestAudioOgg16K, rate, 1)
16+
17+
blocks := make([]Sample, 0, len(frames))
18+
fw := media.NewFrameWriter(&blocks, rate)
19+
20+
enc := Encode(fw)
21+
t.Cleanup(func() {
22+
enc.Close()
23+
})
24+
for i, frame := range frames {
25+
err := enc.WriteSample(frame)
26+
if err != nil {
27+
t.Errorf("encoding frame %d/%d: %v", i+1, len(frames), err)
28+
}
29+
}
30+
famr, err := os.Create("testdata.amrwb")
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
defer famr.Close()
35+
36+
famr.WriteString("#!AMR-WB\n")
37+
for _, block := range blocks {
38+
famr.Write(block)
39+
}
40+
41+
var out []media.PCM16Sample
42+
pw := media.NewPCM16FrameWriter(&out, rate)
43+
44+
dec := Decode(pw)
45+
t.Cleanup(func() {
46+
dec.Close()
47+
})
48+
for _, block := range blocks {
49+
err := dec.WriteSample(block)
50+
if err != nil {
51+
t.Error(err)
52+
}
53+
}
54+
55+
f, err := os.Create("testdata.s16le")
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
defer f.Close()
60+
err = media.DumpFramesPCM16(f, rate, out)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
if _, err := exec.LookPath("ffmpeg"); err != nil {
65+
t.Log("ffmpeg not found in $PATH")
66+
return
67+
}
68+
69+
err = exec.Command("ffmpeg",
70+
"-i", "testdata.amrwb",
71+
"testdata.amrwb.ogg",
72+
).Run()
73+
if err != nil {
74+
t.Error(err)
75+
}
76+
err = exec.Command("ffmpeg",
77+
"-f", "s16le", "-ar", "16000", "-ac", "1",
78+
"-i", "testdata.s16le",
79+
"testdata.s16le.ogg",
80+
).Run()
81+
if err != nil {
82+
t.Error(err)
83+
}
84+
}

buffers.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,43 @@ import (
88
)
99

1010
type sample interface {
11-
int8 | int16 | int32 | int64 | float32 | float64
11+
byte | int8 | int16 | int32 | int64 | float32 | float64
1212
}
1313

1414
// FullFrames creates a writer that only writes full frames of a given size to the underlying writer (except the last one).
1515
func FullFrames[T ~[]S, S sample](w WriteCloser[T], frameSize int) WriteCloser[T] {
1616
if frameSize <= 0 {
1717
panic("invalid frame size")
1818
}
19-
return &frameBuffer[T, S]{
19+
return &fullFrameBuffer[T, S]{
2020
w: w,
2121
frameSize: frameSize,
2222
buf: make([]S, 0, frameSize),
2323
}
2424
}
2525

26-
type frameBuffer[T ~[]S, S sample] struct {
26+
type fullFrameBuffer[T ~[]S, S sample] struct {
2727
frameSize int
2828
mu sync.Mutex
2929
w WriteCloser[T]
3030
buf []S
3131
}
3232

33-
func (b *frameBuffer[T, S]) String() string {
34-
return fmt.Sprintf("FrameBuf(%d) -> %s", b.frameSize, b.w)
33+
func (b *fullFrameBuffer[T, S]) String() string {
34+
return fmt.Sprintf("FullFrameBuf(%d) -> %s", b.frameSize, b.w)
3535
}
36-
func (b *frameBuffer[T, S]) SampleRate() int {
36+
func (b *fullFrameBuffer[T, S]) SampleRate() int {
3737
return b.w.SampleRate()
3838
}
3939

40-
func (b *frameBuffer[T, S]) WriteSample(in T) error {
40+
func (b *fullFrameBuffer[T, S]) WriteSample(in T) error {
4141
b.mu.Lock()
4242
defer b.mu.Unlock()
4343
b.buf = append(b.buf, in...)
4444
return b.flush(false)
4545
}
4646

47-
func (b *frameBuffer[T, S]) flush(force bool) error {
47+
func (b *fullFrameBuffer[T, S]) flush(force bool) error {
4848
it := b.buf
4949
defer func() {
5050
if len(it) == 0 {
@@ -69,10 +69,40 @@ func (b *frameBuffer[T, S]) flush(force bool) error {
6969
return nil
7070
}
7171

72-
func (b *frameBuffer[T, S]) Close() error {
72+
func (b *fullFrameBuffer[T, S]) Close() error {
7373
b.mu.Lock()
7474
defer b.mu.Unlock()
7575
err := b.flush(true)
7676
err2 := b.w.Close()
7777
return errors.Join(err, err2)
7878
}
79+
80+
// NewFrameWriter creates a writer that appends a copy of all written frames to a slice.
81+
func NewFrameWriter[T ~[]S, S sample](buf *[]T, sampleRate int) WriteCloser[T] {
82+
return &frameWriter[T, S]{
83+
buf: buf,
84+
sampleRate: sampleRate,
85+
}
86+
}
87+
88+
type frameWriter[T ~[]S, S sample] struct {
89+
buf *[]T
90+
sampleRate int
91+
}
92+
93+
func (b *frameWriter[T, S]) String() string {
94+
return fmt.Sprintf("Frames(%d)", b.sampleRate)
95+
}
96+
97+
func (b *frameWriter[T, S]) SampleRate() int {
98+
return b.sampleRate
99+
}
100+
101+
func (b *frameWriter[T, S]) Close() error {
102+
return nil
103+
}
104+
105+
func (b *frameWriter[T, S]) WriteSample(data T) error {
106+
*b.buf = append(*b.buf, slices.Clone(data))
107+
return nil
108+
}

0 commit comments

Comments
 (0)