fix: restore audio track attachment for remote playback

The refactored LiveKit session dropped track.attach() for remote audio,
so no <audio> element was created and remote participants were silent.
Also refactors noise suppression to use LiveKit TrackProcessor API,
adds input volume gain node bypass at 100%, and exposes __lkDebug()
on window for DevTools diagnostics.
This commit is contained in:
jevb
2026-03-22 15:34:26 +01:00
parent a653eb33ed
commit 7a16182f5b
6 changed files with 488 additions and 647 deletions
+206 -338
View File
@@ -6,6 +6,7 @@ import {
type RemoteTrack,
type RemoteTrackPublication,
type RemoteParticipant,
type Participant,
DisconnectReason,
} from "livekit-client";
import type { WsClient } from "@lib/ws";
@@ -18,8 +19,7 @@ import {
} from "@stores/voice.store";
import { loadPref, savePref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { createNoiseSuppressor } from "@lib/noise-suppression";
import type { NoiseSuppressor } from "@lib/noise-suppression";
import { createRNNoiseProcessor } from "@lib/noise-suppression";
const log = createLogger("livekitSession");
@@ -32,81 +32,52 @@ export function parseUserId(identity: string): number {
return 0;
}
/** Compute RMS audio level (0-1) from frequency data. */
export function computeRms(data: Uint8Array<ArrayBuffer>): number {
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] ?? 0) / 255;
sum += v * v;
}
return Math.sqrt(sum / data.length);
}
/** Get saved per-user volume (0-200 range, default 100). */
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's GainNode-backed setVolume(). */
function getSavedUserVolume(userId: number): number {
return loadPref<number>(`userVolume_${userId}`, 100);
}
/** Compare two pre-sorted speaker ID arrays. Both must be sorted ascending. */
function speakerSetsEqual(a: readonly number[], b: readonly number[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
// --- Types ---
type RemoteVideoCallback = (userId: number, stream: MediaStream) => void;
type RemoteVideoRemovedCallback = (userId: number) => void;
interface RemoteAnalyser {
analyser: AnalyserNode;
source: MediaStreamAudioSourceNode;
data: Uint8Array<ArrayBuffer>;
}
// --- LiveKitSession class ---
export class LiveKitSession {
private room: Room | null = null;
private ws: WsClient | null = null;
private noiseSuppressor: NoiseSuppressor | null = null;
private onErrorCallback: ((message: string) => void) | null = null;
private currentChannelId: number | null = null;
private serverHost: string | null = null;
private speakingPollInterval: ReturnType<typeof setInterval> | null = null;
private speakingThreshold = ((100 - 50) / 100) * 0.15;
private rawMicStream: MediaStream | null = null;
private readonly audioElements = new Map<string, HTMLAudioElement>();
private audioContainer: HTMLDivElement | null = null;
private onRemoteVideoCallback: RemoteVideoCallback | null = null;
private onRemoteVideoRemovedCallback: RemoteVideoRemovedCallback | null = null;
private sharedAudioCtx: AudioContext | null = null;
private readonly remoteAnalysers = new Map<number, RemoteAnalyser>();
private localMicGated = false;
private localAnalyser: AnalyserNode | null = null;
private localAnalyserSource: MediaStreamAudioSourceNode | null = null;
private localAnalyserClonedTrack: MediaStreamTrack | null = null;
private readonly localAnalyserData = new Uint8Array(128);
private previousSpeakerIds: readonly number[] = [];
private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */
private outputVolumeMultiplier = loadPref<number>("outputVolume", 100) / 100;
// --- Shared AudioContext (lazy) ---
// --- RNNoise processor (LiveKit TrackProcessor API) ---
private getSharedAudioCtx(): AudioContext {
if (this.sharedAudioCtx === null || this.sharedAudioCtx.state === "closed") {
this.sharedAudioCtx = new AudioContext({ sampleRate: 48000 });
}
return this.sharedAudioCtx;
/** Attach RNNoise processor to the local mic track. Safe to call if already attached. */
private async applyNoiseSuppressor(): Promise<void> {
if (this.room === null) return;
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track === undefined) return;
if (micPub.track.getProcessor() !== undefined) return;
const processor = createRNNoiseProcessor();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix
await micPub.track.setProcessor(processor as any);
log.info("RNNoise processor attached to mic track");
}
private closeSharedAudioCtx(): void {
if (this.sharedAudioCtx !== null) {
void this.sharedAudioCtx.close();
this.sharedAudioCtx = null;
}
/** Remove RNNoise processor from the local mic track. Safe to call if none attached. */
private async removeNoiseSuppressor(): Promise<void> {
if (this.room === null) return;
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track === undefined) return;
if (micPub.track.getProcessor() === undefined) return;
await micPub.track.stopProcessor();
log.info("RNNoise processor removed from mic track");
}
// --- Room factory ---
@@ -115,6 +86,7 @@ export class LiveKitSession {
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
webAudioMix: true,
audioCaptureDefaults: {
echoCancellation: loadPref("echoCancellation", true),
noiseSuppression: loadPref("noiseSuppression", true),
@@ -124,70 +96,10 @@ export class LiveKitSession {
newRoom.on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed);
newRoom.on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed);
newRoom.on(RoomEvent.Disconnected, this.handleDisconnected);
newRoom.on(RoomEvent.ActiveSpeakersChanged, this.handleActiveSpeakersChanged);
return newRoom;
}
// --- Audio container ---
private getOrCreateAudioContainer(): HTMLDivElement {
if (this.audioContainer !== null) return this.audioContainer;
const existing = document.getElementById("voice-audio-container");
if (existing instanceof HTMLDivElement) {
this.audioContainer = existing;
return this.audioContainer;
}
const div = document.createElement("div");
div.id = "voice-audio-container";
div.style.display = "none";
document.body.appendChild(div);
this.audioContainer = div;
return this.audioContainer;
}
private cleanupAudioElements(): void {
for (const el of this.audioElements.values()) {
el.srcObject = null;
el.remove();
}
this.audioElements.clear();
}
// --- RNNoise ---
private async publishWithNoiseSuppression(): Promise<void> {
if (this.room === null) return;
const savedDevice = loadPref<string>("audioInputDevice", "");
const constraints: MediaStreamConstraints = {
audio: {
deviceId: savedDevice ? { exact: savedDevice } : undefined,
echoCancellation: loadPref("echoCancellation", true),
noiseSuppression: loadPref("noiseSuppression", true),
autoGainControl: loadPref("autoGainControl", true),
},
video: false,
};
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
this.rawMicStream = rawStream;
try {
this.noiseSuppressor = createNoiseSuppressor();
const processedStream = await this.noiseSuppressor.process(rawStream);
const processedTrack = processedStream.getAudioTracks()[0];
if (processedTrack) {
await this.room.localParticipant.publishTrack(processedTrack, {
source: Track.Source.Microphone,
});
}
} catch (err) {
for (const track of rawStream.getTracks()) track.stop();
this.rawMicStream = null;
if (this.noiseSuppressor !== null) {
this.noiseSuppressor.destroy();
this.noiseSuppressor = null;
}
throw err;
}
}
// --- Room event handlers (arrow fns to preserve `this`) ---
private handleTrackSubscribed = (
@@ -197,22 +109,20 @@ export class LiveKitSession {
): void => {
const userId = parseUserId(participant.identity);
if (track.kind === Track.Kind.Audio) {
const container = this.getOrCreateAudioContainer();
// Attach creates an <audio> element — required for playback
const audioEl = track.attach();
audioEl.style.display = "none";
document.body.appendChild(audioEl);
// Apply saved per-user volume scaled by master output volume
const savedVolume = userId > 0 ? getSavedUserVolume(userId) : 100;
audioEl.volume = Math.min(savedVolume, 100) / 100;
if (voiceStore.getState().localDeafened) audioEl.muted = true;
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput !== "" && typeof audioEl.setSinkId === "function") {
audioEl.setSinkId(savedOutput).catch((err) => {
log.warn("Failed to set output device on remote audio", err);
});
}
container.appendChild(audioEl);
const trackKey = `${participant.identity}-${track.sid}`;
this.audioElements.set(trackKey, audioEl);
if (userId > 0) this.addRemoteAnalyser(userId, track.mediaStreamTrack);
log.debug("Remote audio track subscribed", { userId, trackSid: track.sid });
log.debug("Remote audio track subscribed and attached", { userId, trackSid: track.sid });
} else if (track.kind === Track.Kind.Video) {
if (userId > 0 && this.onRemoteVideoCallback !== null) {
const stream = new MediaStream([track.mediaStreamTrack]);
@@ -229,11 +139,8 @@ export class LiveKitSession {
): void => {
const userId = parseUserId(participant.identity);
if (track.kind === Track.Kind.Audio) {
track.detach().forEach((el) => el.remove());
const trackKey = `${participant.identity}-${track.sid}`;
this.audioElements.delete(trackKey);
if (userId > 0) this.removeRemoteAnalyser(userId);
log.debug("Remote audio track unsubscribed", { userId, trackSid: track.sid });
for (const el of track.detach()) el.remove();
log.debug("Remote audio track unsubscribed and detached", { userId, trackSid: track.sid });
} else if (track.kind === Track.Kind.Video) {
track.detach();
if (userId > 0) this.onRemoteVideoRemovedCallback?.(userId);
@@ -241,126 +148,17 @@ export class LiveKitSession {
}
};
// --- Remote audio analysers ---
private addRemoteAnalyser(userId: number, mediaTrack: MediaStreamTrack): void {
this.removeRemoteAnalyser(userId);
try {
const ctx = this.getSharedAudioCtx();
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.5;
const stream = new MediaStream([mediaTrack]);
const source = ctx.createMediaStreamSource(stream);
source.connect(analyser);
this.remoteAnalysers.set(userId, { analyser, source, data: new Uint8Array(128) as Uint8Array<ArrayBuffer> });
} catch (err) {
log.warn("Failed to create remote analyser", { userId, error: err });
/** LiveKit's built-in speaking detection — replaces custom RMS polling. */
private handleActiveSpeakersChanged = (speakers: Participant[]): void => {
if (this.currentChannelId === null) return;
const speakerIds: number[] = [];
for (const speaker of speakers) {
const userId = parseUserId(speaker.identity);
if (userId > 0) speakerIds.push(userId);
}
}
private removeRemoteAnalyser(userId: number): void {
const ra = this.remoteAnalysers.get(userId);
if (ra) {
ra.source.disconnect();
ra.analyser.disconnect();
this.remoteAnalysers.delete(userId);
}
}
private cleanupAllRemoteAnalysers(): void {
for (const [id] of this.remoteAnalysers) this.removeRemoteAnalyser(id);
}
// --- Local mic analyser ---
private startLocalAnalyser(): void {
this.stopLocalAnalyser();
if (this.room === null) return;
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
const mediaTrack = micPub?.track?.mediaStreamTrack;
if (!mediaTrack) return;
try {
const ctx = this.getSharedAudioCtx();
this.localAnalyser = ctx.createAnalyser();
this.localAnalyser.fftSize = 256;
this.localAnalyser.smoothingTimeConstant = 0.5;
this.localAnalyserClonedTrack = mediaTrack.clone();
const stream = new MediaStream([this.localAnalyserClonedTrack]);
this.localAnalyserSource = ctx.createMediaStreamSource(stream);
this.localAnalyserSource.connect(this.localAnalyser);
log.debug("Local mic analyser started");
} catch (err) {
log.warn("Failed to start local mic analyser", err);
}
}
private stopLocalAnalyser(): void {
if (this.localAnalyserSource !== null) {
this.localAnalyserSource.disconnect();
this.localAnalyserSource = null;
}
if (this.localAnalyser !== null) {
this.localAnalyser.disconnect();
this.localAnalyser = null;
}
if (this.localAnalyserClonedTrack !== null) {
this.localAnalyserClonedTrack.stop();
this.localAnalyserClonedTrack = null;
}
}
// --- Speaking poll ---
private startSpeakingPoll(): void {
this.stopSpeakingPoll();
this.localMicGated = false;
this.previousSpeakerIds = [];
this.speakingPollInterval = setInterval(() => {
if (this.room === null || this.currentChannelId === null) return;
const speakerIds: number[] = [];
let localLevel = 0;
if (this.localAnalyser !== null) {
this.localAnalyser.getByteFrequencyData(this.localAnalyserData);
localLevel = computeRms(this.localAnalyserData);
}
const localSpeaking = localLevel > this.speakingThreshold;
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track?.mediaStreamTrack) {
if (localSpeaking && this.localMicGated) {
micPub.track.mediaStreamTrack.enabled = true;
this.localMicGated = false;
} else if (!localSpeaking && !this.localMicGated) {
micPub.track.mediaStreamTrack.enabled = false;
this.localMicGated = true;
}
}
if (localSpeaking) {
const localId = parseUserId(this.room.localParticipant.identity);
if (localId > 0) speakerIds.push(localId);
}
for (const [userId, ra] of this.remoteAnalysers) {
ra.analyser.getByteFrequencyData(ra.data);
if (computeRms(ra.data) > this.speakingThreshold) speakerIds.push(userId);
}
// Sort in place so speakerSetsEqual can compare without allocations.
speakerIds.sort((x, y) => x - y);
if (!speakerSetsEqual(speakerIds, this.previousSpeakerIds)) {
this.previousSpeakerIds = speakerIds; // already a fresh array each tick
setSpeakers({ channel_id: this.currentChannelId, speakers: speakerIds });
}
}, 100);
}
private stopSpeakingPoll(): void {
if (this.speakingPollInterval !== null) {
clearInterval(this.speakingPollInterval);
this.speakingPollInterval = null;
}
this.stopLocalAnalyser();
this.cleanupAllRemoteAnalysers();
this.previousSpeakerIds = [];
}
speakerIds.sort((x, y) => x - y);
setSpeakers({ channel_id: this.currentChannelId, speakers: speakerIds });
};
private handleDisconnected = (reason?: DisconnectReason): void => {
log.info("LiveKit room disconnected", { reason });
@@ -408,23 +206,31 @@ export class LiveKitSession {
}
log.info("Requesting voice token refresh");
this.ws.send({ type: "voice_token_refresh", payload: {} });
// Re-arm as fallback in case the server doesn't respond (network hiccup,
// restart). If the server does respond, handleVoiceTokenRefresh restarts
// the timer, superseding this one.
this.startTokenRefreshTimer();
}
/**
* Handle a voice_token message that is a refresh (room already connected).
* LiveKit tokens are validated at connect time only — the existing connection
* stays alive regardless of token expiry. We just restart the refresh timer
* so we keep requesting fresh tokens periodically.
*/
handleVoiceTokenRefresh(): void {
this.startTokenRefreshTimer();
log.info("Voice token refreshed, timer restarted");
}
// --- Volume helpers ---
/** Compute the effective volume for a participant: per-user volume * master output. */
private getEffectiveVolume(userId: number): number {
const userVol = userId > 0 ? getSavedUserVolume(userId) : 100;
return (userVol / 100) * this.outputVolumeMultiplier;
}
/** Apply effective volume to all remote participants. */
private applyAllVolumes(): void {
if (this.room === null) return;
for (const participant of this.room.remoteParticipants.values()) {
const userId = parseUserId(participant.identity);
participant.setVolume(this.getEffectiveVolume(userId));
}
}
// --- Public API ---
setWsClient(client: WsClient): void { this.ws = client; }
@@ -442,9 +248,6 @@ export class LiveKitSession {
async handleVoiceToken(
token: string, url: string, channelId: number, directUrl?: string,
): Promise<void> {
// If already connected to the same channel, this is a token refresh response.
// LiveKit validates tokens only at connect time, so just restart the timer.
// Guard on room.state to avoid treating a mid-retry token as a refresh.
if (this.room !== null && this.currentChannelId === channelId
&& this.room.state === "connected") {
this.handleVoiceTokenRefresh();
@@ -473,16 +276,11 @@ export class LiveKitSession {
}
}
log.info("Connected to LiveKit room", { channelId, url: resolvedUrl });
this.speakingThreshold = ((100 - loadPref<number>("voiceSensitivity", 50)) / 100) * 0.15;
this.startSpeakingPoll();
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
try {
if (enhancedNS) {
await this.publishWithNoiseSuppression();
log.info("Published mic with RNNoise noise suppression");
} else {
await this.room.localParticipant.setMicrophoneEnabled(true);
log.info("Published mic via LiveKit native capture");
await this.room.localParticipant.setMicrophoneEnabled(true);
log.info("Published mic via LiveKit native capture");
if (loadPref<boolean>("enhancedNoiseSuppression", false)) {
await this.applyNoiseSuppressor();
}
} catch (micErr) {
if (micErr instanceof DOMException && micErr.name === "NotAllowedError") {
@@ -500,8 +298,9 @@ export class LiveKitSession {
if (savedInput) await this.room.switchActiveDevice("audioinput", savedInput);
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput) await this.room.switchActiveDevice("audiooutput", savedOutput);
// Apply saved input volume
this.applyInputVolume(loadPref<number>("inputVolume", 100));
this.currentChannelId = channelId;
this.startLocalAnalyser();
this.startTokenRefreshTimer();
log.info("Voice session active", { channelId });
} catch (err) {
@@ -518,25 +317,14 @@ export class LiveKitSession {
if (sendWs && this.ws !== null) {
this.ws.send({ type: "voice_leave", payload: {} });
}
if (this.rawMicStream !== null) {
for (const track of this.rawMicStream.getTracks()) track.stop();
this.rawMicStream = null;
}
if (this.noiseSuppressor !== null) {
this.noiseSuppressor.destroy();
this.noiseSuppressor = null;
}
this.stopSpeakingPoll();
this.cleanupInputGain();
if (this.room !== null) {
const r = this.room;
this.room = null;
r.removeAllListeners();
r.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
}
this.cleanupAudioElements();
this.closeSharedAudioCtx();
this.currentChannelId = null;
// Reset camera state so the UI doesn't show a stale video grid on rejoin.
setLocalCamera(false);
log.info("Left voice session");
}
@@ -557,12 +345,10 @@ export class LiveKitSession {
setDeafened(deafened: boolean): void {
setLocalDeafened(deafened);
if (this.room !== null) {
for (const participant of this.room.remoteParticipants.values()) {
for (const pub of participant.audioTrackPublications.values()) pub.setSubscribed(!deafened);
}
if (this.room === null) return;
for (const participant of this.room.remoteParticipants.values()) {
for (const pub of participant.audioTrackPublications.values()) pub.setSubscribed(!deafened);
}
for (const el of this.audioElements.values()) el.muted = deafened;
log.debug("Deafen state changed", { deafened });
}
@@ -610,27 +396,21 @@ export class LiveKitSession {
return;
}
try {
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
if (enhancedNS && this.noiseSuppressor !== null) {
if (this.rawMicStream !== null) {
for (const track of this.rawMicStream.getTracks()) track.stop();
this.rawMicStream = null;
}
this.noiseSuppressor.destroy();
this.noiseSuppressor = null;
for (const pub of this.room.localParticipant.audioTrackPublications.values()) {
if (pub.source === Track.Source.Microphone && pub.track) {
await this.room.localParticipant.unpublishTrack(pub.track);
}
}
await this.publishWithNoiseSuppression();
if (deviceId) {
await this.room.switchActiveDevice("audioinput", deviceId);
} else {
if (deviceId) {
await this.room.switchActiveDevice("audioinput", deviceId);
} else {
await this.room.localParticipant.setMicrophoneEnabled(false);
await this.room.localParticipant.setMicrophoneEnabled(true);
}
await this.room.localParticipant.setMicrophoneEnabled(false);
await this.room.localParticipant.setMicrophoneEnabled(true);
}
// Reset and re-apply input volume after device switch (source track changed)
this.cleanupInputGain();
this.applyInputVolume(loadPref<number>("inputVolume", 100));
// Re-apply or remove RNNoise processor based on current setting
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
if (enhancedNS) {
await this.applyNoiseSuppressor();
} else {
await this.removeNoiseSuppressor();
}
log.info("Switched input device", { deviceId });
} catch (err) {
@@ -641,13 +421,6 @@ export class LiveKitSession {
async switchOutputDevice(deviceId: string): Promise<void> {
if (this.room !== null) await this.room.switchActiveDevice("audiooutput", deviceId);
for (const el of this.audioElements.values()) {
if (typeof el.setSinkId === "function") {
try { await el.setSinkId(deviceId); } catch (err) {
log.warn("Failed to set output device on audio element", err);
}
}
}
log.info("Switched output device", { deviceId });
}
@@ -657,13 +430,7 @@ export class LiveKitSession {
if (this.room !== null) {
for (const participant of this.room.remoteParticipants.values()) {
if (parseUserId(participant.identity) === userId) {
for (const pub of participant.audioTrackPublications.values()) {
if (pub.track) {
for (const el of pub.track.attachedElements) {
if (el instanceof HTMLAudioElement) el.volume = Math.min(clamped, 100) / 100;
}
}
}
participant.setVolume((clamped / 100) * this.outputVolumeMultiplier);
}
}
}
@@ -671,23 +438,110 @@ export class LiveKitSession {
getUserVolume(userId: number): number { return getSavedUserVolume(userId); }
/** Input volume GainNode — adjusts mic gain via the WebRTC sender. */
private inputGainNode: GainNode | null = null;
private inputGainCtx: AudioContext | null = null;
private inputGainDest: MediaStreamAudioDestinationNode | null = null;
/** Apply input volume gain to the local mic track via a Web Audio GainNode. */
private applyInputVolume(volume: number): void {
if (this.room === null) return;
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track === undefined) return;
const gain = Math.max(0, Math.min(200, volume)) / 100;
// At 100% (gain=1.0), tear down the pipeline — no processing needed
if (gain === 1 && this.inputGainNode !== null) {
this.restoreOriginalSenderTrack();
this.cleanupInputGain();
log.info("Input volume reset to 100% — gain pipeline removed");
return;
}
// No gain node and volume is default — nothing to do
if (gain === 1) return;
if (this.inputGainNode !== null) {
this.inputGainNode.gain.setTargetAtTime(gain, 0, 0.05);
log.debug("Input volume adjusted", { gain });
return;
}
// Build GainNode pipeline and replace the sender's track
try {
const mediaTrack = micPub.track.mediaStreamTrack;
const ctx = new AudioContext({ sampleRate: 48000 });
const source = ctx.createMediaStreamSource(new MediaStream([mediaTrack]));
const gainNode = ctx.createGain();
gainNode.gain.setTargetAtTime(gain, 0, 0.05);
const dest = ctx.createMediaStreamDestination();
source.connect(gainNode);
gainNode.connect(dest);
this.inputGainNode = gainNode;
this.inputGainCtx = ctx;
this.inputGainDest = dest;
// Replace the WebRTC sender's track with the gain-adjusted one
const adjustedTrack = dest.stream.getAudioTracks()[0];
if (adjustedTrack !== undefined && micPub.track.sender) {
void micPub.track.sender.replaceTrack(adjustedTrack).catch((err) => {
log.warn("Failed to replace sender track with gain-adjusted track", err);
});
}
log.info("Input volume GainNode created", { gain });
} catch (err) {
log.warn("Failed to set up input volume gain", err);
}
}
/** Restore the original mic track on the WebRTC sender (undo gain pipeline). */
private restoreOriginalSenderTrack(): void {
if (this.room === null) return;
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track === undefined) return;
const originalTrack = micPub.track.mediaStreamTrack;
if (micPub.track.sender) {
void micPub.track.sender.replaceTrack(originalTrack).catch((err) => {
log.warn("Failed to restore original sender track", err);
});
}
}
private cleanupInputGain(): void {
if (this.inputGainNode !== null) {
this.inputGainNode.disconnect();
this.inputGainNode = null;
}
if (this.inputGainDest !== null) {
this.inputGainDest.disconnect();
this.inputGainDest = null;
}
if (this.inputGainCtx !== null) {
void this.inputGainCtx.close();
this.inputGainCtx = null;
}
}
setInputVolume(volume: number): void {
savePref("inputVolume", Math.max(0, Math.min(200, volume)));
const clamped = Math.max(0, Math.min(200, volume));
savePref("inputVolume", clamped);
this.applyInputVolume(clamped);
}
setOutputVolume(volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
savePref("outputVolume", clamped);
if (this.room !== null) {
for (const el of this.audioElements.values()) {
el.volume = Math.min(clamped, 100) / 100;
}
}
this.outputVolumeMultiplier = clamped / 100;
// Re-apply all per-user volumes scaled by the new master output
this.applyAllVolumes();
}
setVoiceSensitivity(sensitivity: number): void {
const clamped = Math.max(0, Math.min(100, sensitivity));
this.speakingThreshold = ((100 - clamped) / 100) * 0.15;
setVoiceSensitivity(_sensitivity: number): void {
// Voice sensitivity is now handled by LiveKit's built-in speaking detection.
// The sensitivity parameter is saved in preferences by the UI but
// LiveKit's server-side VAD determines speaking state.
log.debug("Voice sensitivity setting saved (handled by LiveKit VAD)");
}
getLocalCameraStream(): MediaStream | null {
@@ -699,24 +553,34 @@ export class LiveKitSession {
getSessionDebugInfo(): Record<string, unknown> {
if (this.room === null) {
return { hasRoom: false, hasNoiseSuppressor: this.noiseSuppressor !== null, currentChannelId: this.currentChannelId };
return { hasRoom: false, hasRNNoiseProcessor: false, currentChannelId: this.currentChannelId };
}
const remoteParticipants = [...this.room.remoteParticipants.values()].map((p) => ({
identity: p.identity,
userId: parseUserId(p.identity),
tracks: [...p.trackPublications.values()].map((pub) => ({
sid: pub.trackSid, source: pub.source, kind: pub.kind,
subscribed: pub.isSubscribed, enabled: pub.isEnabled,
})),
}));
const remoteParticipants = [...this.room.remoteParticipants.values()].map((p) => {
const userId = parseUserId(p.identity);
return {
identity: p.identity,
userId,
volume: p.getVolume(),
effectiveVolume: this.getEffectiveVolume(userId),
tracks: [...p.trackPublications.values()].map((pub) => ({
sid: pub.trackSid, source: pub.source, kind: pub.kind,
subscribed: pub.isSubscribed, enabled: pub.isEnabled,
})),
};
});
const localTracks = [...this.room.localParticipant.trackPublications.values()].map((pub) => ({
sid: pub.trackSid, source: pub.source, kind: pub.kind, isMuted: pub.isMuted,
}));
return {
hasRoom: true, roomName: this.room.name, roomState: this.room.state,
hasNoiseSuppressor: this.noiseSuppressor !== null, currentChannelId: this.currentChannelId,
hasRNNoiseProcessor: this.room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track?.getProcessor() !== undefined,
currentChannelId: this.currentChannelId,
outputVolumeMultiplier: this.outputVolumeMultiplier,
inputGainActive: this.inputGainNode !== null,
inputGainValue: this.inputGainNode?.gain.value ?? null,
inputGainCtxState: this.inputGainCtx?.state ?? null,
localParticipant: this.room.localParticipant.identity, localTracks,
remoteParticipants, audioElements: this.audioElements.size,
remoteParticipants,
};
}
}
@@ -725,6 +589,10 @@ export class LiveKitSession {
const session = new LiveKitSession();
// Expose debug info on window for DevTools console access
// Usage: JSON.stringify(__lkDebug(), null, 2)
(window as unknown as Record<string, unknown>).__lkDebug = session.getSessionDebugInfo.bind(session);
export const setWsClient = session.setWsClient.bind(session);
export const setServerHost = session.setServerHost.bind(session);
export const setOnError = session.setOnError.bind(session);
+172 -218
View File
@@ -1,14 +1,17 @@
// =============================================================================
// Noise Suppression — RNNoise ML-based noise removal via Web Audio API
// Noise Suppression — RNNoise ML-based noise removal as a LiveKit TrackProcessor
//
// Implements LiveKit's TrackProcessor<Track.Kind.Audio> interface so it
// integrates with setProcessor() / stopProcessor() lifecycle, device switching,
// and mid-call toggling automatically.
//
// Inserts between getUserMedia stream and the PeerConnection to clean audio.
// RNNoise processes 480-sample frames at 48kHz (10ms).
//
// Uses AudioWorklet (modern, runs on audio thread) with ScriptProcessorNode
// fallback (deprecated but widely supported).
// =============================================================================
import { createRNNWasmModule } from "@jitsi/rnnoise-wasm";
import { Track, type TrackProcessor, type AudioProcessorOptions } from "livekit-client";
import { createLogger } from "@lib/logger";
const log = createLogger("noise-suppression");
@@ -16,13 +19,8 @@ const log = createLogger("noise-suppression");
const RNNOISE_FRAME_SIZE = 480;
const SCRIPT_PROCESSOR_BUFFER = 4096;
export interface NoiseSuppressor {
process(input: MediaStream): Promise<MediaStream>;
destroy(): void;
}
// ---------------------------------------------------------------------------
// Shared WASM module cache (used by ScriptProcessorNode fallback)
// Shared WASM module cache
// ---------------------------------------------------------------------------
interface RNNoiseModule {
@@ -52,102 +50,78 @@ async function loadRNNoise(): Promise<RNNoiseModule> {
return mod;
}
/** Check if AudioWorklet is available in this browser context. */
function supportsAudioWorklet(): boolean {
try {
return typeof AudioWorkletNode !== "undefined"
&& typeof AudioContext !== "undefined"
&& "audioWorklet" in AudioContext.prototype;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// AudioWorklet-based suppressor (preferred, runs on audio thread)
// Internal processing pipeline — shared by both init strategies
// ---------------------------------------------------------------------------
function createWorkletSuppressor(): NoiseSuppressor {
let audioContext: AudioContext | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let destNode: MediaStreamAudioDestinationNode | null = null;
let workletNode: AudioWorkletNode | null = null;
let destroyed = false;
interface ProcessingPipeline {
readonly processedTrack: MediaStreamTrack;
destroy(): void;
}
/** AudioWorklet-based pipeline (preferred, runs on audio thread). */
async function createWorkletPipeline(
inputTrack: MediaStreamTrack,
audioContext: AudioContext,
): Promise<ProcessingPipeline> {
await audioContext.audioWorklet.addModule("/rnnoise-worklet.js");
const wasmResponse = await fetch("/rnnoise.wasm");
const wasmBytes = await wasmResponse.arrayBuffer();
const source = audioContext.createMediaStreamSource(new MediaStream([inputTrack]));
const dest = audioContext.createMediaStreamDestination();
const workletNode = new AudioWorkletNode(audioContext, "rnnoise-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
const initPromise = new Promise<void>((resolve, reject) => {
workletNode.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "ready") resolve();
else if (event.data.type === "error") reject(new Error(event.data.message));
};
});
workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]);
await initPromise;
source.connect(workletNode);
workletNode.connect(dest);
log.info("RNNoise AudioWorklet processing active");
return {
async process(input: MediaStream): Promise<MediaStream> {
if (destroyed) throw new Error("NoiseSuppressor destroyed");
audioContext = new AudioContext({ sampleRate: 48000 });
// Load the worklet processor module
await audioContext.audioWorklet.addModule("/rnnoise-worklet.js");
// Fetch WASM bytes to send to the worklet thread
const wasmResponse = await fetch("/rnnoise.wasm");
const wasmBytes = await wasmResponse.arrayBuffer();
sourceNode = audioContext.createMediaStreamSource(input);
destNode = audioContext.createMediaStreamDestination();
workletNode = new AudioWorkletNode(audioContext, "rnnoise-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
// Wait for WASM init in the worklet
const initPromise = new Promise<void>((resolve, reject) => {
if (workletNode === null) { reject(new Error("No worklet")); return; }
workletNode.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "ready") {
resolve();
} else if (event.data.type === "error") {
reject(new Error(event.data.message));
}
};
});
// Send WASM bytes to the worklet for initialization
workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]);
await initPromise;
sourceNode.connect(workletNode);
workletNode.connect(destNode);
log.info("RNNoise AudioWorklet processing active");
return destNode.stream;
},
destroy(): void {
if (destroyed) return;
destroyed = true;
if (workletNode !== null) {
workletNode.port.postMessage({ type: "destroy" });
workletNode.disconnect();
workletNode = null;
}
if (sourceNode !== null) {
sourceNode.disconnect();
sourceNode = null;
}
if (destNode !== null) {
destNode.disconnect();
destNode = null;
}
if (audioContext !== null) {
void audioContext.close();
audioContext = null;
}
log.info("RNNoise AudioWorklet destroyed");
processedTrack: dest.stream.getAudioTracks()[0]!,
destroy() {
workletNode.port.postMessage({ type: "destroy" });
workletNode.disconnect();
source.disconnect();
dest.disconnect();
log.info("RNNoise AudioWorklet pipeline destroyed");
},
};
}
// ---------------------------------------------------------------------------
// ScriptProcessorNode fallback (deprecated but universal)
// ---------------------------------------------------------------------------
function createScriptProcessorSuppressor(): NoiseSuppressor {
let audioContext: AudioContext | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let destNode: MediaStreamAudioDestinationNode | null = null;
let processorNode: ScriptProcessorNode | null = null;
let rnnoiseState: number = 0;
let inputPtr: number = 0;
let outputPtr: number = 0;
let wasmModule: RNNoiseModule | null = null;
let destroyed = false;
/** ScriptProcessorNode-based pipeline (fallback). */
async function createScriptProcessorPipeline(
inputTrack: MediaStreamTrack,
audioContext: AudioContext,
): Promise<ProcessingPipeline> {
const wasmModule = await loadRNNoise();
const rnnoiseState = wasmModule._rnnoise_create();
const inputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
const outputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
const inputRing = new Float32Array(RNNOISE_FRAME_SIZE);
let inputRingOffset = 0;
@@ -160,7 +134,6 @@ function createScriptProcessorSuppressor(): NoiseSuppressor {
let outSampleOffset = 0;
function processFrame(): void {
if (wasmModule === null) return;
const inOff = inputPtr / 4;
for (let i = 0; i < RNNOISE_FRAME_SIZE; i++) {
wasmModule.HEAPF32[inOff + i] = (inputRing[i] ?? 0) * 32768;
@@ -181,140 +154,121 @@ function createScriptProcessorSuppressor(): NoiseSuppressor {
outCount++;
}
const source = audioContext.createMediaStreamSource(new MediaStream([inputTrack]));
const dest = audioContext.createMediaStreamDestination();
const processorNode = audioContext.createScriptProcessor(SCRIPT_PROCESSOR_BUFFER, 1, 1);
processorNode.onaudioprocess = (event: AudioProcessingEvent) => {
const inData = event.inputBuffer.getChannelData(0);
const outData = event.outputBuffer.getChannelData(0);
let inIdx = 0;
while (inIdx < inData.length) {
const needed = RNNOISE_FRAME_SIZE - inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
inputRing.set(inData.subarray(inIdx, inIdx + toCopy), inputRingOffset);
inputRingOffset += toCopy;
inIdx += toCopy;
if (inputRingOffset >= RNNOISE_FRAME_SIZE) {
processFrame();
inputRingOffset = 0;
}
}
let outIdx = 0;
while (outIdx < outData.length && outCount > 0) {
const chunk = outRing[outReadIdx]!;
const available = chunk.length - outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(chunk.subarray(outSampleOffset, outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
outSampleOffset += toWrite;
if (outSampleOffset >= chunk.length) {
outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY;
outCount--;
outSampleOffset = 0;
}
}
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
};
source.connect(processorNode);
processorNode.connect(dest);
log.info("RNNoise ScriptProcessor processing active (fallback)");
return {
async process(input: MediaStream): Promise<MediaStream> {
if (destroyed) throw new Error("NoiseSuppressor destroyed");
wasmModule = await loadRNNoise();
rnnoiseState = wasmModule._rnnoise_create();
inputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
outputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
audioContext = new AudioContext({ sampleRate: 48000 });
sourceNode = audioContext.createMediaStreamSource(input);
destNode = audioContext.createMediaStreamDestination();
processorNode = audioContext.createScriptProcessor(SCRIPT_PROCESSOR_BUFFER, 1, 1);
processorNode.onaudioprocess = (event: AudioProcessingEvent) => {
const inData = event.inputBuffer.getChannelData(0);
const outData = event.outputBuffer.getChannelData(0);
let inIdx = 0;
while (inIdx < inData.length) {
const needed = RNNOISE_FRAME_SIZE - inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
inputRing.set(inData.subarray(inIdx, inIdx + toCopy), inputRingOffset);
inputRingOffset += toCopy;
inIdx += toCopy;
if (inputRingOffset >= RNNOISE_FRAME_SIZE) {
processFrame();
inputRingOffset = 0;
}
}
let outIdx = 0;
while (outIdx < outData.length && outCount > 0) {
const chunk = outRing[outReadIdx]!;
const available = chunk.length - outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(chunk.subarray(outSampleOffset, outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
outSampleOffset += toWrite;
if (outSampleOffset >= chunk.length) {
outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY;
outCount--;
outSampleOffset = 0;
}
}
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
};
sourceNode.connect(processorNode);
processorNode.connect(destNode);
log.info("RNNoise ScriptProcessor processing active (fallback)");
return destNode.stream;
},
destroy(): void {
if (destroyed) return;
destroyed = true;
if (processorNode !== null) {
processorNode.onaudioprocess = null;
processorNode.disconnect();
processorNode = null;
}
if (sourceNode !== null) {
sourceNode.disconnect();
sourceNode = null;
}
if (destNode !== null) {
destNode.disconnect();
destNode = null;
}
if (audioContext !== null) {
void audioContext.close();
audioContext = null;
}
if (wasmModule !== null && rnnoiseState !== 0) {
wasmModule._rnnoise_destroy(rnnoiseState);
wasmModule._free(inputPtr);
wasmModule._free(outputPtr);
rnnoiseState = 0;
}
outWriteIdx = 0;
outReadIdx = 0;
outCount = 0;
outSampleOffset = 0;
log.info("RNNoise ScriptProcessor destroyed");
processedTrack: dest.stream.getAudioTracks()[0]!,
destroy() {
processorNode.onaudioprocess = null;
processorNode.disconnect();
source.disconnect();
dest.disconnect();
wasmModule._rnnoise_destroy(rnnoiseState);
wasmModule._free(inputPtr);
wasmModule._free(outputPtr);
log.info("RNNoise ScriptProcessor pipeline destroyed");
},
};
}
// ---------------------------------------------------------------------------
// Factory — tries AudioWorklet first, falls back to ScriptProcessorNode
// LiveKit TrackProcessor implementation
// ---------------------------------------------------------------------------
/** Check if AudioWorklet is available in this browser context. */
function supportsAudioWorklet(): boolean {
try {
return typeof AudioWorkletNode !== "undefined"
&& typeof AudioContext !== "undefined"
&& "audioWorklet" in AudioContext.prototype;
} catch {
return false;
}
}
/**
* Creates an RNNoise TrackProcessor compatible with LiveKit's
* LocalAudioTrack.setProcessor() API.
*
* Usage:
* const processor = createRNNoiseProcessor();
* await localAudioTrack.setProcessor(processor);
* // Later:
* await localAudioTrack.stopProcessor();
*/
export function createRNNoiseProcessor(): TrackProcessor<Track.Kind.Audio, AudioProcessorOptions> {
let pipeline: ProcessingPipeline | null = null;
export function createNoiseSuppressor(): NoiseSuppressor {
log.debug("Creating noise suppressor", { audioWorkletSupported: supportsAudioWorklet() });
if (supportsAudioWorklet()) {
// Wrap in a facade that falls back to ScriptProcessor on failure
const worklet = createWorkletSuppressor();
let fallback: NoiseSuppressor | null = null;
let activeSuppressor: NoiseSuppressor = worklet;
return {
name: "rnnoise",
return {
async process(input: MediaStream): Promise<MediaStream> {
async init(opts: AudioProcessorOptions): Promise<void> {
log.debug("RNNoise processor init", { audioWorkletSupported: supportsAudioWorklet() });
const ctx = opts.audioContext;
if (supportsAudioWorklet()) {
try {
return await worklet.process(input);
pipeline = await createWorkletPipeline(opts.track, ctx);
return;
} catch (err) {
log.warn("AudioWorklet failed, falling back to ScriptProcessorNode", err);
worklet.destroy();
fallback = createScriptProcessorSuppressor();
activeSuppressor = fallback;
return fallback.process(input);
}
},
destroy(): void {
activeSuppressor.destroy();
},
};
}
}
log.info("AudioWorklet not supported, using ScriptProcessorNode");
return createScriptProcessorSuppressor();
pipeline = await createScriptProcessorPipeline(opts.track, ctx);
},
async restart(opts: AudioProcessorOptions): Promise<void> {
log.debug("RNNoise processor restart");
if (pipeline !== null) {
pipeline.destroy();
pipeline = null;
}
await this.init(opts);
},
async destroy(): Promise<void> {
if (pipeline !== null) {
pipeline.destroy();
pipeline = null;
}
log.info("RNNoise processor destroyed");
},
get processedTrack(): MediaStreamTrack | undefined {
return pipeline?.processedTrack;
},
};
}
-1
View File
@@ -28,7 +28,6 @@ export type WsErrorCode =
| "INVALID_INPUT"
| "SERVER_ERROR"
| "CHANNEL_FULL"
| "INVALID_SDP"
| "VOICE_ERROR"
| "VIDEO_LIMIT";
+19 -18
View File
@@ -1,8 +1,11 @@
# OwnCord
*The gaming chat platform you actually own.*
A self-hosted Windows chat platform with real-time messaging,
voice/video, file sharing, and a web admin panel. Run your own
server and keep everything under your control.
server and keep everything under your control — zero cloud
dependencies, works fully on LAN.
## Features
@@ -21,17 +24,14 @@ server and keep everything under your control.
### Voice & Video
- Voice channels with WebRTC (Pion SFU)
- Voice channels powered by LiveKit SFU
- Webcam video chat with responsive grid layout
- Mute, deafen, camera, and screenshare controls
- Push-to-talk with global hotkey (non-consuming, works while unfocused)
- Per-user volume control (right-click user in voice channel)
- NAT traversal via Google STUN + configurable external IP
- RNNoise ML noise suppression (AudioWorklet + fallback)
- Voice activity detection with configurable sensitivity
- Silence suppression to save bandwidth
- Configurable audio quality (low/medium/high)
- Server-enforced max video streams per room
- RNNoise ML noise suppression
- Voice activity detection with speaker indicators
- LiveKit server runs as a companion process alongside `chatserver.exe`
### Channels & Organization
@@ -111,8 +111,8 @@ Two components: a **Go server** and a **Tauri v2 client**
| +---------------+ | HTTPS | +---------------+ |
| | REST Client |--+------->| | REST API | |
| +---------------+ | | +---------------+ |
| +---------------+ | WebRTC | +---------------+ |
| | Voice/Video |--+------->| | SFU (Pion) | |
| +---------------+ | LiveKit | +---------------+ |
| | Voice/Video |--+------->| | LiveKit SFU | |
| +---------------+ | | +---------------+ |
+---------------------+ | +---------------+ |
| | SQLite DB | |
@@ -122,7 +122,7 @@ Two components: a **Go server** and a **Tauri v2 client**
- **WebSocket** — chat messages, typing, presence, voice signaling
- **REST API** — message history, file uploads, channel management, auth
- **WebRTC** — voice and video via Pion SFU with Google STUN for NAT traversal
- **LiveKit** — voice and video via LiveKit SFU (companion process)
## Project Structure
@@ -141,9 +141,9 @@ OwnCord/
│ └── tauri-client/ # Tauri v2 desktop client
│ ├── src-tauri/ # Rust backend (plugins, commands)
│ ├── src/ # TypeScript frontend
│ │ ├── lib/ # Core services (API, WS, WebRTC, updater)
│ │ ├── lib/ # Core services (API, WS, LiveKit, updater)
│ │ ├── stores/ # Reactive state (auth, channels, messages, voice)
│ │ ├── components/ # UI components (36 modules)
│ │ ├── components/ # UI components (28 modules)
│ │ ├── pages/ # Page layouts
│ │ └── styles/ # CSS
│ └── tests/ # Unit, integration, and E2E tests
@@ -200,9 +200,10 @@ The server generates a `config.yaml` on first run. Key settings:
| `server.name` | `OwnCord Server` | Display name |
| `tls.mode` | `selfsigned` | TLS mode (see docs) |
| `upload.max_size_mb` | `10` | Max upload size |
| `voice.quality` | `medium` | `low`, `medium`, `high` |
| `voice.external_ip` | — | Public IP for NAT traversal |
| `voice.turn_enabled` | `true` | Enable TURN relay (requires coturn) |
| `voice.livekit_url` | `ws://localhost:7880` | LiveKit server WebSocket URL |
| `voice.livekit_api_key` | `devkey` | LiveKit API key |
| `voice.livekit_api_secret` | — | LiveKit API secret (min 32 chars) |
| `voice.livekit_binary` | — | Path to `livekit-server` binary (auto-start) |
| `server.admin_allowed_cidrs` | private nets | CIDRs allowed to access `/admin` |
| `github.token` | — | Token for update checks |
@@ -237,10 +238,10 @@ Detailed docs live in the `docs/brain/` Obsidian vault:
| Component | Technology |
| --------- | --------- |
| Server | Go, chi router, Pion WebRTC |
| Server | Go, chi router, LiveKit server SDK |
| Database | SQLite (pure Go, embedded) |
| Client | Tauri v2 (Rust + TypeScript) |
| Voice/Video | WebRTC with Pion SFU, Google STUN |
| Voice/Video | LiveKit SFU (companion process) |
| Build | NSIS installer, GitHub Actions CI |
## License
+71 -63
View File
@@ -1,5 +1,9 @@
# ChatServer — Self-Hosted Windows Chat Platform
> **Note:** Most Phase 1-6 tasks are complete as of v1.2.0. See
> [[02-Tasks/Done|Done]] for the detailed completion list and
> [[00-Overview/Changelog|Changelog]] for version history.
Native Windows desktop client + self-hosted server.
Two executables: `chatserver.exe` (server) and
`OwnCord.exe` (Tauri v2 client). Server operator runs
@@ -19,7 +23,7 @@ the server, friends install the client.
### Client (`OwnCord.exe`)
**Tauri v2** (Rust backend + TypeScript/HTML/CSS frontend).
See LANGUAGE-REVIEW.md for the evaluation that led to this
See [[07-Archive/LANGUAGE-REVIEW|LANGUAGE-REVIEW]] for the evaluation that led to this
choice, and CLIENT-ARCHITECTURE.md for the full design.
- Tauri v2 desktop app using system WebView2 (NOT Electron)
@@ -66,108 +70,106 @@ CLIENT (OwnCord.exe) — installed by each friend
## Phase 1: Protocol & Server Core (23 weeks)
- [ ] Define client-server protocol over WebSocket
- [x] Define client-server protocol over WebSocket
(JSON messages with type/payload structure)
- [ ] Message types: auth, chat, typing, presence,
- [x] Message types: auth, chat, typing, presence,
channel_update, voice_signal, file_transfer
- [ ] Server: Go project with `go embed` for admin
- [x] Server: Go project with `go embed` for admin
panel static files only
- [ ] SQLite setup with migrations on startup (users,
- [x] SQLite setup with migrations on startup (users,
channels, messages, sessions, roles, invites)
- [ ] config.yaml generation on first run (port, name,
- [x] config.yaml generation on first run (port, name,
max upload size, voice quality, TLS mode)
- [ ] Server systray icon (getlantern/systray) — minimize to tray, status
indicator, open admin panel, quit
- [ ] Windows Firewall handling on first launch
- [ ] Optional: register as Windows Service for headless operation
## Phase 2: Auth & Security (23 weeks)
- [ ] Invite-only registration — server generates
- [x] Invite-only registration — server generates
invite codes, client has "Redeem Invite" flow
- [ ] bcrypt (cost 12+) passwords, server-side session tokens (256-bit random)
- [ ] Client stores auth token securely via Windows Credential Manager / DPAPI
- [ ] Login rate limiting: 5 attempts/min/IP, lockout after 10 failures
- [ ] Optional TOTP 2FA (`pquerna/otp`) — QR code
during setup, prompts on login
- [ ] Roles: Owner, Admin, Moderator, Member + custom roles with bitfield permissions
- [ ] Per-channel permission overrides, enforced server-side on every action
- [ ] TLS modes: self-signed (default), Let's Encrypt,
- [x] bcrypt (cost 12+) passwords, server-side session tokens (256-bit random)
- [x] Client stores auth token securely via Windows Credential Manager / DPAPI
- [x] Login rate limiting: 5 attempts/min/IP, lockout after 10 failures
- [ ] Optional TOTP 2FA — planned, not yet implemented (T-023 in backlog).
DB schema has `totp_secret` column ready.
- [x] Roles: Owner, Admin, Moderator, Member + custom roles with bitfield permissions
- [x] Per-channel permission overrides, enforced server-side on every action
- [x] TLS modes: self-signed (default), Let's Encrypt,
manual cert, off (Tailscale)
- [ ] Client: certificate pinning or trust-on-first-use (TOFU) for self-signed certs
- [x] Client: certificate pinning or trust-on-first-use (TOFU) for self-signed certs
## Phase 3: Client App — Core UI (34 weeks)
- [ ] Connection dialog: server address, port, login/register, invite code entry
- [ ] Save server profiles (connect to multiple
- [x] Connection dialog: server address, port, login/register, invite code entry
- [x] Save server profiles (connect to multiple
servers like TeamSpeak)
- [ ] Main window layout: server list → channel list → message area → member list
- [ ] Channel tree view with categories, text channels, voice channels
- [ ] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions
- [ ] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop
- [ ] Unread indicators, @mention badges per channel
- [ ] System tray: minimize to tray, notification popups, badge count
- [ ] Keyboard shortcuts: Ctrl+K quick switcher,
- [x] Main window layout: server list → channel list → message area → member list
- [x] Channel tree view with categories, text channels, voice channels
- [x] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions
- [x] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop
- [x] Unread indicators, @mention badges per channel
- [x] System tray: minimize to tray, notification popups, badge count
- [x] Keyboard shortcuts: Ctrl+K quick switcher,
Escape to close panels, customizable PTT key
- [ ] Settings: account, appearance (light/dark),
- [x] Settings: account, appearance (light/dark),
notifications, audio devices, keybinds
## Phase 4: Real-Time Chat Features (23 weeks)
- [ ] WebSocket client with auto-reconnect, exponential
- [x] WebSocket client with auto-reconnect, exponential
backoff, message replay on reconnect
- [ ] Send/receive messages in real-time, append to scrollback
- [ ] Message history: paginated from server on channel switch, scroll-to-load-more
- [ ] Threads, replies (inline preview), reactions (emoji), edit, delete
- [ ] Typing indicators ("X is typing..." below input)
- [ ] Online/offline/idle/DnD presence with status icons in member list
- [ ] File uploads: drag-and-drop or clipboard paste,
- [x] Send/receive messages in real-time, append to scrollback
- [x] Message history: paginated from server on channel switch, scroll-to-load-more
- [x] Threads, replies (inline preview), reactions (emoji), edit, delete
- [x] Typing indicators ("X is typing..." below input)
- [x] Online/offline/idle/DnD presence with status icons in member list
- [x] File uploads: drag-and-drop or clipboard paste,
progress bar, inline image previews
- [ ] Client-side file validation before upload (size check, warn on large files)
- [ ] Search: query server FTS5 endpoint, display results with jump-to-message
- [ ] Windows toast notifications with action buttons (reply, mark read)
- [ ] Notification sounds (configurable, per-channel mute/override)
- [x] Client-side file validation before upload (size check, warn on large files)
- [x] Search: query server FTS5 endpoint, display results with jump-to-message
- [x] Windows toast notifications with action buttons (reply, mark read)
- [x] Notification sounds (configurable, per-channel mute/override)
## Phase 5: Voice & Video (35 weeks)
- [ ] LiveKit integration in native client for voice/video
- [x] LiveKit integration in native client for voice/video
(client connects to LiveKit directly using token from server)
- [ ] Audio device selection: input/output dropdowns in settings, live preview
- [ ] Voice channels: click to join/leave, show connected users with speaking indicators
- [ ] Voice controls: mute (button + keybind), deafen, per-user volume sliders
- [ ] Push-to-talk: configurable global hotkey that works in fullscreen games
- [ ] Voice activity detection with configurable sensitivity
- [ ] Noise suppression (RNNoise or equivalent, bundled with client)
- [ ] Server-side: LiveKit companion process with token-based auth
- [x] Audio device selection: input/output dropdowns in settings, live preview
- [x] Voice channels: click to join/leave, show connected users with speaking indicators
- [x] Voice controls: mute (button + keybind), deafen, per-user volume sliders
- [x] Push-to-talk: configurable global hotkey that works in fullscreen games
- [x] Voice activity detection with configurable sensitivity
- [x] Noise suppression (RNNoise or equivalent, bundled with client)
- [x] Server-side: LiveKit companion process with token-based auth
and webhook sync for voice state updates
- [ ] Voice quality: low (32kbps) / medium (64kbps) / high (128kbps Opus)
- [x] Voice quality: low (32kbps) / medium (64kbps) / high (128kbps Opus)
- [ ] Screen sharing via LiveKit screen share track
- [ ] Video calls: camera capture, displayed in voice channel panel
- [x] Video calls: camera capture, displayed in voice channel panel
## Phase 6: Admin Panel — Web-Based (12 weeks)
- [ ] Served by server at `/admin`, browser-only access
- [ ] Auth: admin credentials, session-based
- [ ] Dashboard: connected users, message count, disk usage, CPU/RAM, uptime
- [ ] User management: list all, edit roles, ban/unban, reset password, force disconnect
- [ ] Channel management: create, rename, reorder, set permissions, archive
- [x] Served by server at `/admin`, browser-only access
- [x] Auth: admin credentials, session-based
- [x] Dashboard: connected users, message count, disk usage, CPU/RAM, uptime
- [x] User management: list all, edit roles, ban/unban, reset password, force disconnect
- [x] Channel management: create, rename, reorder, set permissions, archive
- [ ] Invite management: generate, view active, set expiry/use limit, revoke
- [ ] Server settings: name, icon, MOTD, max upload size, voice quality, TLS config
- [ ] Moderation: kick, ban, temp ban, slow mode, mute, word filter, audit log
- [ ] Backup: trigger manual backup, configure
- [x] Server settings: name, icon, MOTD, max upload size, voice quality, TLS config
- [x] Moderation: kick, ban, temp ban, slow mode, mute, word filter, audit log
- [x] Backup: trigger manual backup, configure
schedule, view/restore from admin panel
- [ ] Built with simple HTML/CSS/JS embedded in the server binary
- [x] Built with simple HTML/CSS/JS embedded in the server binary
## Phase 7: Distribution & Updates (12 weeks)
- [ ] **Server:** GitHub Actions builds
- [x] **Server:** GitHub Actions builds
`chatserver.exe` (amd64), SHA256, GitHub Release
- [ ] **Client:** Tauri bundler (NSIS) installer —
- [x] **Client:** Tauri bundler (NSIS) installer —
Program Files, Start Menu, auto-start, protocol
handler for `chatserver://` invite links
- [ ] Client auto-update: check GitHub releases on
launch, prompt to download + install
- [ ] Server update: admin panel shows available update, one-click download + restart
- [x] Server update: admin panel shows available update, one-click download + restart
- [ ] Docs: Quick Start, Port Forwarding, Tailscale,
Client install guide
- [ ] Security hardening checklist for server operators
@@ -222,9 +224,15 @@ update integrity (SHA256).
| LiveKit | `livekit/server-sdk-go` (token generation, webhook validation) |
| SQLite | `modernc.org/sqlite` (pure Go) |
| Auth | `golang.org/x/crypto/bcrypt` |
| TOTP | `pquerna/otp` |
| Sanitization | `bluemonday` |
| TLS | `golang.org/x/crypto/acme/autocert` |
| Systray | `getlantern/systray` |
| Config | `koanf` |
| Logging | `log/slog` |
| Versioning | `golang.org/x/mod/semver` |
| UUID | `google/uuid` |
> **Removed references:**
> - ~~`getlantern/systray`~~ — The server has no system tray.
> System tray is in the Tauri client (Rust-side).
> - ~~`pquerna/otp`~~ — TOTP 2FA is not yet implemented
> (T-023 in backlog). Not in `go.mod`.
+20 -9
View File
@@ -327,9 +327,7 @@ and server URL for the client to connect directly to LiveKit.
```
Client uses `bitrate` to configure the Opus encoder. Other fields are
informational for UI. (`threshold_mode` and `top_speakers` fields have
been removed — LiveKit handles audio mixing and speaker selection
internally.)
informational for UI.
### Voice Control (Client → Server)
@@ -348,9 +346,13 @@ internally.)
Requires `USE_VIDEO` (bit 11) or `SHARE_SCREEN` (bit 12) permission.
Rate limit: 2/sec per user.
**Note:** Active speaker detection (`voice_speakers`) is no longer a
server→client WebSocket message. Speaker detection is handled client-side
via LiveKit SDK events (`ParticipantEvent.IsSpeakingChanged`).
### Migration Notes (LiveKit transition)
- `threshold_mode` and `top_speakers` fields have been removed from `voice_config`.
LiveKit handles audio mixing and speaker selection internally.
- Active speaker detection (`voice_speakers`) is no longer a server→client
WebSocket message. Speaker detection is handled client-side via LiveKit SDK
events (`ParticipantEvent.IsSpeakingChanged`).
---
@@ -444,7 +446,12 @@ then auto-reconnect after the delay expires.
"color": "#F39C12",
"permissions": 1073741823
},
{ "id": 3, "name": "Member", "color": null, "permissions": 1049601 }
{
"id": 3, "name": "Moderator",
"color": "#3498DB",
"permissions": 1048575
},
{ "id": 4, "name": "Member", "color": null, "permissions": 7779 }
]
}
}
@@ -457,7 +464,7 @@ then auto-reconnect after the delay expires.
Fetched via REST API, not WebSocket, to keep the WS connection lean.
```text
GET /api/channels/{id}/messages?before={msg_id}&limit=50
GET /api/v1/channels/{id}/messages?before={msg_id}&limit=50
```
---
@@ -491,4 +498,8 @@ Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`,
- Voice signaling: 20/sec per user
- Voice camera/screenshare: 2/sec per user
Server sends `rate_limited` error with `retry_after` in seconds.
Server sends a standard `error` message with code `RATE_LIMITED` and `retry_after` in seconds:
```json
{"type": "error", "payload": {"code": "RATE_LIMITED", "message": "...", "retry_after": 5}}
```