Latest code. Working voice, still needs some ui cleanup and accessibility fixes but in decent shape.

This commit is contained in:
Storm Dragon
2026-05-19 21:47:31 -04:00
parent 01ce7529eb
commit 6275e3adef
19 changed files with 855 additions and 775 deletions
+2
View File
@@ -2,6 +2,8 @@
/skald /skald
/skaldctl/skaldctl /skaldctl/skaldctl
/data /data
/recordings
/halls/*.json
/halls/**/*.json /halls/**/*.json
/static/**/*.d.ts /static/**/*.d.ts
/static/third-party/tasks-vision /static/third-party/tasks-vision
+11 -5
View File
@@ -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 hall conferencing server. The current development repository is
<https://git.stormux.org/storm/skald>. <https://git.stormux.org/storm/skald>.
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 ## Quick start
```sh ```sh
@@ -11,13 +16,14 @@ git clone https://git.stormux.org/storm/skald
cd skald cd skald
CGO_ENABLED=0 go build -ldflags='-s -w' CGO_ENABLED=0 go build -ldflags='-s -w'
mkdir halls mkdir halls
echo '{"users": {"vimes": {"password":"sybil", "permissions":"op"}}}' > halls/night-watch.json echo '{"users": {"Username": {"password":"Password", "permissions":"op"}}}' > halls/main.json
./skald & ./skald &
``` ```
Point your browser at <https://localhost:8443/hall/night-watch/>, ignore Point your browser at <https://localhost:8443/hall/main/>, ignore the
the unknown certificate warning, and log in with username *vimes* and unknown certificate warning, and log in with username *Username* and
password *sybil*. 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] For full installation instructions, please see the file [skald-install.md][1]
in this directory. in this directory.
@@ -29,7 +35,7 @@ in this directory.
* [skald-client.md][3]: writing clients; * [skald-client.md][3]: writing clients;
* [skald-protocol.md][4]: the client protocol; * [skald-protocol.md][4]: the client protocol;
* [skald-api.md][5]: Skald's administrative API. * [skald-api.md][5]: Skald's administrative API.
## Contributing ## Contributing
Skald is currently being developed at Skald is currently being developed at
+462 -488
View File
File diff suppressed because it is too large Load Diff
+65 -124
View File
@@ -1,10 +1,13 @@
package diskwriter package diskwriter
import ( import (
"encoding/binary"
"os"
"os/exec"
"path/filepath"
"strings"
"testing" "testing"
"time" "time"
"git.stormux.org/storm/skald/rtptime"
) )
func TestSanitise(t *testing.T) { func TestSanitise(t *testing.T) {
@@ -23,151 +26,89 @@ func TestSanitise(t *testing.T) {
} }
} }
func TestAdjustOriginLocalNow(t *testing.T) { func TestOpenDiskFileUsesSkaldOggName(t *testing.T) {
now := time.Now() dir := t.TempDir()
f, err := openDiskFile(dir, "ogg")
c := &diskConn{ if err != nil {
tracks: []*diskTrack{ t.Fatal(err)
{},
},
} }
for _, t := range c.tracks { name := filepath.Base(f.Name())
t.conn = c f.Close()
}
c.tracks[0].setOrigin(132, now, 100)
if !c.originLocal.Equal(now) { if !strings.HasPrefix(name, "skald-recording-") {
t.Errorf("Expected %v, got %v", now, c.originLocal) t.Fatalf("filename %q does not use Skald recording prefix", name)
} }
if !strings.HasSuffix(name, ".ogg") {
if c.originRemote != 0 { t.Fatalf("filename %q does not use Ogg extension", name)
t.Errorf("Expected 0, got %v", c.originRemote)
} }
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
if c.tracks[0].origin != some(132) { t.Fatal(err)
t.Errorf("Expected 132, got %v", value(c.tracks[0].origin))
} }
} }
func TestAdjustOriginLocalEarlier(t *testing.T) { func TestMixFrameClipsAndSumsSources(t *testing.T) {
now := time.Now() a := make(chan []byte, 1)
earlier := now.Add(-time.Second) b := make(chan []byte, 1)
a <- pcmFrame(20000)
b <- pcmFrame(20000)
c := &diskConn{ out := mixFrame([]chan []byte{a, b})
originLocal: earlier, if len(out) != bytesPerFrame {
tracks: []*diskTrack{ t.Fatalf("mixed frame has length %d, expected %d", len(out), bytesPerFrame)
{},
},
}
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)
} }
if c.originRemote != 0 { for i := 0; i < len(out); i += 2 {
t.Errorf("Expected 0, got %v", c.originRemote) got := int16(binary.LittleEndian.Uint16(out[i:]))
} if got != 32767 {
t.Fatalf("sample %d = %d, expected clipped 32767", i/2, got)
if c.tracks[0].origin != some(32) { }
t.Errorf("Expected 32, got %v", value(c.tracks[0].origin))
} }
} }
func TestAdjustOriginLocalLater(t *testing.T) { func TestMixFrameReturnsNilForSilence(t *testing.T) {
now := time.Now() if out := mixFrame(nil); out != nil {
later := now.Add(time.Second) t.Fatalf("expected nil silent frame, got %d bytes", len(out))
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 TestAdjustOriginRemote(t *testing.T) { func TestRecorderCreatesPlayableOgg(t *testing.T) {
now := time.Now() if _, err := exec.LookPath("ffmpeg"); err != nil {
earlier := now.Add(-time.Second) t.Skip("ffmpeg not installed")
c := &diskConn{
tracks: []*diskTrack{
{
remoteNTP: rtptime.TimeToNTP(earlier),
remoteRTP: 32,
},
},
} }
for _, t := range c.tracks { if _, err := exec.LookPath("ffprobe"); err != nil {
t.conn = c t.Skip("ffprobe not installed")
}
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)) r, err := newRecorder(nil, t.TempDir())
if d < -time.Millisecond || d > time.Millisecond { if err != nil {
t.Errorf("Expected %v, got %v (delta %v)", t.Fatal(err)
rtptime.TimeToNTP(now), }
c.originRemote, d) time.Sleep(100 * time.Millisecond)
if err := r.Close(); err != nil {
t.Fatal(err)
} }
if c.tracks[0].origin != some(132) { cmd := exec.Command(
t.Errorf("Expected 132, got %v", value(c.tracks[0].origin)) "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) { func pcmFrame(sample int16) []byte {
now := time.Now() out := make([]byte, bytesPerFrame)
earlier := now.Add(-time.Second) for i := 0; i < len(out); i += 2 {
binary.LittleEndian.PutUint16(out[i:], uint16(sample))
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))
} }
return out
} }
-2
View File
@@ -3,11 +3,9 @@ module git.stormux.org/storm/skald
go 1.21 go 1.21
require ( require (
github.com/at-wat/ebml-go v0.17.1
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/jech/cert v0.0.0-20240301122532-f491cf43a77d 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/ice/v4 v4.0.10
github.com/pion/interceptor v0.1.40 github.com/pion/interceptor v0.1.40
github.com/pion/rtcp v1.2.15 github.com/pion/rtcp v1.2.15
-4
View File
@@ -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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= 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/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 h1:On8Zm5Vs4x9x/cHtH8ZoLrSmGRgNPy8CieEOcJoFubA=
github.com/jech/cert v0.0.0-20240301122532-f491cf43a77d/go.mod h1:ILvE5TtvouQgno/A2RxRuT2qB4/pP1DYXtp6zQcgTUk= 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 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
+15
View File
@@ -676,9 +676,24 @@ func DelClient(c Client) {
g.Name(), "delete", c.Id(), c.Username(), nil, nil, g.Name(), "delete", c.Id(), c.Username(), nil, nil,
) )
} }
closeSystemClientsIfNoUsers(g, clients)
autoLockKick(g) 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 { func (g *Hall) GetClients(except Client) []Client {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
+99
View File
@@ -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" <<EOF
{"users":{"$escapedAdminUsername":{"password":"$escapedAdminPassword","permissions":"op"}},"wildcard-user":{"password":"$escapedGuestPassword","permissions":"present"},"allow-recording":true}
EOF
if [[ ! -x "$binary" ]]; then
echo "Building $binary"
go build -o "$binary" .
fi
if [[ "$httpAddress" == :* ]]; then
urlPort="$httpAddress"
else
urlPort=":${httpAddress##*:}"
fi
protocol="https"
serverMode="HTTPS with Skald's local self-signed certificate"
insecureFlag=()
if [[ "$insecure" == "1" || "$insecure" == "true" || "$insecure" == "yes" ]]; then
protocol="http"
serverMode="insecure HTTP"
insecureFlag=(-insecure)
fi
hostAddress="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
echo "Server mode: $serverMode"
echo "Local URL: $protocol://localhost$urlPort/hall/$hallName/"
if [[ -n "$hostAddress" ]]; then
echo "Local network URL: $protocol://$hostAddress$urlPort/hall/$hallName/"
else
echo "Local network URL: $protocol://<this-machine-ip>$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"
-9
View File
@@ -50,11 +50,6 @@ func (c *webClient) GetStats() *stats.Client {
Id: down.id, Id: down.id,
} }
for _, t := range down.tracks { for _, t := range down.tracks {
layer := t.getLayerInfo()
sid := layer.sid
maxSid := layer.maxSid
tid := layer.tid
maxTid := layer.maxTid
rate, _ := t.rate.Estimate() rate, _ := t.rate.Estimate()
maxRate, _, _ := t.GetMaxBitrate() maxRate, _, _ := t.GetMaxBitrate()
rtt := rtptime.ToDuration(int64(t.getRTT()), rtt := rtptime.ToDuration(int64(t.getRTT()),
@@ -63,10 +58,6 @@ func (c *webClient) GetStats() *stats.Client {
j := time.Duration(jitter) * time.Second / j := time.Duration(jitter) * time.Second /
time.Duration(t.track.Codec().ClockRate) time.Duration(t.track.Codec().ClockRate)
conns.Tracks = append(conns.Tracks, stats.Track{ conns.Tracks = append(conns.Tracks, stats.Track{
Tid: &tid,
MaxTid: &maxTid,
Sid: &sid,
MaxSid: &maxSid,
Bitrate: uint64(rate) * 8, Bitrate: uint64(rate) * 8,
MaxBitrate: maxRate, MaxBitrate: maxRate,
Loss: float64(loss) / 256.0, Loss: float64(loss) / 256.0,
+4
View File
@@ -1657,6 +1657,10 @@ func handleClientMessage(c *webClient, m clientMessage) error {
} }
} }
disk := diskwriter.New(g) disk := diskwriter.New(g)
if err := disk.Start(); err != nil {
disk.Close()
return c.error(err)
}
_, err := hall.AddClient(g.Name(), disk, _, err := hall.AddClient(g.Name(), disk,
hall.ClientCredentials{ hall.ClientCredentials{
System: true, System: true,
+5 -1
View File
@@ -1,7 +1,11 @@
# Skald's administrative API # Skald's administrative API
Skald provides an HTTP-based API that can be used to create halls and 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/ PUT /skald-api/v0/.halls/hallname/
Content-Type: application/json Content-Type: application/json
+30 -7
View File
@@ -58,7 +58,7 @@ chmod go-rw data/key.pem
``` ```
Since certificates are regularly rotated, this should be done in a monthly 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 ### 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: Create an "op", a user with hall moderation privileges:
```sh ```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: Set the new user's password:
```sh ```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 You should now be able to test your Skald installation by pointing a web
@@ -163,8 +163,8 @@ browser at <https://skald.example.org:8443/hall/city-watch/>.
Create an ordinary user: Create an ordinary user:
```sh ```sh
skaldctl create-user -hall city-watch -user fred skaldctl create-user -hall city-watch -user Participant
skaldctl set-password -hall city-watch -user fred skaldctl set-password -hall city-watch -user Participant
``` ```
Check the results: Check the results:
@@ -177,6 +177,18 @@ skaldctl list-users -l -hall city-watch
Type `skaldctl -help`, `skaldctl create-hall -help`, etc. for more Type `skaldctl -help`, `skaldctl create-hall -help`, etc. for more
information. 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 ## Advanced configuration
Skald is designed to be exposed directly to the Internet. If your server 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 Second, and depending on your proxy implementation, you might need to
request that the proxy pass WebSocket handshakes to the URL at `ws`; for proxy normal Skald paths such as `/hall/` and `/skald-api/`, and pass
example, with Nginx, you will need to say something like the following: 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 { location /ws {
proxy_pass https://localhost:8443/ws; proxy_pass https://localhost:8443/ws;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
+4
View File
@@ -1,5 +1,9 @@
# Skald's protocol # 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 ## Data structures
### Hall ### Hall
+23 -6
View File
@@ -230,7 +230,7 @@ skaldctl delete-hall -hall amcw
A user entry is created with the `skaldctl create-user` command: A user entry is created with the `skaldctl create-user` command:
```sh ```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`, 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: is done with the `skaldctl set-password` command:
```sh ```sh
skaldctl set-password -hall city-watch -user vimes skaldctl set-password -hall city-watch -user Username
``` ```
#### The fallback user #### The fallback user
@@ -362,6 +362,8 @@ are optional. The following fields are allowed:
between which joining the hall is allowed; between which joining the hall is allowed;
- `allow-recording`: if true, then recording is allowed in this hall; - `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" - `unrestricted-tokens`: if true, then ordinary users (without the "op"
privilege) are allowed to create tokens; 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 The value of the `codecs` field is an array of codecs allowed in the
hall. Supported audio codecs include `"opus"`, `"g722"`, `"pcmu"` and 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. 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 ## Client Authorisation
Skald implements three authorisation methods: a username/password Skald implements three authorisation methods: a username/password
@@ -435,11 +451,12 @@ For example, the entry
```json ```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 ```json
{ {
@@ -469,7 +486,7 @@ A user entry with a hashed password looks like this:
```json ```json
"users": { "users": {
"vimes": { "Username": {
"password": { "password": {
"type": "pbkdf2", "type": "pbkdf2",
"hash": "sha-256", "hash": "sha-256",
+6 -4
View File
@@ -70,7 +70,8 @@
</button> </button>
<h2 id="chat-history-heading" class="sr-only">Chat History</h2> <h2 id="chat-history-heading" class="sr-only">Chat History</h2>
<div id="box"></div> <div id="box"></div>
<div id="chat-announcements" aria-live="polite" aria-atomic="true" class="sr-only"></div> <div id="chat-announcements" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<div id="audio-streams" aria-hidden="true"></div>
<div class="reply"> <div class="reply">
<form id="inputform"> <form id="inputform">
<textarea id="input" class="form-reply" aria-label="Message or /command"></textarea> <textarea id="input" class="form-reply" aria-label="Message or /command"></textarea>
@@ -105,11 +106,11 @@
<legend>Enable at start:</legend> <legend>Enable at start:</legend>
<div class="present-switch"> <div class="present-switch">
<p class="switch-radio"> <p class="switch-radio">
<input id="presentoff" type="radio" name="presentradio" value="" checked/> <input id="presentoff" type="radio" name="presentradio" value=""/>
<label for="presentoff">Nothing</label> <label for="presentoff">Nothing</label>
</p> </p>
<p class="switch-radio"> <p class="switch-radio">
<input id="presentmike" type="radio" name="presentradio" value="mike"/> <input id="presentmike" type="radio" name="presentradio" value="mike" checked/>
<label for="presentmike">Microphone</label> <label for="presentmike">Microphone</label>
</p> </p>
</div> </div>
@@ -157,7 +158,8 @@
<legend>Media Options</legend> <legend>Media Options</legend>
<label for="audioselect" class="sidenav-label-first">Microphone:</label> <label for="audioselect" class="sidenav-label-first">Microphone:</label>
<select id="audioselect" class="select select-inline"> <select id="audioselect" class="select select-inline">
<option value="">off</option> <option value="default">default microphone</option>
<option value="off">off</option>
</select> </select>
<form> <form>
+83 -66
View File
@@ -800,7 +800,7 @@ let mediaChoicesDone = false;
* Populate the media choices menu. * Populate the media choices menu.
* *
* Since media names might not be available before we call * 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. * to update the menu with user-readable labels.
* *
* @param{boolean} done * @param{boolean} done
@@ -818,10 +818,17 @@ async function setMediaChoices(done) {
return; return;
} }
let cn = 1, mn = 1; let mn = 1;
devices.forEach(d => { devices.forEach(d => {
let label = d.label; let label = d.label;
if(d.kind === 'audioinput' && d.deviceId) {
if(!label)
label = `Microphone ${mn}`;
addSelectOption(getSelectElement('audioselect'),
label, d.deviceId);
mn++;
}
}); });
mediaChoicesDone = done; mediaChoicesDone = done;
@@ -1031,9 +1038,18 @@ async function addLocalMedia(localId) {
let settings = getSettings(); let settings = getSettings();
/** @type{boolean|MediaTrackConstraints} */ /** @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) { if(!settings.preprocessing) {
audio.echoCancellation = false; audio.echoCancellation = false;
audio.noiseSuppression = false; audio.noiseSuppression = false;
@@ -1080,17 +1096,6 @@ async function addLocalMedia(localId) {
} }
setButtonsVisibility(); setButtonsVisibility();
} }
/**
* @param {File} file
*/
async function addFileMedia(file) {
displayError('Local file playback is not supported in audio-only mode');
}
/** /**
* @param {MediaStream} s * @param {MediaStream} s
*/ */
@@ -1182,6 +1187,26 @@ async function setMedia(c) {
if(!stream) if(!stream)
return; return;
c.userdata.media = stream; 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 * @param {Stream} c
*/ */
function resetMedia(c) { function resetMedia(c) {
// Audio-only: no media elements to reset let media = /** @type {HTMLAudioElement} */
return; (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 * @param {string} localId
*/ */
function delMedia(localId) { function delMedia(localId) {
let media = document.getElementById('media-' + localId);
if(media) {
if(media instanceof HTMLMediaElement)
media.srcObject = null;
media.remove();
}
setButtonsVisibility(); setButtonsVisibility();
} }
@@ -1274,12 +1310,20 @@ function setMediaStatus(c) {
} }
if(good) { if(good) {
media.classList.remove('media-failed'); 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 => { media.play().catch(e => {
console.error(e); console.error(e);
displayError(e); displayError(e);
}); });
}
delete(c.userdata.play); delete(c.userdata.play);
} }
} else { } else {
@@ -1500,8 +1544,6 @@ function userMenu(elt) {
inviteMenu(); inviteMenu();
}}); }});
} }
if(serverConnection.permissions.indexOf('present') >= 0 && canFile())
items.push({label: 'Broadcast file', onClick: presentFile});
items.push({label: 'Restart media', onClick: renegotiateStreams}); items.push({label: 'Restart media', onClick: renegotiateStreams});
} else { } else {
items.push({label: 'Send file', onClick: () => { items.push({label: 'Send file', onClick: () => {
@@ -2239,6 +2281,20 @@ function formatTime(time) {
/** @type {lastMessage} */ /** @type {lastMessage} */
let 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} id
* @param {string} peerId * @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) // Announce new messages to screen readers (but not history)
if(!history) { if(!history) {
let announcement = document.getElementById('chat-announcements'); let messageText = typeof message === 'string' ? message : body.textContent;
if(announcement) { let speaker = nick;
let messageText = typeof message === 'string' ? message : body.textContent; if(serverConnection && peerId === serverConnection.id)
announcement.textContent = nick ? `${nick}: ${messageText}` : messageText; speaker = 'You';
} announceChat(speaker ? `${speaker}: ${messageText}` : messageText);
} }
return; 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 * @param {string} id
*/ */
@@ -3355,7 +3372,7 @@ document.getElementById('loginform').onsubmit = async function(e) {
presentRequested = 'mike'; presentRequested = 'mike';
else else
presentRequested = null; presentRequested = null;
getInputElement('presentoff').checked = true; getInputElement('presentmike').checked = true;
// Connect to the server, gotConnected will join. // Connect to the server, gotConnected will join.
serverConnect(); serverConnect();
-9
View File
@@ -99,15 +99,6 @@ function formatTrack(table, track) {
tr.appendChild(document.createElement('td')); tr.appendChild(document.createElement('td'));
tr.appendChild(document.createElement('td')); tr.appendChild(document.createElement('td'));
let td = 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); tr.appendChild(td);
let td2 = document.createElement('td'); let td2 = document.createElement('td');
if(track.maxBitrate) if(track.maxBitrate)
-4
View File
@@ -47,10 +47,6 @@ func (d *Duration) UnmarshalJSON(buf []byte) error {
} }
type Track struct { 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"` Bitrate uint64 `json:"bitrate"`
MaxBitrate uint64 `json:"maxBitrate,omitempty"` MaxBitrate uint64 `json:"maxBitrate,omitempty"`
Loss float64 `json:"loss"` Loss float64 `json:"loss"`
+46 -46
View File
@@ -118,7 +118,7 @@ X Verify runtime protocol backward compatibility with Skald is intentionally bro
+ Remove screenshare backend support + Remove screenshare backend support
+ Remove presentation/file-as-video support if it depends on video tracks + 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 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 + 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 + 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 - 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 # PHASE 6: RECORDING REWRITE - SINGLE MIXED OGG OPUS
# ============================================================================ # ============================================================================
- Remove diskwriter dependency on github.com/at-wat/ebml-go and WebM/Matroska writing + Remove diskwriter dependency on github.com/at-wat/ebml-go and WebM/Matroska writing
- Remove per-track/per-participant WebM recording logic + Remove per-track/per-participant WebM recording logic
- Remove video recording fields such as hasVideo, width, height, keyframes, and video MIME handling + Remove video recording fields such as hasVideo, width, height, keyframes, and video MIME handling
- Decide final mixer implementation before coding: X Decide final mixer implementation before coding:
* Decode all active Opus RTP audio to 48 kHz PCM * 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 * Maintain a per-source jitter/timing buffer so join/leave does not corrupt the mix
* Mix to stereo with clipping prevention or limiting * Mix to stereo with clipping prevention or limiting
* Insert silence for missing frames rather than blocking the mixer * Insert silence for missing frames rather than blocking the mixer
* Encode mixed PCM to Ogg Opus * Encode mixed PCM to Ogg Opus
- Evaluate Go-native decode/encode/container libraries versus ffmpeg subprocess + 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, 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, use nonblocking stdin writes and drain stderr safely
- If using ffmpeg, command shape starts from: 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 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 + Ensure recording work cannot block WebRTC packet handling
- Name recording files skald-recording-YYYYMMDD-HHMMSS.ogg + Name recording files skald-recording-YYYYMMDD-HHMMSS.ogg
- Avoid usernames or live personal identifiers in recording filenames + Avoid usernames or live personal identifiers in recording filenames
- Store one recording file per hall recording session + Store one recording file per hall recording session
- Close recordings cleanly on explicit stop + Close recordings cleanly on explicit stop
- Close recordings cleanly when the hall empties + Close recordings cleanly when the hall empties
- Handle participant join/leave during recording without stopping the file + Handle participant join/leave during recording without stopping the file
- Handle mute/unmute during recording without stale audio + Handle mute/unmute during recording without stale audio
- Handle recording start when no one is speaking X Handle recording start when no one is speaking
- Add focused mixer/recording tests with synthetic audio where possible X Add focused mixer/recording tests with synthetic audio where possible
- Verify produced Ogg Opus file with `ffprobe` or equivalent X Verify produced Ogg Opus file with `ffprobe` or equivalent
- Verify produced file plays in a standard audio player - Verify produced file plays in a standard audio player
# ============================================================================ # ============================================================================
# PHASE 7: CONFIGURATION, INSTALLATION, AND DEPLOYMENT SURFACES # PHASE 7: CONFIGURATION, INSTALLATION, AND DEPLOYMENT SURFACES
# ============================================================================ # ============================================================================
- Update command-line flags and help output for Skald and halls X Update command-line flags and help output for Skald and halls
- Update default examples for halls/, data/, recordings/, and skaldctl.json X Update default examples for halls/, data/, recordings/, and skaldctl.json
- Update systemd service examples to skald user, binary, and paths X Update systemd service examples to skald user, binary, and paths
- Update reverse proxy examples to /hall/ and /skald-api/ X Update reverse proxy examples to /hall/ and /skald-api/
- Update TURN/STUN docs only where project name or URLs changed X Update TURN/STUN docs only where project name or URLs changed
- Update Docker/container or packaging files if present later X Update Docker/container or packaging files if present later
- Update .gitignore for renamed binaries, config files, and generated artifacts X Update .gitignore for renamed binaries, config files, and generated artifacts
- Verify there are no stale install commands that build or run skald/skaldctl X Verify there are no stale install commands that build or run skald/skaldctl
# ============================================================================ # ============================================================================
# PHASE 8: DOCUMENTATION # PHASE 8: DOCUMENTATION
# ============================================================================ # ============================================================================
- Rewrite README.md to describe Skald as audio-only hall conferencing X Rewrite README.md to describe Skald as audio-only hall conferencing
- Rewrite skald-install.md for new project name, binary names, and hall paths X Rewrite skald-install.md for new project name, binary names, and hall paths
- Rewrite skald.md admin/usage docs for audio-only behavior X Rewrite skald.md admin/usage docs for audio-only behavior
- Rewrite skald-client.md for audio-only client authors X Rewrite skald-client.md for audio-only client authors
- Rewrite skald-protocol.md for hall terminology and audio-only tracks X Rewrite skald-protocol.md for hall terminology and audio-only tracks
- Rewrite skald-api.md for /skald-api/ and .halls paths X Rewrite skald-api.md for /skald-api/ and .halls paths
- Remove video, camera, screenshare, simulcast, SVC, and WebM recording instructions X Remove video, camera, screenshare, simulcast, SVC, and WebM recording instructions
- Add Ogg Opus recording documentation X Add Ogg Opus recording documentation
- Document that incoming video is rejected by design X Document that incoming video is rejected by design
- Document that Skald protocol/API compatibility is intentionally broken X Document that Skald protocol/API compatibility is intentionally broken
- Document any required external ffmpeg dependency if the recorder uses ffmpeg X Document any required external ffmpeg dependency if the recorder uses ffmpeg
- Check docs for old real-person sample usernames and replace with placeholders X Check docs for old real-person sample usernames and replace with placeholders
# ============================================================================ # ============================================================================
# PHASE 9: VERIFICATION AND CLEANUP # 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 video, camera, screenshare, simulcast, WebM, and Matroska references
X Search source/docs for remaining group/room terminology in user-facing surfaces 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 X Classify allowed survivors as attribution/history/third-party icon names only
- Build skald binary X Build skald binary
- Build skaldctl binary X Build skaldctl binary
X Run a local server and verify static pages load without 404s X Run a local server and verify static pages load without 404s
- Test: create a hall with skaldctl X Test: create a hall with skaldctl
- Test: list halls with skaldctl X Test: list halls with skaldctl
- Test: join one hall from two browsers and confirm two-way audio - Test: join one hall from two browsers and confirm two-way audio
- Test: attempt to publish video and confirm rejection - Test: attempt to publish video and confirm rejection
- Test: start recording and confirm one Ogg Opus file appears - 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: mute/unmute during recording and confirm the output reflects it
- Test: leave all participants and confirm recording stops cleanly - Test: leave all participants and confirm recording stops cleanly
- Test: play the recording in a standard audio player - Test: play the recording in a standard audio player
- Test: restart server and verify halls/config load from the new paths X Test: restart server and verify halls/config load from the new paths
- Remove obsolete files that are no longer served or built X Remove obsolete files that are no longer served or built
- Do a final diff review for accidental compatibility shims or stale Skald naming - Do a final diff review for accidental compatibility shims or stale Skald naming