From 6275e3adeffa99ae078c1202b854a5f1b7fcf091 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 19 May 2026 21:47:31 -0400 Subject: [PATCH] Latest code. Working voice, still needs some ui cleanup and accessibility fixes but in decent shape. --- .gitignore | 2 + README.md | 16 +- diskwriter/diskwriter.go | 950 +++++++++++++++++----------------- diskwriter/diskwriter_test.go | 189 +++---- go.mod | 2 - go.sum | 4 - hall/hall.go | 15 + local-server.sh | 99 ++++ rtpconn/rtpstats.go | 9 - rtpconn/webclient.go | 4 + skald-api.md | 6 +- skald-install.md | 37 +- skald-protocol.md | 4 + skald.md | 29 +- static/skald.html | 10 +- static/skald.js | 149 +++--- static/stats.js | 9 - stats/stats.go | 4 - todo.txt | 92 ++-- 19 files changed, 855 insertions(+), 775 deletions(-) create mode 100755 local-server.sh diff --git a/.gitignore b/.gitignore index 8ad5d3d..949e0a2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /skald /skaldctl/skaldctl /data +/recordings +/halls/*.json /halls/**/*.json /static/**/*.d.ts /static/third-party/tasks-vision diff --git a/README.md b/README.md index 5b0b85f..b53caf7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ Skald is a hard fork of Galene that is being turned into an audio-only hall conferencing server. The current development repository is . +Skald is not protocol-compatible or API-compatible with Galene. Its public +URLs, administrative API, and protocol terminology use halls and Skald names. +Recordings are written as single mixed Ogg Opus audio files; `ffmpeg` must be +installed on the server for recording to work. + ## Quick start ```sh @@ -11,13 +16,14 @@ git clone https://git.stormux.org/storm/skald cd skald CGO_ENABLED=0 go build -ldflags='-s -w' mkdir halls -echo '{"users": {"vimes": {"password":"sybil", "permissions":"op"}}}' > halls/night-watch.json +echo '{"users": {"Username": {"password":"Password", "permissions":"op"}}}' > halls/main.json ./skald & ``` -Point your browser at , ignore -the unknown certificate warning, and log in with username *vimes* and -password *sybil*. +Point your browser at , ignore the +unknown certificate warning, and log in with username *Username* and +password *Password*. The browser client enables the microphone by default +and does not provide camera or screen-sharing controls. For full installation instructions, please see the file [skald-install.md][1] in this directory. @@ -29,7 +35,7 @@ in this directory. * [skald-client.md][3]: writing clients; * [skald-protocol.md][4]: the client protocol; * [skald-api.md][5]: Skald's administrative API. - + ## Contributing Skald is currently being developed at diff --git a/diskwriter/diskwriter.go b/diskwriter/diskwriter.go index 91466c4..33f7fd8 100644 --- a/diskwriter/diskwriter.go +++ b/diskwriter/diskwriter.go @@ -2,33 +2,34 @@ package diskwriter import ( crand "crypto/rand" + "encoding/binary" "encoding/hex" "errors" "fmt" + "io" "log" "net" "os" + "os/exec" "path/filepath" "runtime" "strings" "sync" "time" - "github.com/at-wat/ebml-go/mkvcore" - "github.com/at-wat/ebml-go/webm" "github.com/pion/rtp" - "github.com/pion/rtp/codecs" - "github.com/pion/webrtc/v4/pkg/media" - - "github.com/jech/samplebuilder" "git.stormux.org/storm/skald/conn" "git.stormux.org/storm/skald/hall" - "git.stormux.org/storm/skald/rtptime" ) const ( - audioMaxLate = 32 + sampleRate = 48000 + channels = 2 + frameDuration = 20 * time.Millisecond + samplesPerFrame = sampleRate / 1000 * 20 + bytesPerFrame = samplesPerFrame * channels * 2 + sourceBufferSize = 16 ) var Directory string @@ -37,9 +38,10 @@ type Client struct { hall *hall.Hall id string - mu sync.Mutex - down map[string]*diskConn - closed bool + mu sync.Mutex + down map[string]*diskConn + closed bool + recorder *recorder } func newId() string { @@ -52,6 +54,31 @@ func New(g *hall.Hall) *Client { return &Client{hall: g, id: newId()} } +func (client *Client) Start() error { + client.mu.Lock() + defer client.mu.Unlock() + + if client.closed { + return errors.New("disk client is closed") + } + if client.recorder != nil { + return nil + } + + directory := filepath.Join(Directory, client.hall.Name()) + err := os.MkdirAll(directory, 0700) + if err != nil { + return err + } + + r, err := newRecorder(client, directory) + if err != nil { + return err + } + client.recorder = r + return nil +} + func (client *Client) Hall() *hall.Hall { return client.hall } @@ -65,11 +92,9 @@ func (client *Client) Username() string { } func (client *Client) SetUsername(string) { - return } func (client *Client) SetPermissions(perms []string) { - return } func (client *Client) Permissions() []string { @@ -97,6 +122,12 @@ func (client *Client) Close() error { } client.down = nil client.closed = true + + if client.recorder != nil { + err := client.recorder.Close() + client.recorder = nil + return err + } return nil } @@ -125,6 +156,9 @@ func (client *Client) PushConn(g *hall.Hall, id string, up conn.Up, tracks []con if client.closed { return errors.New("disk client is closed") } + if client.recorder == nil { + return errors.New("recorder is not started") + } if replace != "" { rp := client.down[replace] @@ -146,18 +180,11 @@ func (client *Client) PushConn(g *hall.Hall, id string, up conn.Up, tracks []con return nil } - directory := filepath.Join(Directory, client.hall.Name()) - err := os.MkdirAll(directory, 0700) - if err != nil { - g.WallOps("Write to disk: " + err.Error()) - return err - } - if client.down == nil { client.down = make(map[string]*diskConn) } - down, err := newDiskConn(client, directory, up, tracks) + down, err := newDiskConn(client, up, tracks) if err != nil { g.WallOps("Write to disk: " + err.Error()) return err @@ -167,99 +194,447 @@ func (client *Client) PushConn(g *hall.Hall, id string, up conn.Up, tracks []con return nil } -type diskConn struct { - client *Client - directory string - username string +type recorder struct { + client *Client + path string + cmd *exec.Cmd + stdin io.WriteCloser + done chan error - mu sync.Mutex - file *os.File - remote conn.Up - tracks []*diskTrack - lastWarning time.Time - originLocal time.Time - originRemote uint64 + mu sync.Mutex + sources map[*diskTrack]chan []byte + closed bool } -// called locked -func (conn *diskConn) warn(message string) { - now := time.Now() - if now.Sub(conn.lastWarning) < 10*time.Second { - return +func newRecorder(client *Client, directory string) (*recorder, error) { + file, err := openDiskFile(directory, "ogg") + if err != nil { + return nil, err } - log.Println(message) - conn.client.hall.WallOps(message) - conn.lastWarning = now + path := file.Name() + if err := file.Close(); err != nil { + return nil, err + } + + cmd := exec.Command( + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", "error", + "-f", "s16le", + "-ar", fmt.Sprint(sampleRate), + "-ac", fmt.Sprint(channels), + "-i", "pipe:0", + "-c:a", "libopus", + "-b:a", "128k", + path, + ) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("could not start ffmpeg: %w", err) + } + 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), + } + go r.mix() + return r, nil } -// called locked -func (conn *diskConn) open(extension string) error { - if conn.file != nil { - return errors.New("already open") +func (r *recorder) addSource(t *diskTrack) chan []byte { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed { + return nil + } + ch := make(chan []byte, sourceBufferSize) + r.sources[t] = ch + return ch +} + +func (r *recorder) removeSource(t *diskTrack) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.sources, t) +} + +func (r *recorder) mix() { + ticker := time.NewTicker(frameDuration) + 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 { + r.done <- err + return + } + } +} + +func mixFrame(sources []chan []byte) []byte { + var mixed []int32 + used := false + + for _, ch := range sources { + var latest []byte + for { + select { + case b := <-ch: + latest = b + default: + goto drained + } + } + drained: + if len(latest) < bytesPerFrame { + continue + } + if mixed == nil { + mixed = make([]int32, samplesPerFrame*channels) + } + for i := range mixed { + sample := int16(binary.LittleEndian.Uint16(latest[i*2:])) + mixed[i] += int32(sample) + } + used = true } - file, err := openDiskFile(conn.directory, conn.username, extension) + if !used { + return nil + } + + out := make([]byte, bytesPerFrame) + for i, sample := range mixed { + if sample > 32767 { + sample = 32767 + } else if sample < -32768 { + sample = -32768 + } + binary.LittleEndian.PutUint16(out[i*2:], uint16(int16(sample))) + } + return out +} + +func (r *recorder) Close() error { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return nil + } + r.closed = true + r.mu.Unlock() + + err := <-r.done + stdinErr := r.stdin.Close() + waitErr := r.cmd.Wait() + if err != nil { return err } - - conn.file = file + if stdinErr != nil { + return stdinErr + } + if waitErr != nil { + return waitErr + } return nil } -// called locked -func (conn *diskConn) close() []*diskTrack { - conn.originLocal = time.Time{} - conn.originRemote = 0 - - tracks := make([]*diskTrack, 0, len(conn.tracks)) - for _, t := range conn.tracks { - t.writeBuffered(true) - if t.writer != nil { - t.writer.Close() - t.writer = nil - } - t.origin = none - tracks = append(tracks, t) - } - conn.file = nil - return tracks +type diskConn struct { + client *Client + remote conn.Up + tracks []*diskTrack } func (conn *diskConn) Close() error { conn.remote.DelLocal(conn) - conn.mu.Lock() - tracks := conn.close() - conn.mu.Unlock() - - for _, t := range tracks { - t.remote.DelLocal(t) + for _, t := range conn.tracks { + t.Close() } return nil } +type diskTrack struct { + remote conn.UpTrack + conn *diskConn + + mu sync.Mutex + started bool + closed bool + payload uint8 + rtpConn *net.UDPConn + cmd *exec.Cmd + stdin io.WriteCloser + ch chan []byte +} + +func newDiskConn(client *Client, up conn.Up, remoteTracks []conn.UpTrack) (*diskConn, error) { + var tracks []conn.UpTrack + + for _, remote := range remoteTracks { + codec := remote.Codec().MimeType + if strings.EqualFold(codec, "audio/opus") { + tracks = append(tracks, remote) + } else { + client.hall.WallOps("Unknown codec, " + codec + ", not recording") + } + } + + if len(tracks) == 0 { + return nil, errors.New("no usable tracks found") + } + + down := &diskConn{ + client: client, + remote: up, + tracks: make([]*diskTrack, 0, len(tracks)), + } + + for _, remote := range tracks { + track := &diskTrack{ + remote: remote, + conn: down, + } + down.tracks = append(down.tracks, track) + } + + for _, t := range down.tracks { + err := t.remote.AddLocal(t) + if err != nil { + log.Printf("Couldn't add disk track: %v", err) + client.hall.WallOps("Couldn't add disk track: " + err.Error()) + } + } + err := up.AddLocal(down) + if err != nil { + return nil, err + } + + return down, nil +} + +func (t *diskTrack) SetCname(string) { +} + +func (t *diskTrack) Write(buf []byte) (int, error) { + var p rtp.Packet + if err := p.Unmarshal(buf); err != nil { + log.Printf("Diskwriter: %v", err) + return 0, nil + } + + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return 0, conn.ErrConnectionClosed + } + if !t.started { + if err := t.start(p.PayloadType); err != nil { + t.conn.client.hall.WallOps("Write to disk: " + err.Error()) + return 0, err + } + } + if t.payload != p.PayloadType { + return 0, nil + } + + if _, err := t.rtpConn.Write(buf); err != nil { + return 0, err + } + return len(buf), nil +} + +func (t *diskTrack) start(payload uint8) error { + ch := t.conn.client.recorder.addSource(t) + if ch == nil { + return errors.New("recorder is closed") + } + + port, err := freeUDPPort() + if err != nil { + t.conn.client.recorder.removeSource(t) + return err + } + + cmd := exec.Command( + "ffmpeg", + "-hide_banner", + "-loglevel", "error", + "-protocol_whitelist", "file,pipe,udp,rtp", + "-f", "sdp", + "-i", "pipe:0", + "-f", "s16le", + "-ar", fmt.Sprint(sampleRate), + "-ac", fmt.Sprint(channels), + "pipe:1", + ) + stdin, err := cmd.StdinPipe() + if err != nil { + t.conn.client.recorder.removeSource(t) + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.conn.client.recorder.removeSource(t) + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + t.conn.client.recorder.removeSource(t) + return err + } + if err := cmd.Start(); err != nil { + t.conn.client.recorder.removeSource(t) + return fmt.Errorf("could not start ffmpeg: %w", err) + } + + sdp := fmt.Sprintf( + "v=0\r\n"+ + "o=- 0 0 IN IP4 127.0.0.1\r\n"+ + "s=Skald recording\r\n"+ + "c=IN IP4 127.0.0.1\r\n"+ + "t=0 0\r\n"+ + "m=audio %d RTP/AVP %d\r\n"+ + "a=rtpmap:%d opus/48000/2\r\n"+ + "a=recvonly\r\n", + port, payload, payload, + ) + if _, err := io.WriteString(stdin, sdp); err != nil { + cmd.Process.Kill() + t.conn.client.recorder.removeSource(t) + return err + } + stdin.Close() + + addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port} + rtpConn, err := net.DialUDP("udp", nil, addr) + if err != nil { + cmd.Process.Kill() + t.conn.client.recorder.removeSource(t) + return err + } + + t.started = true + t.payload = payload + t.rtpConn = rtpConn + t.cmd = cmd + t.stdin = stdin + t.ch = ch + + go drainStderr("ffmpeg decoder", stderr) + go t.readPCM(stdout, ch) + return nil +} + +func (t *diskTrack) readPCM(r io.Reader, ch chan []byte) { + for { + buf := make([]byte, bytesPerFrame) + _, err := io.ReadFull(r, buf) + if err != nil { + return + } + select { + case ch <- buf: + default: + <-ch + ch <- buf + } + } +} + +func (t *diskTrack) Close() { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return + } + t.closed = true + + t.remote.DelLocal(t) + if t.rtpConn != nil { + t.rtpConn.Close() + t.rtpConn = nil + } + if t.cmd != nil && t.cmd.Process != nil { + t.cmd.Process.Kill() + t.cmd.Wait() + } + if t.conn.client.recorder != nil { + t.conn.client.recorder.removeSource(t) + } +} + +func (t *diskTrack) SetTimeOffset(ntp uint64, rtp uint32) { +} + +func (t *diskTrack) GetMaxBitrate() (uint64, int, int) { + return ^uint64(0), -1, -1 +} + +func freeUDPPort() (int, error) { + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + return 0, err + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return 0, err + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).Port, nil +} + var replacer = strings.NewReplacer( "/", "-slash-", "\\", "-backslash-", ) -// sanitise sanitises a string so it can be safely used in a filename -// It does not need to be injective, since we check for filename collisions. +// sanitise sanitises a string so it can be safely used in a filename. func sanitise(s string) string { return replacer.Replace(s) } -func openDiskFile(directory, username, extension string) (*os.File, error) { - filenameFormat := "2006-01-02T15:04:05.000" +func openDiskFile(directory, extension string) (*os.File, error) { + filenameFormat := "20060102-150405" if runtime.GOOS == "windows" { - filenameFormat = "2006-01-02T15-04-05-000" + filenameFormat = "20060102-150405" } - filename := time.Now().Format(filenameFormat) - if username != "" { - filename = filename + "-" + sanitise(username) - } + filename := "skald-recording-" + time.Now().Format(filenameFormat) for counter := 0; counter < 100; counter++ { var fn string if counter == 0 { @@ -283,419 +658,18 @@ func openDiskFile(directory, username, extension string) (*os.File, error) { return nil, errors.New("couldn't create file") } -type maybeUint32 uint64 - -const none maybeUint32 = 0 - -func some(value uint32) maybeUint32 { - return maybeUint32(uint64(1<<32) | uint64(value)) -} - -func valid(m maybeUint32) bool { - return (m & (1 << 32)) != 0 -} - -func value(m maybeUint32) uint32 { - return uint32(m) -} - -type diskTrack struct { - remote conn.UpTrack - conn *diskConn - - writer mkvcore.BlockWriteCloser - builder *samplebuilder.SampleBuilder - lastSeqno maybeUint32 - - origin maybeUint32 - - remoteNTP uint64 - remoteRTP uint32 -} - -func newDiskConn(client *Client, directory string, up conn.Up, remoteTracks []conn.UpTrack) (*diskConn, error) { - var audio conn.UpTrack - - for _, remote := range remoteTracks { - codec := remote.Codec().MimeType - if strings.EqualFold(codec, "audio/opus") { - if audio == nil { - audio = remote - } else { - client.hall.WallOps("Multiple audio tracks, recording just one") - } - } else { - client.hall.WallOps("Unknown codec, " + codec + ", not recording") - } - } - - if audio == nil { - return nil, errors.New("no usable tracks found") - } - - tracks := []conn.UpTrack{audio} - - _, username := up.User() - conn := diskConn{ - client: client, - directory: directory, - username: username, - tracks: make([]*diskTrack, 0, len(tracks)), - remote: up, - } - - for _, remote := range tracks { - var builder *samplebuilder.SampleBuilder - codec := remote.Codec() - if strings.EqualFold(codec.MimeType, "audio/opus") { - builder = samplebuilder.New( - audioMaxLate, - &codecs.OpusPacket{}, codec.ClockRate, - ) - } else { - // this shouldn't happen - return nil, errors.New( - "cannot record codec " + codec.MimeType, - ) - } - track := &diskTrack{ - remote: remote, - builder: builder, - conn: &conn, - } - conn.tracks = append(conn.tracks, track) - } - - for _, t := range conn.tracks { - err := t.remote.AddLocal(t) - if err != nil { - log.Printf("Couldn't add disk track: %v", err) - conn.warn("Couldn't add disk track: " + err.Error()) - } - } - err := up.AddLocal(&conn) - if err != nil { - return nil, err - } - - return &conn, nil -} - -func (t *diskTrack) SetCname(string) { -} - -func (t *diskTrack) Write(buf []byte) (int, error) { - t.conn.mu.Lock() - defer t.conn.mu.Unlock() - - if t.builder == nil { - return 0, nil - } - - // samplebuilder retains packets - data := make([]byte, len(buf)) - copy(data, buf) - p := new(rtp.Packet) - err := p.Unmarshal(data) - if err != nil { - log.Printf("Diskwriter: %v", err) - return 0, nil - } - - if valid(t.lastSeqno) { - lastSeqno := uint16(value(t.lastSeqno)) - if ((p.SequenceNumber - lastSeqno) & 0x8000) == 0 { - // jump forward - count := p.SequenceNumber - lastSeqno - if count < 256 { - for i := uint16(1); i < count; i++ { - fetch(t, lastSeqno+i) - } - } else { - - } - t.lastSeqno = some(uint32(p.SequenceNumber)) - } else { - // jump backward - count := lastSeqno - p.SequenceNumber - if count >= 512 { - t.lastSeqno = none - - } - } - } else { - t.lastSeqno = some(uint32(p.SequenceNumber)) - } - - err = t.writeRTP(p) - if err != nil { - return 0, err - } - return len(buf), nil -} - -func fetch(t *diskTrack, seqno uint16) { - // since the samplebuilder retains packets, use a fresh buffer - buf := make([]byte, 1504) - n := t.remote.GetPacket(seqno, buf, false) - if n == 0 { - return - } - p := new(rtp.Packet) - err := p.Unmarshal(buf) - if err != nil { - return - } - t.writeRTP(p) -} - -// writeRTP writes the packet without fetching lost packets -// Called locked. -func (t *diskTrack) writeRTP(p *rtp.Packet) error { - if !valid(t.origin) { - t.setOrigin( - p.Timestamp, time.Now(), - t.remote.Codec().ClockRate, - ) - } - - t.builder.Push(p) - - return t.writeBuffered(false) -} - -// writeBuffered writes buffered samples to disk. If force is true, then -// samples will be flushed even if they are preceded by incomplete -// samples. -func (t *diskTrack) writeBuffered(force bool) error { +func drainStderr(prefix string, r io.Reader) { + buf := make([]byte, 1024) for { - var sample *media.Sample - var ts uint32 - if !force { - sample, ts = t.builder.PopWithTimestamp() - } else { - sample, ts = t.builder.ForcePopWithTimestamp() - } - if sample == nil { - return nil - } - - if valid(t.origin) && int32(ts-value(t.origin)) < 0 { - if value(t.origin)-ts < 0x10000 { - // late packet before origin, drop - continue - } - // we've gone around 2^31 timestamps, force - // creating a new file to avoid wraparound - t.conn.close() - } - - if t.writer == nil { - err := t.conn.initWriter(t, ts) - if err != nil { - t.conn.warn( - "Write to disk " + err.Error(), - ) - return err + n, err := r.Read(buf) + if n > 0 { + msg := strings.TrimSpace(string(buf[:n])) + if msg != "" { + log.Printf("%s: %s", prefix, msg) } } - - if t.writer == nil { - continue - } - - if !valid(t.origin) { - log.Println("Invalid origin") - return nil - } - - tm := (ts - value(t.origin)) / - (t.remote.Codec().ClockRate / 1000) - keyframe := true - _, err := t.writer.Write(keyframe, int64(tm), sample.Data) if err != nil { - return err + return } } } - -// setOrigin sets the origin of track t after receiving a packet with -// timestamp ts at local time now. -// called locked -func (t *diskTrack) setOrigin(ts uint32, now time.Time, clockrate uint32) { - sub := func(a, b uint32, hz uint32) time.Duration { - return rtptime.ToDuration(int64(int32(a-b)), hz) - } - - if t.conn.originLocal.Equal(time.Time{}) { - t.origin = some(ts) - t.conn.originLocal = now - if t.remoteNTP != 0 { - remote := rtptime.NTPToTime(t.remoteNTP).Add( - sub(ts, t.remoteRTP, clockrate), - ) - t.conn.originRemote = rtptime.TimeToNTP(remote) - } else { - t.conn.originRemote = 0 - } - } else if t.conn.originRemote != 0 && t.remoteNTP != 0 { - remote := rtptime.NTPToTime(t.remoteNTP).Add( - sub(ts, t.remoteRTP, clockrate), - ) - origin := rtptime.NTPToTime(t.conn.originRemote) - delta := rtptime.FromDuration(remote.Sub(origin), clockrate) - t.origin = some(ts - uint32(delta)) - } else { - d := now.Sub(t.conn.originLocal) - delta := rtptime.FromDuration(d, clockrate) - t.origin = some(ts - uint32(delta)) - if t.remoteNTP != 0 { - remote := rtptime.NTPToTime(t.remoteNTP).Add( - sub(ts, t.remoteRTP, clockrate), - ) - t.conn.originRemote = rtptime.TimeToNTP( - remote.Add(-d), - ) - } - } -} - -// SetTimeOffset adjusts the origin of track t given remote sync information. -func (t *diskTrack) SetTimeOffset(ntp uint64, rtp uint32) { - t.conn.mu.Lock() - defer t.conn.mu.Unlock() - t.setTimeOffset(ntp, rtp, t.remote.Codec().ClockRate) -} - -// called locked -func (t *diskTrack) setTimeOffset(ntp uint64, rtp uint32, clockrate uint32) { - if valid(t.origin) { - local := rtptime.ToDuration( - int64(int32(rtp-value(t.origin))), clockrate, - ) - if t.conn.originRemote == 0 { - t.conn.originRemote = - rtptime.TimeToNTP( - rtptime.NTPToTime(ntp).Add(-local)) - } else { - remote := rtptime.NTPToTime(ntp).Sub( - rtptime.NTPToTime(t.conn.originRemote)) - delta := rtptime.FromDuration(remote-local, clockrate) - t.origin = some(value(t.origin) - uint32(delta)) - } - } - - t.remoteNTP = ntp - t.remoteRTP = rtp -} - -// adjustOrigin adjusts all origin-related fields of all tracks so that -// the origin of track t is equal to ts. -// Called locked. -func (t *diskTrack) adjustOrigin(ts uint32) { - if !valid(t.origin) || value(t.origin) == ts { - return - } - - offset := rtptime.ToDuration( - int64(int32(ts-value(t.origin))), t.remote.Codec().ClockRate, - ) - - if !t.conn.originLocal.Equal(time.Time{}) { - t.conn.originLocal = t.conn.originLocal.Add(offset) - } - if t.conn.originRemote != 0 { - t.conn.originRemote = - rtptime.TimeToNTP( - rtptime.NTPToTime( - t.conn.originRemote, - ).Add(offset), - ) - } - - for _, tt := range t.conn.tracks { - if valid(tt.origin) { - tt.origin = some(value(tt.origin) + - uint32(rtptime.FromDuration( - offset, - tt.remote.Codec().ClockRate, - )), - ) - } - } -} - -// called locked -func (conn *diskConn) initWriter(track *diskTrack, ts uint32) error { - if conn.file != nil { - conn.close() - } - - var desc []mkvcore.TrackDescription - for i, t := range conn.tracks { - codec := t.remote.Codec() - entry := webm.TrackEntry{ - Name: "Audio", - TrackNumber: uint64(i + 1), - CodecID: "A_OPUS", - TrackType: 2, - Audio: &webm.Audio{ - SamplingFrequency: float64(codec.ClockRate), - Channels: uint64(codec.Channels), - }, - } - desc = append(desc, - mkvcore.TrackDescription{ - TrackNumber: uint64(i + 1), - TrackEntry: entry, - }, - ) - } - - if track != nil { - track.adjustOrigin(ts) - } - - err := conn.open("webm") - if err != nil { - return err - } - - interceptor, err := mkvcore.NewMultiTrackBlockSorter( - // must be larger than the samplebuilder's MaxLate. - mkvcore.WithMaxDelayedPackets(audioMaxLate+16), - mkvcore.WithSortRule(mkvcore.BlockSorterWriteOutdated), - ) - if err != nil { - conn.file.Close() - conn.file = nil - return err - } - - ws, err := mkvcore.NewSimpleBlockWriter( - conn.file, desc, - mkvcore.WithEBMLHeader(webm.DefaultEBMLHeader), - mkvcore.WithSegmentInfo(webm.DefaultSegmentInfo), - mkvcore.WithBlockInterceptor(interceptor), - ) - if err != nil { - conn.file.Close() - conn.file = nil - return err - } - - if len(ws) != len(conn.tracks) { - conn.file.Close() - conn.file = nil - return errors.New("unexpected number of writers") - } - - for i, t := range conn.tracks { - t.writer = ws[i] - } - return nil -} - -func (t *diskTrack) GetMaxBitrate() (uint64, int, int) { - return ^uint64(0), -1, -1 -} diff --git a/diskwriter/diskwriter_test.go b/diskwriter/diskwriter_test.go index d3dbfc3..2da6ee1 100644 --- a/diskwriter/diskwriter_test.go +++ b/diskwriter/diskwriter_test.go @@ -1,10 +1,13 @@ package diskwriter import ( + "encoding/binary" + "os" + "os/exec" + "path/filepath" + "strings" "testing" "time" - - "git.stormux.org/storm/skald/rtptime" ) func TestSanitise(t *testing.T) { @@ -23,151 +26,89 @@ func TestSanitise(t *testing.T) { } } -func TestAdjustOriginLocalNow(t *testing.T) { - now := time.Now() - - c := &diskConn{ - tracks: []*diskTrack{ - {}, - }, +func TestOpenDiskFileUsesSkaldOggName(t *testing.T) { + dir := t.TempDir() + f, err := openDiskFile(dir, "ogg") + if err != nil { + t.Fatal(err) } - for _, t := range c.tracks { - t.conn = c - } - c.tracks[0].setOrigin(132, now, 100) + name := filepath.Base(f.Name()) + f.Close() - if !c.originLocal.Equal(now) { - t.Errorf("Expected %v, got %v", now, c.originLocal) + if !strings.HasPrefix(name, "skald-recording-") { + t.Fatalf("filename %q does not use Skald recording prefix", name) } - - if c.originRemote != 0 { - t.Errorf("Expected 0, got %v", c.originRemote) + if !strings.HasSuffix(name, ".ogg") { + t.Fatalf("filename %q does not use Ogg extension", name) } - - if c.tracks[0].origin != some(132) { - t.Errorf("Expected 132, got %v", value(c.tracks[0].origin)) + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatal(err) } } -func TestAdjustOriginLocalEarlier(t *testing.T) { - now := time.Now() - earlier := now.Add(-time.Second) +func TestMixFrameClipsAndSumsSources(t *testing.T) { + a := make(chan []byte, 1) + b := make(chan []byte, 1) + a <- pcmFrame(20000) + b <- pcmFrame(20000) - c := &diskConn{ - originLocal: earlier, - tracks: []*diskTrack{ - {}, - }, - } - for _, t := range c.tracks { - t.conn = c - } - c.tracks[0].setOrigin(132, now, 100) - - if !c.originLocal.Equal(earlier) { - t.Errorf("Expected %v, got %v", earlier, c.originLocal) + out := mixFrame([]chan []byte{a, b}) + if len(out) != bytesPerFrame { + t.Fatalf("mixed frame has length %d, expected %d", len(out), bytesPerFrame) } - if c.originRemote != 0 { - t.Errorf("Expected 0, got %v", c.originRemote) - } - - if c.tracks[0].origin != some(32) { - t.Errorf("Expected 32, got %v", value(c.tracks[0].origin)) + 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) + } } } -func TestAdjustOriginLocalLater(t *testing.T) { - now := time.Now() - later := now.Add(time.Second) - - c := &diskConn{ - originLocal: later, - tracks: []*diskTrack{ - {}, - }, - } - for _, t := range c.tracks { - t.conn = c - } - c.tracks[0].setOrigin(32, now, 100) - - if !c.originLocal.Equal(later) { - t.Errorf("Expected %v, got %v", later, c.originLocal) - } - - if c.originRemote != 0 { - t.Errorf("Expected 0, got %v", c.originRemote) - } - - if c.tracks[0].origin != some(132) { - t.Errorf("Expected 132, got %v", value(c.tracks[0].origin)) +func TestMixFrameReturnsNilForSilence(t *testing.T) { + if out := mixFrame(nil); out != nil { + t.Fatalf("expected nil silent frame, got %d bytes", len(out)) } } -func TestAdjustOriginRemote(t *testing.T) { - now := time.Now() - earlier := now.Add(-time.Second) - - c := &diskConn{ - tracks: []*diskTrack{ - { - remoteNTP: rtptime.TimeToNTP(earlier), - remoteRTP: 32, - }, - }, +func TestRecorderCreatesPlayableOgg(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") } - for _, t := range c.tracks { - t.conn = c - } - c.tracks[0].setOrigin(132, now, 100) - - if !c.originLocal.Equal(now) { - t.Errorf("Expected %v, got %v", now, c.originLocal) + if _, err := exec.LookPath("ffprobe"); err != nil { + t.Skip("ffprobe not installed") } - d := now.Sub(rtptime.NTPToTime(c.originRemote)) - if d < -time.Millisecond || d > time.Millisecond { - t.Errorf("Expected %v, got %v (delta %v)", - rtptime.TimeToNTP(now), - c.originRemote, d) + r, err := newRecorder(nil, t.TempDir()) + if err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + if err := r.Close(); err != nil { + t.Fatal(err) } - if c.tracks[0].origin != some(132) { - t.Errorf("Expected 132, got %v", value(c.tracks[0].origin)) + cmd := exec.Command( + "ffprobe", + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=codec_name", + "-of", "default=noprint_wrappers=1:nokey=1", + r.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 TestAdjustOriginLocalRemote(t *testing.T) { - now := time.Now() - earlier := now.Add(-time.Second) - - c := &diskConn{ - tracks: []*diskTrack{ - {}, - }, - } - for _, t := range c.tracks { - t.conn = c - } - c.tracks[0].setOrigin(132, now, 100) - - c.tracks[0].setTimeOffset(rtptime.TimeToNTP(earlier), 32, 100) - - c.tracks[0].setOrigin(132, now, 100) - - if !c.originLocal.Equal(now) { - t.Errorf("Expected %v, got %v", now, c.originLocal) - } - - d := now.Sub(rtptime.NTPToTime(c.originRemote)) - if d < -time.Millisecond || d > time.Millisecond { - t.Errorf("Expected %v, got %v (delta %v)", - rtptime.TimeToNTP(now), - c.originRemote, d) - } - - if c.tracks[0].origin != some(132) { - t.Errorf("Expected 132, got %v", value(c.tracks[0].origin)) +func pcmFrame(sample int16) []byte { + out := make([]byte, bytesPerFrame) + for i := 0; i < len(out); i += 2 { + binary.LittleEndian.PutUint16(out[i:], uint16(sample)) } + return out } diff --git a/go.mod b/go.mod index 5b5aa04..b7cffb1 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,9 @@ module git.stormux.org/storm/skald go 1.21 require ( - github.com/at-wat/ebml-go v0.17.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.0 github.com/jech/cert v0.0.0-20240301122532-f491cf43a77d - github.com/jech/samplebuilder v0.0.0-20241027120643-76c654ae55e1 github.com/pion/ice/v4 v4.0.10 github.com/pion/interceptor v0.1.40 github.com/pion/rtcp v1.2.15 diff --git a/go.sum b/go.sum index 0474867..1c05103 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/at-wat/ebml-go v0.17.1 h1:pWG1NOATCFu1hnlowCzrA1VR/3s8tPY6qpU+2FwW7X4= -github.com/at-wat/ebml-go v0.17.1/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -10,8 +8,6 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jech/cert v0.0.0-20240301122532-f491cf43a77d h1:On8Zm5Vs4x9x/cHtH8ZoLrSmGRgNPy8CieEOcJoFubA= github.com/jech/cert v0.0.0-20240301122532-f491cf43a77d/go.mod h1:ILvE5TtvouQgno/A2RxRuT2qB4/pP1DYXtp6zQcgTUk= -github.com/jech/samplebuilder v0.0.0-20241027120643-76c654ae55e1 h1:yEtAj1O4YF+dH6yVtF5ujfYLClJhKOJIBZQSnNDlHaI= -github.com/jech/samplebuilder v0.0.0-20241027120643-76c654ae55e1/go.mod h1:RifwfrDurQDSkiU6kIOvpT0pluegudzi76U1LAMno/A= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= diff --git a/hall/hall.go b/hall/hall.go index 4a124cc..fa9b7e5 100644 --- a/hall/hall.go +++ b/hall/hall.go @@ -676,9 +676,24 @@ func DelClient(c Client) { g.Name(), "delete", c.Id(), c.Username(), nil, nil, ) } + closeSystemClientsIfNoUsers(g, clients) autoLockKick(g) } +func closeSystemClientsIfNoUsers(g *Hall, clients []Client) { + var system []Client + for _, c := range clients { + if member("system", c.Permissions()) { + system = append(system, c) + } else { + return + } + } + for _, c := range system { + c.Kick("", nil, "hall is empty") + } +} + func (g *Hall) GetClients(except Client) []Client { g.mu.Lock() defer g.mu.Unlock() diff --git a/local-server.sh b/local-server.sh new file mode 100755 index 0000000..de52249 --- /dev/null +++ b/local-server.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +httpAddress="${SKALD_HTTP:-:8443}" +hallName="${SKALD_TEST_HALL:-test}" +insecure="${SKALD_INSECURE:-0}" + +staticDir="${SKALD_STATIC_DIR:-static}" +binary="${SKALD_BINARY:-./skald}" + +json_string() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +adminUsername="${SKALD_TEST_ADMIN_USER:-admin}" +adminPassword="${SKALD_TEST_ADMIN_PASSWORD:-password}" +guestPassword="${SKALD_TEST_GUEST_PASSWORD:-guest}" + +workDir="$(mktemp -d "${TMPDIR:-/tmp}/skald-local.XXXXXXXXXX")" +dataDir="$workDir/data" +hallsDir="$workDir/halls" +recordingsDir="$workDir/recordings" +serverPid="" + +cleanup() { + if [[ -n "$serverPid" ]] && kill -0 "$serverPid" 2>/dev/null; then + kill "$serverPid" 2>/dev/null || true + wait "$serverPid" 2>/dev/null || true + fi + rm -rf "$workDir" +} +trap cleanup EXIT INT TERM + +mkdir -p "$dataDir" "$hallsDir" "$recordingsDir" + +hallFile="$hallsDir/$hallName.json" +escapedAdminUsername="$(json_string "$adminUsername")" +escapedAdminPassword="$(json_string "$adminPassword")" +escapedGuestPassword="$(json_string "$guestPassword")" +cat > "$hallFile" <$urlPort/hall/$hallName/" +fi +echo "Admin username: $adminUsername" +echo "Admin password: $adminPassword" +echo "Guest password: $guestPassword" +echo "Recording output: $recordingsDir/$hallName/" +echo "Temporary data: $workDir" +if [[ "$protocol" == "https" ]]; then + echo "Browser note: accept the self-signed certificate warning before testing microphone access." +else + echo "Browser note: insecure HTTP is only useful for localhost; remote browsers may block microphone access." +fi +echo "Press Ctrl+C to stop and clean up the temporary hall." + +"$binary" \ + "${insecureFlag[@]}" \ + -http "$httpAddress" \ + -data "$dataDir" \ + -halls "$hallsDir" \ + -recordings "$recordingsDir" \ + -static "$staticDir" & + +serverPid="$!" +wait "$serverPid" diff --git a/rtpconn/rtpstats.go b/rtpconn/rtpstats.go index c55393c..e3ddd8a 100644 --- a/rtpconn/rtpstats.go +++ b/rtpconn/rtpstats.go @@ -50,11 +50,6 @@ func (c *webClient) GetStats() *stats.Client { Id: down.id, } for _, t := range down.tracks { - layer := t.getLayerInfo() - sid := layer.sid - maxSid := layer.maxSid - tid := layer.tid - maxTid := layer.maxTid rate, _ := t.rate.Estimate() maxRate, _, _ := t.GetMaxBitrate() rtt := rtptime.ToDuration(int64(t.getRTT()), @@ -63,10 +58,6 @@ func (c *webClient) GetStats() *stats.Client { j := time.Duration(jitter) * time.Second / time.Duration(t.track.Codec().ClockRate) conns.Tracks = append(conns.Tracks, stats.Track{ - Tid: &tid, - MaxTid: &maxTid, - Sid: &sid, - MaxSid: &maxSid, Bitrate: uint64(rate) * 8, MaxBitrate: maxRate, Loss: float64(loss) / 256.0, diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index 9e026c1..3d6a30a 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -1657,6 +1657,10 @@ func handleClientMessage(c *webClient, m clientMessage) error { } } disk := diskwriter.New(g) + if err := disk.Start(); err != nil { + disk.Close() + return c.error(err) + } _, err := hall.AddClient(g.Name(), disk, hall.ClientCredentials{ System: true, diff --git a/skald-api.md b/skald-api.md index eefbb69..3e3b4b1 100644 --- a/skald-api.md +++ b/skald-api.md @@ -1,7 +1,11 @@ # Skald's administrative API Skald provides an HTTP-based API that can be used to create halls and -users. For example, in order to create a hall, a client may do +users. This API is intentionally not compatible with Galene's +administrative API. Skald uses the `/skald-api/` URL space and hall-based +collection paths. + +For example, in order to create a hall, a client may do PUT /skald-api/v0/.halls/hallname/ Content-Type: application/json diff --git a/skald-install.md b/skald-install.md index 8fb563d..0d16cd4 100644 --- a/skald-install.md +++ b/skald-install.md @@ -58,7 +58,7 @@ chmod go-rw data/key.pem ``` Since certificates are regularly rotated, this should be done in a monthly -cron job (or a *SystemD* timer unit, if you're feeling particularly kinky). +cron job or a systemd timer unit. ### Run Skald on the server @@ -148,13 +148,13 @@ skaldctl -insecure create-hall -hall city-watch Create an "op", a user with hall moderation privileges: ```sh -skaldctl create-user -hall city-watch -user vimes -permissions op +skaldctl create-user -hall city-watch -user Username -permissions op ``` Set the new user's password: ```sh -skaldctl set-password -hall city-watch -user vimes +skaldctl set-password -hall city-watch -user Username ``` You should now be able to test your Skald installation by pointing a web @@ -163,8 +163,8 @@ browser at . Create an ordinary user: ```sh -skaldctl create-user -hall city-watch -user fred -skaldctl set-password -hall city-watch -user fred +skaldctl create-user -hall city-watch -user Participant +skaldctl set-password -hall city-watch -user Participant ``` Check the results: @@ -177,6 +177,18 @@ skaldctl list-users -l -hall city-watch Type `skaldctl -help`, `skaldctl create-hall -help`, etc. for more information. +### Recording dependency + +Skald recordings are single mixed Ogg Opus audio files. Recording requires +`ffmpeg` on the server: + +```sh +ffmpeg -version +``` + +If `ffmpeg` is missing or cannot be started, Skald reports a recording error +to the operator instead of creating a partial recording. + ## Advanced configuration Skald is designed to be exposed directly to the Internet. If your server @@ -235,10 +247,21 @@ First, you might need to inform Skald of the URL at which users connect ``` Second, and depending on your proxy implementation, you might need to -request that the proxy pass WebSocket handshakes to the URL at `ws`; for -example, with Nginx, you will need to say something like the following: +proxy normal Skald paths such as `/hall/` and `/skald-api/`, and pass +WebSocket handshakes to the URL at `ws`; for example, with Nginx, you will +need to say something like the following: ``` +location /hall/ { + proxy_pass https://localhost:8443/hall/; + proxy_set_header Host $http_host; +} + +location /skald-api/ { + proxy_pass https://localhost:8443/skald-api/; + proxy_set_header Host $http_host; +} + location /ws { proxy_pass https://localhost:8443/ws; proxy_set_header Upgrade $http_upgrade; diff --git a/skald-protocol.md b/skald-protocol.md index 333bba9..55906b6 100644 --- a/skald-protocol.md +++ b/skald-protocol.md @@ -1,5 +1,9 @@ # Skald's protocol +Skald's protocol is intentionally not compatible with Galene's protocol. +Client-visible terminology and URLs use halls, and Skald rejects video media +instead of negotiating it. + ## Data structures ### Hall diff --git a/skald.md b/skald.md index e9995bc..af6a6db 100644 --- a/skald.md +++ b/skald.md @@ -230,7 +230,7 @@ skaldctl delete-hall -hall amcw A user entry is created with the `skaldctl create-user` command: ```sh -skaldctl create-user -hall city-watch -user vimes -permissions op +skaldctl create-user -hall city-watch -user Username -permissions op ``` If the `-permissions` flag is not specified, it defaults to `present`, @@ -246,7 +246,7 @@ In order to be useful, a user entry needs to be assigned a password. This is done with the `skaldctl set-password` command: ```sh -skaldctl set-password -hall city-watch -user vimes +skaldctl set-password -hall city-watch -user Username ``` #### The fallback user @@ -362,6 +362,8 @@ are optional. The following fields are allowed: between which joining the hall is allowed; - `allow-recording`: if true, then recording is allowed in this hall; + recordings are single mixed Ogg Opus audio files and require `ffmpeg` + on the server; - `unrestricted-tokens`: if true, then ordinary users (without the "op" privilege) are allowed to create tokens; @@ -402,9 +404,23 @@ following strings: The value of the `codecs` field is an array of codecs allowed in the hall. Supported audio codecs include `"opus"`, `"g722"`, `"pcmu"` and -`"pcma"`. Opus is the default and is the only codec planned for the +`"pcma"`. Opus is the default and is the codec used by Skald's single-file recording path. +## Recording + +When `allow-recording` is enabled and an operator starts recording, Skald +creates one recording file for the hall session under the configured +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. +The recorder closes when recording is stopped explicitly or when the hall no +longer has any non-system clients. + ## Client Authorisation Skald implements three authorisation methods: a username/password @@ -435,11 +451,12 @@ For example, the entry ```json { - "users": {"vimes": {"password": "sybil", "permissions": "op"}} + "users": {"Username": {"password": "Password", "permissions": "op"}} } ``` -specifies that user "vimes" may log in as operator with password "sybil", while +specifies that user "Username" may log in as operator with password +"Password", while ```json { @@ -469,7 +486,7 @@ A user entry with a hashed password looks like this: ```json "users": { - "vimes": { + "Username": { "password": { "type": "pbkdf2", "hash": "sha-256", diff --git a/static/skald.html b/static/skald.html index e15a751..9e8b3f5 100644 --- a/static/skald.html +++ b/static/skald.html @@ -70,7 +70,8 @@

