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

This commit is contained in:
Storm Dragon
2026-05-19 21:47:31 -04:00
parent 01ce7529eb
commit 6275e3adef
19 changed files with 855 additions and 775 deletions
+2
View File
@@ -2,6 +2,8 @@
/skald
/skaldctl/skaldctl
/data
/recordings
/halls/*.json
/halls/**/*.json
/static/**/*.d.ts
/static/third-party/tasks-vision
+11 -5
View File
@@ -4,6 +4,11 @@ Skald is a hard fork of Galene that is being turned into an audio-only
hall conferencing server. The current development repository is
<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
View File
File diff suppressed because it is too large Load Diff
+65 -124
View File
@@ -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
}
-2
View File
@@ -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
-4
View File
@@ -1,5 +1,3 @@
github.com/at-wat/ebml-go v0.17.1 h1:pWG1NOATCFu1hnlowCzrA1VR/3s8tPY6qpU+2FwW7X4=
github.com/at-wat/ebml-go v0.17.1/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/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=
+15
View File
@@ -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()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
httpAddress="${SKALD_HTTP:-:8443}"
hallName="${SKALD_TEST_HALL:-test}"
insecure="${SKALD_INSECURE:-0}"
staticDir="${SKALD_STATIC_DIR:-static}"
binary="${SKALD_BINARY:-./skald}"
json_string() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
value="${value//$'\r'/\\r}"
value="${value//$'\t'/\\t}"
printf '%s' "$value"
}
adminUsername="${SKALD_TEST_ADMIN_USER:-admin}"
adminPassword="${SKALD_TEST_ADMIN_PASSWORD:-password}"
guestPassword="${SKALD_TEST_GUEST_PASSWORD:-guest}"
workDir="$(mktemp -d "${TMPDIR:-/tmp}/skald-local.XXXXXXXXXX")"
dataDir="$workDir/data"
hallsDir="$workDir/halls"
recordingsDir="$workDir/recordings"
serverPid=""
cleanup() {
if [[ -n "$serverPid" ]] && kill -0 "$serverPid" 2>/dev/null; then
kill "$serverPid" 2>/dev/null || true
wait "$serverPid" 2>/dev/null || true
fi
rm -rf "$workDir"
}
trap cleanup EXIT INT TERM
mkdir -p "$dataDir" "$hallsDir" "$recordingsDir"
hallFile="$hallsDir/$hallName.json"
escapedAdminUsername="$(json_string "$adminUsername")"
escapedAdminPassword="$(json_string "$adminPassword")"
escapedGuestPassword="$(json_string "$guestPassword")"
cat > "$hallFile" <<EOF
{"users":{"$escapedAdminUsername":{"password":"$escapedAdminPassword","permissions":"op"}},"wildcard-user":{"password":"$escapedGuestPassword","permissions":"present"},"allow-recording":true}
EOF
if [[ ! -x "$binary" ]]; then
echo "Building $binary"
go build -o "$binary" .
fi
if [[ "$httpAddress" == :* ]]; then
urlPort="$httpAddress"
else
urlPort=":${httpAddress##*:}"
fi
protocol="https"
serverMode="HTTPS with Skald's local self-signed certificate"
insecureFlag=()
if [[ "$insecure" == "1" || "$insecure" == "true" || "$insecure" == "yes" ]]; then
protocol="http"
serverMode="insecure HTTP"
insecureFlag=(-insecure)
fi
hostAddress="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
echo "Server mode: $serverMode"
echo "Local URL: $protocol://localhost$urlPort/hall/$hallName/"
if [[ -n "$hostAddress" ]]; then
echo "Local network URL: $protocol://$hostAddress$urlPort/hall/$hallName/"
else
echo "Local network URL: $protocol://<this-machine-ip>$urlPort/hall/$hallName/"
fi
echo "Admin username: $adminUsername"
echo "Admin password: $adminPassword"
echo "Guest password: $guestPassword"
echo "Recording output: $recordingsDir/$hallName/"
echo "Temporary data: $workDir"
if [[ "$protocol" == "https" ]]; then
echo "Browser note: accept the self-signed certificate warning before testing microphone access."
else
echo "Browser note: insecure HTTP is only useful for localhost; remote browsers may block microphone access."
fi
echo "Press Ctrl+C to stop and clean up the temporary hall."
"$binary" \
"${insecureFlag[@]}" \
-http "$httpAddress" \
-data "$dataDir" \
-halls "$hallsDir" \
-recordings "$recordingsDir" \
-static "$staticDir" &
serverPid="$!"
wait "$serverPid"
-9
View File
@@ -50,11 +50,6 @@ func (c *webClient) GetStats() *stats.Client {
Id: down.id,
}
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,
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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
+23 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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();
-9
View File
@@ -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)
-4
View File
@@ -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"`
+46 -46
View File
@@ -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