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
|
/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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+65
-124
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user