Latest code. Working voice, still needs some ui cleanup and accessibility fixes but in decent shape.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
/skald
|
||||
/skaldctl/skaldctl
|
||||
/data
|
||||
/recordings
|
||||
/halls/*.json
|
||||
/halls/**/*.json
|
||||
/static/**/*.d.ts
|
||||
/static/third-party/tasks-vision
|
||||
|
||||
@@ -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
|
||||
<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
|
||||
|
||||
```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 <https://localhost:8443/hall/night-watch/>, ignore
|
||||
the unknown certificate warning, and log in with username *vimes* and
|
||||
password *sybil*.
|
||||
Point your browser at <https://localhost:8443/hall/main/>, 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
|
||||
|
||||
+462
-488
File diff suppressed because it is too large
Load Diff
+65
-124
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
Executable
+99
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+30
-7
@@ -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 <https://skald.example.org:8443/hall/city-watch/>.
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
-4
@@ -70,7 +70,8 @@
|
||||
</button>
|
||||
<h2 id="chat-history-heading" class="sr-only">Chat History</h2>
|
||||
<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">
|
||||
<form id="inputform">
|
||||
<textarea id="input" class="form-reply" aria-label="Message or /command"></textarea>
|
||||
@@ -105,11 +106,11 @@
|
||||
<legend>Enable at start:</legend>
|
||||
<div class="present-switch">
|
||||
<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>
|
||||
</p>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
@@ -157,7 +158,8 @@
|
||||
<legend>Media Options</legend>
|
||||
<label for="audioselect" class="sidenav-label-first">Microphone:</label>
|
||||
<select id="audioselect" class="select select-inline">
|
||||
<option value="">off</option>
|
||||
<option value="default">default microphone</option>
|
||||
<option value="off">off</option>
|
||||
</select>
|
||||
|
||||
<form>
|
||||
|
||||
+83
-66
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user