Start work on improving recordings.

This commit is contained in:
Storm Dragon
2026-05-23 23:33:30 -04:00
parent 08b484f106
commit 3e06de7501
8 changed files with 662 additions and 105 deletions
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"flag"
"fmt"
"log"
"time"
"git.stormux.org/storm/skald/diskwriter"
)
func main() {
var directory string
var sources int
var duration time.Duration
flag.StringVar(&directory, "out", ".", "output directory")
flag.IntVar(&sources, "sources", 5, "synthetic active source count")
flag.DurationVar(&duration, "duration", 10*time.Second, "recording duration")
flag.Parse()
result, err := diskwriter.RunSyntheticRecording(directory, sources, duration)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Recording: %s\n", result.Path)
fmt.Printf("Duration: %v\n", result.Metrics.Duration.Round(time.Millisecond))
fmt.Printf("Decoder starts: %d\n", result.Metrics.DecoderStarts)
fmt.Printf("Decoder stops: %d\n", result.Metrics.DecoderStops)
fmt.Printf("Source frame drops: %d\n", result.Metrics.DroppedSourceFrames)
fmt.Printf("Stale source ticks: %d\n", result.Metrics.StaleSourceTicks)
fmt.Printf("Mixed frame drops: %d\n", result.Metrics.DroppedMixedFrames)
fmt.Printf("Encoder write errors: %d\n", result.Metrics.EncoderWriteErrors)
}
+354 -75
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"math"
"net"
"os"
"os/exec"
@@ -24,12 +25,16 @@ import (
)
const (
sampleRate = 48000
channels = 2
frameDuration = 20 * time.Millisecond
samplesPerFrame = sampleRate / 1000 * 20
bytesPerFrame = samplesPerFrame * channels * 2
sourceBufferSize = 16
sampleRate = 48000
channels = 2
frameDuration = 20 * time.Millisecond
samplesPerFrame = sampleRate / 1000 * 20
bytesPerFrame = samplesPerFrame * channels * 2
sourceBufferSize = 16
encoderBufferSize = 50
sourceStaleAfter = 2 * time.Second
limiterThreshold = 28000
limiterRatio = 4
)
var Directory string
@@ -197,17 +202,46 @@ func (client *Client) PushConn(g *hall.Hall, id string, up conn.Up, tracks []con
}
type recorder struct {
client *Client
path string
cmd *exec.Cmd
stdin io.WriteCloser
done chan error
client *Client
path string
cmd *exec.Cmd
stdin io.WriteCloser
done chan error
writeCh chan []byte
writerDone chan error
mu sync.Mutex
sources map[*diskTrack]chan []byte
sources map[*diskTrack]*recordingSource
metrics RecorderMetrics
closed bool
}
type recordingSource struct {
ch chan []byte
lastFrame time.Time
decoderStarted bool
decoderStopped bool
staleLogged bool
}
type RecorderMetrics struct {
Started time.Time
Duration time.Duration
ActiveSources int
DecoderStarts uint64
DecoderStops uint64
DroppedSourceFrames uint64
StaleSourceTicks uint64
DroppedMixedFrames uint64
EncoderWriteErrors uint64
MixerFrames uint64
}
type SyntheticRecordingResult struct {
Path string
Metrics RecorderMetrics
}
func newRecorder(client *Client, directory string) (*recorder, error) {
file, err := openDiskFile(directory, "ogg")
if err != nil {
@@ -245,35 +279,107 @@ func newRecorder(client *Client, directory string) (*recorder, error) {
go drainStderr("ffmpeg encoder", stderr)
r := &recorder{
client: client,
path: path,
cmd: cmd,
stdin: stdin,
done: make(chan error, 1),
sources: make(map[*diskTrack]chan []byte),
client: client,
path: path,
cmd: cmd,
stdin: stdin,
done: make(chan error, 1),
writeCh: make(chan []byte, encoderBufferSize),
writerDone: make(chan error, 1),
sources: make(map[*diskTrack]*recordingSource),
metrics: RecorderMetrics{Started: time.Now()},
}
activeRecordings.Store(path, struct{}{})
go r.writeEncoder()
go r.mix()
return r, nil
}
func (r *recorder) addSource(t *diskTrack) chan []byte {
func (r *recorder) addSource(t *diskTrack) *recordingSource {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return nil
}
ch := make(chan []byte, sourceBufferSize)
r.sources[t] = ch
return ch
source := &recordingSource{
ch: make(chan []byte, sourceBufferSize),
lastFrame: time.Now(),
}
r.sources[t] = source
return source
}
func (r *recorder) noteDecoderStarted(t *diskTrack) {
r.mu.Lock()
defer r.mu.Unlock()
source := r.sources[t]
if source == nil || source.decoderStarted {
return
}
source.decoderStarted = true
r.metrics.DecoderStarts++
log.Printf("Recording %s: decoder started, active sources %d",
filepath.Base(r.path), len(r.sources))
}
func (r *recorder) removeSource(t *diskTrack) {
r.mu.Lock()
defer r.mu.Unlock()
source := r.sources[t]
if source == nil {
return
}
if source.decoderStarted && !source.decoderStopped {
source.decoderStopped = true
r.metrics.DecoderStops++
}
delete(r.sources, t)
log.Printf("Recording %s: source removed, active sources %d",
filepath.Base(r.path), len(r.sources))
}
func (r *recorder) noteDecoderStopped(t *diskTrack, err error) {
r.mu.Lock()
defer r.mu.Unlock()
source := r.sources[t]
if source == nil || source.decoderStopped {
return
}
source.decoderStopped = true
r.metrics.DecoderStops++
delete(r.sources, t)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
log.Printf("Recording %s: decoder stopped: %v",
filepath.Base(r.path), err)
} else {
log.Printf("Recording %s: decoder stopped, active sources %d",
filepath.Base(r.path), len(r.sources))
}
}
func (r *recorder) pushSourceFrame(t *diskTrack, frame []byte) bool {
r.mu.Lock()
defer r.mu.Unlock()
source := r.sources[t]
if source == nil {
return false
}
now := time.Now()
source.lastFrame = now
source.staleLogged = false
select {
case source.ch <- frame:
default:
<-source.ch
source.ch <- frame
r.metrics.DroppedSourceFrames++
}
return true
}
func (r *recorder) mix() {
@@ -281,74 +387,149 @@ func (r *recorder) mix() {
defer ticker.Stop()
silence := make([]byte, bytesPerFrame)
for range ticker.C {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
r.done <- nil
return
}
sources := make([]chan []byte, 0, len(r.sources))
for _, ch := range r.sources {
sources = append(sources, ch)
}
r.mu.Unlock()
frame := mixFrame(sources)
if frame == nil {
frame = silence
}
if _, err := r.stdin.Write(frame); err != nil {
for {
select {
case err := <-r.writerDone:
if err != nil {
r.mu.Lock()
r.metrics.EncoderWriteErrors++
r.mu.Unlock()
}
r.done <- err
return
case <-ticker.C:
if r.isClosed() {
close(r.writeCh)
err := <-r.writerDone
if err != nil {
r.mu.Lock()
r.metrics.EncoderWriteErrors++
r.mu.Unlock()
}
r.done <- err
return
}
frame := r.nextMixedFrame(time.Now())
if frame == nil {
frame = silence
}
r.queueMixedFrame(frame)
}
}
}
func mixFrame(sources []chan []byte) []byte {
var mixed []int32
used := false
func (r *recorder) isClosed() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.closed
}
for _, ch := range sources {
var latest []byte
for {
select {
case b := <-ch:
latest = b
default:
goto drained
func (r *recorder) nextMixedFrame(now time.Time) []byte {
r.mu.Lock()
frames := make([][]byte, 0, len(r.sources))
for _, source := range r.sources {
select {
case frame := <-source.ch:
if len(frame) >= bytesPerFrame {
frames = append(frames, frame)
}
default:
if now.Sub(source.lastFrame) >= sourceStaleAfter && !source.staleLogged {
source.staleLogged = true
r.metrics.StaleSourceTicks++
log.Printf("Recording %s: source has no decoded frames for %v",
filepath.Base(r.path), sourceStaleAfter)
}
}
drained:
if len(latest) < bytesPerFrame {
continue
}
r.metrics.MixerFrames++
r.mu.Unlock()
return mixPCMFrames(frames)
}
func (r *recorder) queueMixedFrame(frame []byte) {
select {
case r.writeCh <- frame:
default:
r.mu.Lock()
r.metrics.DroppedMixedFrames++
r.mu.Unlock()
log.Printf("Recording %s: encoder queue full, dropping mixed frame",
filepath.Base(r.path))
}
}
func (r *recorder) writeEncoder() {
for frame := range r.writeCh {
if _, err := r.stdin.Write(frame); err != nil {
r.writerDone <- err
return
}
}
r.writerDone <- nil
}
func mixFrame(sources []chan []byte) []byte {
frames := make([][]byte, 0, len(sources))
for _, ch := range sources {
var frame []byte
select {
case frame = <-ch:
default:
}
if len(frame) >= bytesPerFrame {
frames = append(frames, frame)
}
}
return mixPCMFrames(frames)
}
func mixPCMFrames(frames [][]byte) []byte {
if len(frames) == 0 {
return nil
}
var mixed []int32
gain := 1.0
if len(frames) > 1 {
gain = 1 / math.Sqrt(float64(len(frames)))
}
for _, frame := range frames {
if mixed == nil {
mixed = make([]int32, samplesPerFrame*channels)
}
for i := range mixed {
sample := int16(binary.LittleEndian.Uint16(latest[i*2:]))
sample := int16(binary.LittleEndian.Uint16(frame[i*2:]))
mixed[i] += int32(sample)
}
used = true
}
if !used {
return nil
}
out := make([]byte, bytesPerFrame)
for i, sample := range mixed {
if sample > 32767 {
sample = 32767
} else if sample < -32768 {
sample = -32768
}
sample = int32(math.Round(float64(sample) * gain))
sample = limitSample(sample)
binary.LittleEndian.PutUint16(out[i*2:], uint16(int16(sample)))
}
return out
}
func limitSample(sample int32) int32 {
if sample > limiterThreshold {
sample = limiterThreshold + (sample-limiterThreshold)/limiterRatio
} else if sample < -limiterThreshold {
sample = -limiterThreshold + (sample+limiterThreshold)/limiterRatio
}
if sample > 32767 {
return 32767
}
if sample < -32768 {
return -32768
}
return sample
}
func (r *recorder) Close() error {
r.mu.Lock()
if r.closed {
@@ -362,6 +543,18 @@ func (r *recorder) Close() error {
stdinErr := r.stdin.Close()
waitErr := r.cmd.Wait()
activeRecordings.Delete(r.path)
metrics := r.snapshotMetrics()
log.Printf("Recording %s: closed after %v, sources=%d, decoders=%d/%d, source drops=%d, stale ticks=%d, mixed drops=%d, encoder errors=%d",
filepath.Base(r.path),
metrics.Duration,
metrics.ActiveSources,
metrics.DecoderStarts,
metrics.DecoderStops,
metrics.DroppedSourceFrames,
metrics.StaleSourceTicks,
metrics.DroppedMixedFrames,
metrics.EncoderWriteErrors,
)
if err != nil {
return err
@@ -379,6 +572,91 @@ func (r *recorder) Close() error {
return nil
}
func (r *recorder) snapshotMetrics() RecorderMetrics {
r.mu.Lock()
defer r.mu.Unlock()
metrics := r.metrics
metrics.Duration = time.Since(metrics.Started)
metrics.ActiveSources = len(r.sources)
return metrics
}
func RunSyntheticRecording(directory string, sources int, duration time.Duration) (SyntheticRecordingResult, error) {
if sources <= 0 {
return SyntheticRecordingResult{}, errors.New("sources must be greater than zero")
}
if duration <= 0 {
return SyntheticRecordingResult{}, errors.New("duration must be greater than zero")
}
if err := os.MkdirAll(directory, 0700); err != nil {
return SyntheticRecordingResult{}, err
}
r, err := newRecorder(nil, directory)
if err != nil {
return SyntheticRecordingResult{}, err
}
var wg sync.WaitGroup
for i := 0; i < sources; i++ {
track := &diskTrack{}
if r.addSource(track) == nil {
r.Close()
return SyntheticRecordingResult{}, errors.New("recorder is closed")
}
r.noteDecoderStarted(track)
wg.Add(1)
go func(index int, t *diskTrack) {
defer wg.Done()
defer r.removeSource(t)
ticker := time.NewTicker(frameDuration)
defer ticker.Stop()
timer := time.NewTimer(duration)
defer timer.Stop()
frequency := 220 + float64(index)*37
phase := 0.0
for {
select {
case <-timer.C:
return
case <-ticker.C:
r.pushSourceFrame(t, sinePCMFrame(frequency, &phase))
}
}
}(i, track)
}
wg.Wait()
if err := r.Close(); err != nil {
return SyntheticRecordingResult{}, err
}
return SyntheticRecordingResult{
Path: r.path,
Metrics: r.snapshotMetrics(),
}, nil
}
func sinePCMFrame(frequency float64, phase *float64) []byte {
frame := make([]byte, bytesPerFrame)
step := 2 * math.Pi * frequency / sampleRate
for i := 0; i < samplesPerFrame; i++ {
sample := int16(math.Sin(*phase) * 6000)
*phase += step
if *phase >= 2*math.Pi {
*phase -= 2 * math.Pi
}
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(
frame[offset+ch*2:], uint16(sample),
)
}
}
return frame
}
type diskConn struct {
client *Client
remote conn.Up
@@ -486,8 +764,8 @@ func (t *diskTrack) Write(buf []byte) (int, error) {
}
func (t *diskTrack) start(payload uint8) error {
ch := t.conn.client.recorder.addSource(t)
if ch == nil {
source := t.conn.client.recorder.addSource(t)
if source == nil {
return errors.New("recorder is closed")
}
@@ -560,25 +838,26 @@ func (t *diskTrack) start(payload uint8) error {
t.rtpConn = rtpConn
t.cmd = cmd
t.stdin = stdin
t.ch = ch
t.ch = source.ch
t.conn.client.recorder.noteDecoderStarted(t)
go drainStderr("ffmpeg decoder", stderr)
go t.readPCM(stdout, ch)
go t.readPCM(stdout)
return nil
}
func (t *diskTrack) readPCM(r io.Reader, ch chan []byte) {
func (t *diskTrack) readPCM(r io.Reader) {
for {
buf := make([]byte, bytesPerFrame)
_, err := io.ReadFull(r, buf)
if err != nil {
if t.conn.client.recorder != nil {
t.conn.client.recorder.noteDecoderStopped(t, err)
}
return
}
select {
case ch <- buf:
default:
<-ch
ch <- buf
if t.conn.client.recorder != nil {
t.conn.client.recorder.pushSourceFrame(t, buf)
}
}
}
+109 -3
View File
@@ -103,7 +103,7 @@ func TestCleanupOldRecordings(t *testing.T) {
}
}
func TestMixFrameClipsAndSumsSources(t *testing.T) {
func TestMixFrameAppliesHeadroomForMultipleSources(t *testing.T) {
a := make(chan []byte, 1)
b := make(chan []byte, 1)
a <- pcmFrame(20000)
@@ -116,12 +116,79 @@ func TestMixFrameClipsAndSumsSources(t *testing.T) {
for i := 0; i < len(out); i += 2 {
got := int16(binary.LittleEndian.Uint16(out[i:]))
if got != 32767 {
t.Fatalf("sample %d = %d, expected clipped 32767", i/2, got)
if got != 28071 {
t.Fatalf("sample %d = %d, expected headroom-adjusted 28071", i/2, got)
}
}
}
func TestMixFrameLimiterPreservesNormalSpeechLevel(t *testing.T) {
a := make(chan []byte, 1)
a <- pcmFrame(12000)
out := mixFrame([]chan []byte{a})
for i := 0; i < len(out); i += 2 {
got := int16(binary.LittleEndian.Uint16(out[i:]))
if got != 12000 {
t.Fatalf("sample %d = %d, expected unchanged 12000", i/2, got)
}
}
}
func TestMixFrameHandlesQuietAndLoudSources(t *testing.T) {
a := make(chan []byte, 1)
b := make(chan []byte, 1)
a <- pcmFrame(1000)
b <- pcmFrame(20000)
out := mixFrame([]chan []byte{a, b})
for i := 0; i < len(out); i += 2 {
got := int16(binary.LittleEndian.Uint16(out[i:]))
if got != 14849 {
t.Fatalf("sample %d = %d, expected mixed quiet/loud sample 14849", i/2, got)
}
}
}
func TestMixFrameLimiterBoundsOverloadedSamples(t *testing.T) {
a := make(chan []byte, 1)
b := make(chan []byte, 1)
a <- pcmFrame(32000)
b <- pcmFrame(32000)
out := mixFrame([]chan []byte{a, b})
for i := 0; i < len(out); i += 2 {
got := int16(binary.LittleEndian.Uint16(out[i:]))
if got <= 0 || got > 32767 {
t.Fatalf("sample %d = %d, expected bounded positive sample", i/2, got)
}
if got >= 32767 {
t.Fatalf("sample %d = %d, expected limiter below hard clip", i/2, got)
}
}
}
func TestMixFrameConsumesOldestQueuedFrame(t *testing.T) {
source := make(chan []byte, 2)
source <- pcmFrame(1000)
source <- pcmFrame(2000)
out := mixFrame([]chan []byte{source})
if len(out) != bytesPerFrame {
t.Fatalf("mixed frame has length %d, expected %d", len(out), bytesPerFrame)
}
for i := 0; i < len(out); i += 2 {
got := int16(binary.LittleEndian.Uint16(out[i:]))
if got != 1000 {
t.Fatalf("sample %d = %d, expected oldest queued sample 1000", i/2, got)
}
}
if queued := len(source); queued != 1 {
t.Fatalf("queued frames after mix = %d, expected 1", queued)
}
}
func TestMixFrameReturnsNilForSilence(t *testing.T) {
if out := mixFrame(nil); out != nil {
t.Fatalf("expected nil silent frame, got %d bytes", len(out))
@@ -162,6 +229,45 @@ func TestRecorderCreatesPlayableOgg(t *testing.T) {
}
}
func TestSyntheticRecordingCreatesPlayableOgg(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed")
}
result, err := RunSyntheticRecording(t.TempDir(), 3, 100*time.Millisecond)
if err != nil {
t.Fatal(err)
}
if result.Metrics.DecoderStarts != 3 {
t.Fatalf("decoder starts = %d, expected 3", result.Metrics.DecoderStarts)
}
if result.Metrics.DecoderStops != 3 {
t.Fatalf("decoder stops = %d, expected 3", result.Metrics.DecoderStops)
}
if result.Metrics.DroppedMixedFrames != 0 {
t.Fatalf("dropped mixed frames = %d, expected 0", result.Metrics.DroppedMixedFrames)
}
cmd := exec.Command(
"ffprobe",
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
result.Path,
)
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(out)) != "opus" {
t.Fatalf("expected opus stream, got %q", strings.TrimSpace(string(out)))
}
}
func pcmFrame(sample int16) []byte {
out := make([]byte, bytesPerFrame)
for i := 0; i < len(out); i += 2 {
@@ -2,7 +2,7 @@
# shellcheck shell=bash disable=SC2034,SC2154
pkgname=skald-git
pkgver=0.0.0.r1530.g2e28dff
pkgver=0.0.0.r1531.g08b484f
pkgrel=1
pkgdesc='Audio-only hall-based conferencing server'
arch=('x86_64' 'aarch64')
+36 -10
View File
@@ -174,7 +174,17 @@ func (g *Hall) Description() *Description {
func (g *Hall) ClientCount() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.clients)
return g.clientCountUnlocked()
}
func (g *Hall) clientCountUnlocked() int {
count := 0
for _, c := range g.clients {
if !isSystemClient(c) {
count++
}
}
return count
}
func (g *Hall) mayExpire() bool {
@@ -525,6 +535,10 @@ func member(v string, l []string) bool {
return false
}
func isSystemClient(c Client) bool {
return member("system", c.Permissions())
}
func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) {
g, err := Add(hall, nil)
if err != nil {
@@ -535,8 +549,9 @@ func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) {
defer g.mu.Unlock()
clients := g.getClientsUnlocked(nil)
systemClient := isSystemClient(c)
if !member("system", c.Permissions()) {
if !systemClient {
username, perms, err := g.getPermission(creds)
if err != nil {
return nil, err
@@ -587,7 +602,7 @@ func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) {
}
if !member("op", perms) && g.description.MaxClients > 0 {
if len(g.clients) >= g.description.MaxClients {
if g.clientCountUnlocked() >= g.description.MaxClients {
return nil, UserError("too many users")
}
}
@@ -607,12 +622,20 @@ func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) {
u := c.Username()
p := c.Permissions()
s := c.Data()
c.PushClient(g.Name(), "add", c.Id(), u, p, s)
if !systemClient {
c.PushClient(g.Name(), "add", c.Id(), u, p, s)
}
for _, cc := range clients {
pp := cc.Permissions()
uu := cc.Username()
c.PushClient(g.Name(), "add", cc.Id(), uu, pp, cc.Data())
cc.PushClient(g.Name(), "add", id, u, p, s)
if !isSystemClient(cc) {
c.PushClient(
g.Name(), "add", cc.Id(), uu, pp, cc.Data(),
)
}
if !systemClient {
cc.PushClient(g.Name(), "add", id, u, p, s)
}
}
return g, nil
@@ -659,6 +682,7 @@ func DelClient(c Client) {
if g == nil {
return
}
systemClient := isSystemClient(c)
g.mu.Lock()
if g.clients[c.Id()] != c {
log.Printf("Deleting unknown client")
@@ -671,10 +695,12 @@ func DelClient(c Client) {
g.mu.Unlock()
c.Joined(g.Name(), "leave")
for _, cc := range clients {
cc.PushClient(
g.Name(), "delete", c.Id(), c.Username(), nil, nil,
)
if !systemClient {
for _, cc := range clients {
cc.PushClient(
g.Name(), "delete", c.Id(), c.Username(), nil, nil,
)
}
}
closeSystemClientsIfNoUsers(g, clients)
autoLockKick(g)
+90 -1
View File
@@ -129,6 +129,14 @@ type testClient struct {
id string
username string
permissions []string
pushed []pushedClient
}
type pushedClient struct {
kind string
id string
username string
permissions []string
}
func (c *testClient) Hall() *Hall { return nil }
@@ -144,11 +152,92 @@ func (c *testClient) PushConn(*Hall, string, conn.Up, []conn.UpTrack, string) er
}
func (c *testClient) RequestConns(Client, *Hall, string) error { return nil }
func (c *testClient) Joined(string, string) error { return nil }
func (c *testClient) PushClient(string, string, string, string, []string, map[string]interface{}) error {
func (c *testClient) PushClient(_ string, kind string, id string, username string, permissions []string, _ map[string]interface{}) error {
c.pushed = append(c.pushed, pushedClient{
kind: kind,
id: id,
username: username,
permissions: permissions,
})
return nil
}
func (c *testClient) Kick(string, *string, string) error { return nil }
func TestSystemClientIsNotAdvertisedToUsers(t *testing.T) {
halls.halls = nil
oldDirectory := Directory
Directory = t.TempDir()
t.Cleanup(func() {
Directory = oldDirectory
halls.halls = nil
})
err := os.WriteFile(
filepath.Join(Directory, "system-hidden-test.json"),
[]byte(`{
"users": {
"user1": {"password": "secret", "permissions": "present"},
"user2": {"password": "secret", "permissions": "present"}
}
}`),
0600,
)
if err != nil {
t.Fatal(err)
}
user1 := &testClient{id: "user1-client"}
g, err := AddClient("system-hidden-test", user1, ClientCredentials{
Username: strPtr("user1"),
Password: "secret",
})
if err != nil {
t.Fatal(err)
}
user1.pushed = nil
recorder := &testClient{
id: "recorder-client",
username: "RECORDING",
permissions: []string{"system"},
}
_, err = AddClient("system-hidden-test", recorder, ClientCredentials{
System: true,
})
if err != nil {
t.Fatal(err)
}
if len(user1.pushed) != 0 {
t.Fatalf("user saw system recorder client: %#v", user1.pushed)
}
if count := g.ClientCount(); count != 1 {
t.Fatalf("client count with recorder = %d, expected 1", count)
}
user2 := &testClient{id: "user2-client"}
_, err = AddClient("system-hidden-test", user2, ClientCredentials{
Username: strPtr("user2"),
Password: "secret",
})
if err != nil {
t.Fatal(err)
}
for _, pushed := range user2.pushed {
if pushed.id == recorder.id {
t.Fatalf("joining user saw system recorder client: %#v", user2.pushed)
}
}
for _, pushed := range user1.pushed {
if pushed.id == recorder.id {
t.Fatalf("existing user saw system recorder client: %#v", user1.pushed)
}
}
}
func strPtr(s string) *string {
return &s
}
func TestLockedHallJoinMessage(t *testing.T) {
halls.halls = nil
oldDirectory := Directory
+23 -3
View File
@@ -423,12 +423,32 @@ recordings directory. The file is named
`skald-recording-YYYYMMDD-HHMMSS.ogg` and contains a single mixed Ogg Opus
audio stream.
Skald uses `ffmpeg` to decode incoming Opus RTP audio and encode the mixed
output. If `ffmpeg` is not installed or cannot be started, recording fails
with an operator-visible error instead of silently creating a broken file.
Skald uses one `ffmpeg` decoder process for each active incoming Opus RTP
audio source, mixes decoded 48 kHz stereo PCM every 20 ms, and sends the
mixed stream to one `ffmpeg` encoder process that writes the Ogg Opus file.
The mixer treats missing source frames as silence, applies deterministic
headroom and limiting before encoding, and logs recording metrics when the
file closes: active sources, decoder starts and stops, dropped source frames,
stale source ticks, dropped mixed frames, and encoder write errors.
The encoder is fed through a bounded queue so a stuck encoder cannot block
WebRTC packet handling indefinitely. If the queue fills, Skald drops mixed
frames and logs the condition. If `ffmpeg` is not installed or cannot be
started, recording fails with an operator-visible error instead of silently
creating a broken file.
The recorder closes when recording is stopped explicitly or when the hall no
longer has any non-system clients.
Maintainers can exercise the recording mixer and encoder without browser
clients by running:
```
go run ./cmd/skald-recording-smoke -out /tmp -sources 5 -duration 10s
```
This creates a real mixed Ogg Opus file from synthetic PCM sources and prints
the same core counters that are useful when checking larger halls.
If `recordingRetention` is set in `data/config.json`, Skald periodically
deletes completed recording files older than the configured duration.
+14 -12
View File
@@ -28,14 +28,16 @@
X Confirm the current live deployment can start and stop a Skald recording.
X Confirm migrated Galene rooms work as Skald halls on meet.stormux.org.
- Document the current recording pipeline in skald.md for maintainers:
X Document the current recording pipeline in skald.md for maintainers:
incoming Opus RTP -> per-source ffmpeg decoder -> PCM mixer -> ffmpeg Opus
encoder -> Ogg Opus file.
- Add lightweight recording metrics in logs or debug output:
X Add lightweight recording metrics in logs or debug output:
active recorded sources, dropped source frames, mixer write errors, ffmpeg
decoder starts/stops, and encoder failures.
- Add a synthetic recording benchmark or smoke tool that can simulate multiple
active Opus sources without needing browser clients.
X Add a synthetic recording smoke tool that can simulate multiple decoded PCM
sources without needing browser clients.
- Add a synthetic RTP/Opus recording benchmark that exercises per-source
decoder subprocesses without needing browser clients.
- Record baseline CPU, process count, and output quality for 1, 5, 10, and 25
active sources on a representative server.
- Define practical target limits for "normal hall", "large moderated hall",
@@ -45,13 +47,13 @@ X Confirm migrated Galene rooms work as Skald halls on meet.stormux.org.
# PHASE 1: MIX QUALITY BEFORE PIPELINE REWRITE
# ============================================================================
- Add mixer tests for multiple simultaneous PCM sources, including clipping
X Add mixer tests for multiple simultaneous PCM sources, including clipping
cases and quiet/loud source combinations.
- Replace raw summing with a safer mix strategy:
X Replace raw summing with a safer mix strategy:
per-source attenuation, headroom, or another deterministic approach that
reduces clipping when multiple users speak.
- Add a simple limiter to prevent harsh clipping in the final mixed PCM.
- Add tests proving the limiter preserves normal speech-level samples and
X Add a simple limiter to prevent harsh clipping in the final mixed PCM.
X Add tests proving the limiter preserves normal speech-level samples and
bounds overloaded samples.
- Add optional per-source level tracking so logs or future UI can identify
sources that dominate the recording.
@@ -64,8 +66,8 @@ X Confirm migrated Galene rooms work as Skald halls on meet.stormux.org.
# PHASE 2: SOURCE LIFECYCLE AND SILENCE HANDLING
# ============================================================================
- Track whether each recording source is active, silent, stalled, or closed.
- Treat missing frames as silence without blocking the mixer.
X Track whether each recording source is active, silent, stalled, or closed.
X Treat missing frames as silence without blocking the mixer.
- Drop or de-prioritize stale silent sources after a short timeout.
- Ensure mute/unmute transitions do not leave stale audio in the mixed output.
- Ensure participant join/leave during recording never stops the recording file.
@@ -99,12 +101,12 @@ X Confirm migrated Galene rooms work as Skald halls on meet.stormux.org.
- Decide whether the final Ogg Opus encoder should remain ffmpeg or move
in-process later.
- Make encoder startup errors operator-visible and log the exact ffmpeg error.
- Ensure encoder stdin writes cannot block the mixer indefinitely.
X Ensure encoder stdin writes cannot block the mixer indefinitely.
- Ensure recorder close handles encoder failure, stdin close failure, and wait
failure without leaving misleading successful output.
- Verify interrupted or failed recordings are either clearly marked as failed or
produce a playable partial file.
- Verify completed files with ffprobe or equivalent in tests where available.
X Verify completed files with ffprobe or equivalent in tests where available.
- Verify completed files play in common players after long recordings.
# ============================================================================