Chat History

-
+
+
@@ -105,11 +106,11 @@ Enable at start:

- +

- +

@@ -157,7 +158,8 @@ Media Options diff --git a/static/skald.js b/static/skald.js index c19fda6..b18c245 100644 --- a/static/skald.js +++ b/static/skald.js @@ -800,7 +800,7 @@ let mediaChoicesDone = false; * Populate the media choices menu. * * Since media names might not be available before we call - * getDisplayMedia, we call this function twice, the second time in order + * getUserMedia, we call this function twice, the second time in order * to update the menu with user-readable labels. * * @param{boolean} done @@ -818,10 +818,17 @@ async function setMediaChoices(done) { return; } - let cn = 1, mn = 1; + let mn = 1; devices.forEach(d => { let label = d.label; + if(d.kind === 'audioinput' && d.deviceId) { + if(!label) + label = `Microphone ${mn}`; + addSelectOption(getSelectElement('audioselect'), + label, d.deviceId); + mn++; + } }); mediaChoicesDone = done; @@ -1031,9 +1038,18 @@ async function addLocalMedia(localId) { let settings = getSettings(); /** @type{boolean|MediaTrackConstraints} */ - let audio = settings.audio ? {deviceId: settings.audio} : false; + let audio = false; + if(settings.audio === 'default') { + audio = true; + } else if(settings.audio && settings.audio !== 'off') { + audio = {deviceId: settings.audio}; + } + if(!audio) { + displayMessage('Please choose a microphone before enabling audio.'); + return; + } - if(audio) { + if(audio !== true) { if(!settings.preprocessing) { audio.echoCancellation = false; audio.noiseSuppression = false; @@ -1080,17 +1096,6 @@ async function addLocalMedia(localId) { } setButtonsVisibility(); } - - - - -/** - * @param {File} file - */ -async function addFileMedia(file) { - displayError('Local file playback is not supported in audio-only mode'); -} - /** * @param {MediaStream} s */ @@ -1182,6 +1187,26 @@ async function setMedia(c) { if(!stream) return; c.userdata.media = stream; + + let media = /** @type {HTMLAudioElement} */ + (document.getElementById('media-' + c.localId)); + if(!media) { + media = document.createElement('audio'); + media.id = 'media-' + c.localId; + media.autoplay = true; + media.muted = !!c.up; + media.classList.add('media'); + + let container = document.getElementById('audio-streams'); + if(!container) + throw new Error("Couldn't find audio-streams"); + container.appendChild(media); + } + + if(media.srcObject !== stream) + media.srcObject = stream; + + setMediaStatus(c); } @@ -1201,8 +1226,13 @@ function showHideMedia(c, elt) { * @param {Stream} c */ function resetMedia(c) { - // Audio-only: no media elements to reset - return; + let media = /** @type {HTMLAudioElement} */ + (document.getElementById('media-' + c.localId)); + if(!media) { + console.error("Resetting unknown media element") + return; + } + media.srcObject = media.srcObject; } /** @@ -1254,6 +1284,12 @@ function setVolumeButton(muted, button, slider) { * @param {string} localId */ function delMedia(localId) { + let media = document.getElementById('media-' + localId); + if(media) { + if(media instanceof HTMLMediaElement) + media.srcObject = null; + media.remove(); + } setButtonsVisibility(); } @@ -1274,12 +1310,20 @@ function setMediaStatus(c) { } if(good) { media.classList.remove('media-failed'); - if(c.userdata.play) { - if(media instanceof HTMLMediaElement) + if(media instanceof HTMLMediaElement) { + if(!c.up && !c.userdata.playing) { + c.userdata.playing = true; + media.play().catch(e => { + c.userdata.playing = false; + console.error(e); + displayError(e); + }); + } else if(c.userdata.play) { media.play().catch(e => { console.error(e); displayError(e); }); + } delete(c.userdata.play); } } else { @@ -1500,8 +1544,6 @@ function userMenu(elt) { inviteMenu(); }}); } - if(serverConnection.permissions.indexOf('present') >= 0 && canFile()) - items.push({label: 'Broadcast file', onClick: presentFile}); items.push({label: 'Restart media', onClick: renegotiateStreams}); } else { items.push({label: 'Send file', onClick: () => { @@ -2239,6 +2281,20 @@ function formatTime(time) { /** @type {lastMessage} */ let lastMessage = {}; +/** + * @param {string} text + */ +function announceChat(text) { + let announcement = document.getElementById('chat-announcements'); + if(!announcement) + return; + + announcement.textContent = ''; + window.setTimeout(() => { + announcement.textContent = text; + }, 20); +} + /** * @param {string} id * @param {string} peerId @@ -2359,11 +2415,11 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m // Announce new messages to screen readers (but not history) if(!history) { - let announcement = document.getElementById('chat-announcements'); - if(announcement) { - let messageText = typeof message === 'string' ? message : body.textContent; - announcement.textContent = nick ? `${nick}: ${messageText}` : messageText; - } + let messageText = typeof message === 'string' ? message : body.textContent; + let speaker = nick; + if(serverConnection && peerId === serverConnection.id) + speaker = 'You'; + announceChat(speaker ? `${speaker}: ${messageText}` : messageText); } return; @@ -3014,45 +3070,6 @@ commands.unraise = { } } -/** @returns {boolean} */ -function canFile() { - return false; -} - -function presentFile() { - let input = document.createElement('input'); - input.type = 'file'; - input.accept="audio/*"; - input.onchange = function(e) { - if(!(this instanceof HTMLInputElement)) - throw new Error('Unexpected type for this'); - let files = this.files; - for(let i = 0; i < files.length; i++) { - addFileMedia(files[i]).catch(e => { - console.error(e); - displayError(e); - }); - } - }; - input.click(); -} - -commands.presentfile = { - description: 'broadcast an audio file', - f: (c, r) => { - presentFile(); - }, - predicate: () => { - if(!canFile()) - return 'Your browser does not support presenting arbitrary files'; - if(!serverConnection || !serverConnection.permissions || - serverConnection.permissions.indexOf('present') < 0) - return 'You are not authorised to present.'; - return null; - } -}; - - /** * @param {string} id */ @@ -3355,7 +3372,7 @@ document.getElementById('loginform').onsubmit = async function(e) { presentRequested = 'mike'; else presentRequested = null; - getInputElement('presentoff').checked = true; + getInputElement('presentmike').checked = true; // Connect to the server, gotConnected will join. serverConnect(); diff --git a/static/stats.js b/static/stats.js index f07b033..a683a29 100644 --- a/static/stats.js +++ b/static/stats.js @@ -99,15 +99,6 @@ function formatTrack(table, track) { tr.appendChild(document.createElement('td')); tr.appendChild(document.createElement('td')); let td = document.createElement('td'); - let layer = ''; - if(track.sid || track.maxSid) - layer = layer + `s${track.sid}/${track.maxSid}`; - if(track.tid || track.maxTid) { - if(layer !== '') - layer = layer + '+'; - layer = layer + `t${track.tid}/${track.maxTid}`; - } - td.textContent = layer; tr.appendChild(td); let td2 = document.createElement('td'); if(track.maxBitrate) diff --git a/stats/stats.go b/stats/stats.go index 5404a51..c485227 100644 --- a/stats/stats.go +++ b/stats/stats.go @@ -47,10 +47,6 @@ func (d *Duration) UnmarshalJSON(buf []byte) error { } type Track struct { - Sid *uint8 `json:"sid,omitempty"` - MaxSid *uint8 `json:"maxSid,omitempty"` - Tid *uint8 `json:"tid,omitempty"` - MaxTid *uint8 `json:"maxTid,omitempty"` Bitrate uint64 `json:"bitrate"` MaxBitrate uint64 `json:"maxBitrate,omitempty"` Loss float64 `json:"loss"` diff --git a/todo.txt b/todo.txt index 668f539..83bd296 100644 --- a/todo.txt +++ b/todo.txt @@ -118,7 +118,7 @@ X Verify runtime protocol backward compatibility with Skald is intentionally bro + Remove screenshare backend support + Remove presentation/file-as-video support if it depends on video tracks + Remove background blur support and background-blur-worker.js from the served app -- Remove video fields from hall descriptions, stats, API responses, and protocol docs +X Remove video fields from hall descriptions, stats, API responses, and protocol docs + Check WHIP/WHEP-style endpoints for video assumptions and either make audio-only or remove + Ensure browsers that request audio+video get a working audio-only session or a clear video rejection - Verify two browser clients can join one hall and exchange audio only @@ -151,62 +151,62 @@ X Verify audio-only UI loads without 404s for renamed assets # PHASE 6: RECORDING REWRITE - SINGLE MIXED OGG OPUS # ============================================================================ -- Remove diskwriter dependency on github.com/at-wat/ebml-go and WebM/Matroska writing -- Remove per-track/per-participant WebM recording logic -- Remove video recording fields such as hasVideo, width, height, keyframes, and video MIME handling -- Decide final mixer implementation before coding: ++ Remove diskwriter dependency on github.com/at-wat/ebml-go and WebM/Matroska writing ++ Remove per-track/per-participant WebM recording logic ++ Remove video recording fields such as hasVideo, width, height, keyframes, and video MIME handling +X Decide final mixer implementation before coding: * Decode all active Opus RTP audio to 48 kHz PCM * Maintain a per-source jitter/timing buffer so join/leave does not corrupt the mix * Mix to stereo with clipping prevention or limiting * Insert silence for missing frames rather than blocking the mixer * Encode mixed PCM to Ogg Opus -- Evaluate Go-native decode/encode/container libraries versus ffmpeg subprocess -- If using ffmpeg, add a startup check and clear admin/operator error when ffmpeg is missing -- If using ffmpeg, use nonblocking stdin writes and drain stderr safely -- If using ffmpeg, command shape starts from: ++ Evaluate Go-native decode/encode/container libraries versus ffmpeg subprocess ++ If using ffmpeg, add a startup check and clear admin/operator error when ffmpeg is missing ++ If using ffmpeg, use nonblocking stdin writes and drain stderr safely +X If using ffmpeg, command shape starts from: ffmpeg -f s16le -ar 48000 -ac 2 -i pipe:0 -c:a libopus -b:a 128k output.ogg -- Ensure recording work cannot block WebRTC packet handling -- Name recording files skald-recording-YYYYMMDD-HHMMSS.ogg -- Avoid usernames or live personal identifiers in recording filenames -- Store one recording file per hall recording session -- Close recordings cleanly on explicit stop -- Close recordings cleanly when the hall empties -- Handle participant join/leave during recording without stopping the file -- Handle mute/unmute during recording without stale audio -- Handle recording start when no one is speaking -- Add focused mixer/recording tests with synthetic audio where possible -- Verify produced Ogg Opus file with `ffprobe` or equivalent ++ Ensure recording work cannot block WebRTC packet handling ++ Name recording files skald-recording-YYYYMMDD-HHMMSS.ogg ++ Avoid usernames or live personal identifiers in recording filenames ++ Store one recording file per hall recording session ++ Close recordings cleanly on explicit stop ++ Close recordings cleanly when the hall empties ++ Handle participant join/leave during recording without stopping the file ++ Handle mute/unmute during recording without stale audio +X Handle recording start when no one is speaking +X Add focused mixer/recording tests with synthetic audio where possible +X Verify produced Ogg Opus file with `ffprobe` or equivalent - Verify produced file plays in a standard audio player # ============================================================================ # PHASE 7: CONFIGURATION, INSTALLATION, AND DEPLOYMENT SURFACES # ============================================================================ -- Update command-line flags and help output for Skald and halls -- Update default examples for halls/, data/, recordings/, and skaldctl.json -- Update systemd service examples to skald user, binary, and paths -- Update reverse proxy examples to /hall/ and /skald-api/ -- Update TURN/STUN docs only where project name or URLs changed -- Update Docker/container or packaging files if present later -- Update .gitignore for renamed binaries, config files, and generated artifacts -- Verify there are no stale install commands that build or run skald/skaldctl +X Update command-line flags and help output for Skald and halls +X Update default examples for halls/, data/, recordings/, and skaldctl.json +X Update systemd service examples to skald user, binary, and paths +X Update reverse proxy examples to /hall/ and /skald-api/ +X Update TURN/STUN docs only where project name or URLs changed +X Update Docker/container or packaging files if present later +X Update .gitignore for renamed binaries, config files, and generated artifacts +X Verify there are no stale install commands that build or run skald/skaldctl # ============================================================================ # PHASE 8: DOCUMENTATION # ============================================================================ -- Rewrite README.md to describe Skald as audio-only hall conferencing -- Rewrite skald-install.md for new project name, binary names, and hall paths -- Rewrite skald.md admin/usage docs for audio-only behavior -- Rewrite skald-client.md for audio-only client authors -- Rewrite skald-protocol.md for hall terminology and audio-only tracks -- Rewrite skald-api.md for /skald-api/ and .halls paths -- Remove video, camera, screenshare, simulcast, SVC, and WebM recording instructions -- Add Ogg Opus recording documentation -- Document that incoming video is rejected by design -- Document that Skald protocol/API compatibility is intentionally broken -- Document any required external ffmpeg dependency if the recorder uses ffmpeg -- Check docs for old real-person sample usernames and replace with placeholders +X Rewrite README.md to describe Skald as audio-only hall conferencing +X Rewrite skald-install.md for new project name, binary names, and hall paths +X Rewrite skald.md admin/usage docs for audio-only behavior +X Rewrite skald-client.md for audio-only client authors +X Rewrite skald-protocol.md for hall terminology and audio-only tracks +X Rewrite skald-api.md for /skald-api/ and .halls paths +X Remove video, camera, screenshare, simulcast, SVC, and WebM recording instructions +X Add Ogg Opus recording documentation +X Document that incoming video is rejected by design +X Document that Skald protocol/API compatibility is intentionally broken +X Document any required external ffmpeg dependency if the recorder uses ffmpeg +X Check docs for old real-person sample usernames and replace with placeholders # ============================================================================ # PHASE 9: VERIFICATION AND CLEANUP @@ -221,11 +221,11 @@ X Search source/docs for remaining /group/, .groups, public-groups, galenectl, a X Search source/docs for remaining video, camera, screenshare, simulcast, WebM, and Matroska references X Search source/docs for remaining group/room terminology in user-facing surfaces X Classify allowed survivors as attribution/history/third-party icon names only -- Build skald binary -- Build skaldctl binary +X Build skald binary +X Build skaldctl binary X Run a local server and verify static pages load without 404s -- Test: create a hall with skaldctl -- Test: list halls with skaldctl +X Test: create a hall with skaldctl +X Test: list halls with skaldctl - Test: join one hall from two browsers and confirm two-way audio - Test: attempt to publish video and confirm rejection - Test: start recording and confirm one Ogg Opus file appears @@ -233,6 +233,6 @@ X Run a local server and verify static pages load without 404s - Test: mute/unmute during recording and confirm the output reflects it - Test: leave all participants and confirm recording stops cleanly - Test: play the recording in a standard audio player -- Test: restart server and verify halls/config load from the new paths -- Remove obsolete files that are no longer served or built +X Test: restart server and verify halls/config load from the new paths +X Remove obsolete files that are no longer served or built - Do a final diff review for accidental compatibility shims or stale Skald naming