feat(streaming): restore preset controls and add separate FPS/bitrate tuning

Restore working output resolution presets for the sidecar service path and
extend the video streaming UI/backend with separate user-controlled FPS
and bitrate settings.

This change:
- wires resolution presets back through the sidecar service mode
- adds a dedicated framerate selection independent from resolution
- adds a dedicated video bitrate input independent from resolution
- passes resolution, FPS, and bitrate through frontend, API, backend,
  and sidecar paths
- makes local mode and service mode use the same explicit stream settings
- exposes active framerate and bitrate in stream status
- keeps preset values only as fallback defaults where appropriate

Tested successfully across multiple combinations, including:
- 480p / 720p / 1080p
- 24 FPS / 30 FPS / 60 FPS
- local files and YouTube sources
- high bitrate cases up to 1080p@60fps with 6000k video bitrate
This commit is contained in:
Philipp
2026-03-22 01:54:57 +01:00
parent d35dc5e3e2
commit 7b2995ca67
9 changed files with 173 additions and 45 deletions
+14
View File
@@ -241,6 +241,20 @@ The Docker images handle migrations automatically on startup.
| `SIDECAR_URL` | — | Optional. Full URL of the WebRTC sidecar service (e.g. `http://ts6-sidecar:9800`). Set in Docker when sidecar runs as a separate container. |
| `YT_COOKIE_FILE` | — | Optional. Path to a Netscape-format cookies.txt file for yt-dlp. Can also be managed via **Settings → YouTube** in the UI. |
## Environment Variables Sidecar(VideoStreaming)
| Variable | Default | Description |
|----------|---------|-------------|
| `VIDEO_QUEUE_SIZE` | `2048` | Size of the video RTP queue |
| `AUDIO_QUEUE_SIZE` | `4096` | Size of the audio RTP queue |
| `SYNC_PLAYOUT_BUFFER_MS` | `4` | Small playout buffer used by the adaptive pacing logic |
| `SYNC_VIDEO_BIAS_MS` | `4` | Optional extra holdback for video to fine-tune sync |
| `AUDIO_DELAY_MS` | `0` | Legacy / manual audio delay option With the current pacing logic this is typically expected to stay at 0 |
| `SIDECAR_DEBUG_LOGS` | `1` | Enables verbose debug logging for high-frequency runtime details |
| `VIDEO_READ_RTP_BUFFER` | `4194304` | UDP OS-socketbuffer for video port |
| `AUDIO_READ_RTP_BUFFER` | `1048576` | UDP OS-socketbuffer for audio port |
| `VIDEO_BUFSIZE` | `1M` | FFmpeg Video Buffer |
## Music Bot Text Commands
When a music bot is connected to a channel, users in that channel can control it via chat:
@@ -583,9 +583,9 @@ musicBotRoutes.post('/:id/stream/start', async (req: Request, res: Response, nex
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
const bot = manager.getBot(parseInt(req.params.id as string));
if (!bot) throw new AppError(404, 'Music bot not found');
const { source, preset } = req.body;
const { source, preset, framerate, bitrate } = req.body;
if (!source) throw new AppError(400, 'source is required');
await bot.startVideoStream(source, preset);
await bot.startVideoStream(source, preset, framerate, bitrate);
res.json({ success: true, status: bot.videoStreamStatus });
} catch (err) { next(err); }
});
@@ -49,8 +49,14 @@ export class SidecarClient {
await this.call('POST', '/peer/close', { id });
}
async setSource(source: string): Promise<void> {
await this.call('POST', '/source', { source });
async setSource(source: string, width?: number, height?: number, framerate?: number, bitrate?: string): Promise<void> {
await this.call('POST', '/source', {
source,
width,
height,
framerate,
bitrate,
});
}
async stopSource(): Promise<void> {
@@ -29,6 +29,8 @@ export interface VideoStreamStatus {
streamId: string | null;
source: string | null;
preset: string;
framerate: number;
bitrate: string;
startedAt: number | null;
viewerCount: number;
viewers: VideoViewerInfo[];
+33 -7
View File
@@ -127,6 +127,8 @@ export class VoiceBot extends EventEmitter {
private _activeStreamId: string | null = null;
private _videoSource: string | null = null;
private _videoPreset: string = DEFAULT_PRESET;
private _videoFramerate: number = STREAM_PRESETS[DEFAULT_PRESET]?.framerate ?? 30;
private _videoBitrate: string = STREAM_PRESETS[DEFAULT_PRESET]?.bitrate ?? '2500k';
private _videoStartedAt: number | null = null;
private _viewers: Map<number, VideoViewerInfo> = new Map();
@@ -750,6 +752,8 @@ export class VoiceBot extends EventEmitter {
streamId: this._activeStreamId,
source: this._videoSource,
preset: this._videoPreset,
framerate: this._videoFramerate,
bitrate: this._videoBitrate,
startedAt: this._videoStartedAt,
viewerCount: this._viewers.size,
viewers: Array.from(this._viewers.values()),
@@ -758,7 +762,7 @@ export class VoiceBot extends EventEmitter {
}
/** Start video streaming to TS6 via WebRTC */
async startVideoStream(source: string, preset?: string): Promise<void> {
async startVideoStream(source: string, preset?: string, framerate?: number, bitrate?: string): Promise<void> {
if (this._status !== 'connected' && this._status !== 'playing' && this._status !== 'paused') {
throw new Error('Bot is not connected');
}
@@ -768,8 +772,17 @@ export class VoiceBot extends EventEmitter {
const sidecarBinary = this.config.sidecarBinaryPath || process.env.SIDECAR_BINARY_PATH || 'sidecar';
const sidecarPort = this.config.sidecarPort || 9800;
this._videoPreset = preset || this.config.streamPreset || DEFAULT_PRESET;
this._videoPreset = preset ?? this.config.streamPreset ?? DEFAULT_PRESET;
const presetConfig = STREAM_PRESETS[this._videoPreset] || STREAM_PRESETS[DEFAULT_PRESET];
const effectiveFramerate = framerate && framerate > 0
? framerate
: presetConfig.framerate;
const effectiveBitrate = bitrate?.trim()
? bitrate.trim()
: presetConfig.bitrate;
this._videoFramerate = effectiveFramerate;
this._videoBitrate = effectiveBitrate;
// Check if sidecar URL is set (Docker mode — sidecar runs as separate container)
const sidecarUrl = process.env.SIDECAR_URL;
@@ -782,9 +795,9 @@ export class VoiceBot extends EventEmitter {
const sidecarConfig: SidecarConfig = {
binaryPath: sidecarBinary,
port: sidecarPort,
videoBitrate: presetConfig.bitrate,
videoBitrate: effectiveBitrate,
videoResolution: { width: presetConfig.width, height: presetConfig.height },
videoFramerate: presetConfig.framerate,
videoFramerate: effectiveFramerate,
};
this.sidecarProc = new SidecarProcess(sidecarConfig);
@@ -848,7 +861,13 @@ export class VoiceBot extends EventEmitter {
// Resolve YouTube/streaming URLs via yt-dlp, then start ffmpeg
const resolvedSource = await resolveVideoUrl(source, presetConfig.height);
await this.sidecarHttp.setSource(resolvedSource);
await this.sidecarHttp.setSource(
resolvedSource,
presetConfig.width,
presetConfig.height,
effectiveFramerate,
effectiveBitrate,
);
console.log(`[VoiceBot ${this.config.id}] Video stream started: ${stream.id}, source: ${source}`);
this.emit('videoStreamStarted', { streamId: stream.id, source, preset: this._videoPreset });
@@ -907,7 +926,14 @@ export class VoiceBot extends EventEmitter {
this._videoSource = source;
const currentPreset = STREAM_PRESETS[this._videoPreset] || STREAM_PRESETS[DEFAULT_PRESET];
const resolvedSource = await resolveVideoUrl(source, currentPreset.height);
await this.sidecarHttp.setSource(resolvedSource);
await this.sidecarHttp.setSource(
resolvedSource,
currentPreset.width,
currentPreset.height,
this._videoFramerate,
this._videoBitrate,
);
console.log(`[VoiceBot ${this.config.id}] Video source changed: ${source}`);
this.emit('videoSourceChanged', source);
}
@@ -960,7 +986,7 @@ export class VoiceBot extends EventEmitter {
const clid = parseInt(params.clid) || 0;
if (this._viewers.has(clid)) {
console.log(`[VoiceBot ${this.config.id}] Viewer left: clid=${clid}`);
this.sidecarHttp?.closePeer(String(clid)).catch(() => {});
this.sidecarHttp?.closePeer(String(clid)).catch(() => { });
this._viewers.delete(clid);
this.emit('videoViewerLeft', clid);
}
+2 -2
View File
@@ -39,8 +39,8 @@ export const musicBotsApi = {
playerWidgetToken: (id: number) => api.get(`/music-bots/${id}/player-widget-token`).then((r) => r.data),
// Video Streaming
startStream: (id: number, source: string, preset?: string) =>
api.post(`/music-bots/${id}/stream/start`, { source, preset }).then((r) => r.data),
startStream: (id: number, source: string, preset?: string, framerate?: number, bitrate?: string) =>
api.post(`/music-bots/${id}/stream/start`, { source, preset, framerate, bitrate }).then((r) => r.data),
stopStream: (id: number) => api.post(`/music-bots/${id}/stream/stop`).then((r) => r.data),
setStreamSource: (id: number, source: string) =>
api.post(`/music-bots/${id}/stream/source`, { source }).then((r) => r.data),
@@ -25,6 +25,12 @@ const PRESETS = [
{ value: '1080p', label: '1080p (1920x1080, 4.5 Mbps)' },
];
const FPS_OPTIONS = [
{ value: '24', label: '24 FPS' },
{ value: '30', label: '30 FPS' },
{ value: '60', label: '60 FPS' },
];
interface VideoStreamTabProps {
botId: number;
botStatus: string;
@@ -33,6 +39,8 @@ interface VideoStreamTabProps {
export function VideoStreamTab({ botId, botStatus }: VideoStreamTabProps) {
const [sourceUrl, setSourceUrl] = useState('');
const [preset, setPreset] = useState('720p');
const [framerate, setFramerate] = useState('30');
const [bitrate, setBitrate] = useState('2500k');
const { data: streamStatus } = useVideoStreamStatus(botId);
const startStream = useStartVideoStream();
@@ -45,7 +53,13 @@ export function VideoStreamTab({ botId, botStatus }: VideoStreamTabProps) {
const handleStart = () => {
if (!sourceUrl.trim()) return;
startStream.mutate({ botId, source: sourceUrl.trim(), preset });
startStream.mutate({
botId,
source: sourceUrl.trim(),
preset,
framerate: Number(framerate),
bitrate: bitrate.trim(),
});
};
const handleStop = () => {
@@ -117,19 +131,48 @@ export function VideoStreamTab({ botId, botStatus }: VideoStreamTabProps) {
</div>
{!isStreaming && (
<div className="space-y-2">
<Label>Quality Preset</Label>
<div className="flex gap-2">
{PRESETS.map((p) => (
<Button
key={p.value}
variant={preset === p.value ? 'default' : 'outline'}
size="sm"
onClick={() => setPreset(p.value)}
>
{p.value}
</Button>
))}
<div className="space-y-4">
<div className="space-y-2">
<Label>Quality Preset</Label>
<div className="flex gap-2">
{PRESETS.map((p) => (
<Button
key={p.value}
variant={preset === p.value ? 'default' : 'outline'}
size="sm"
onClick={() => setPreset(p.value)}
>
{p.value}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Frame Rate (FPS)</Label>
<div className="flex gap-2">
{FPS_OPTIONS.map((fps) => (
<Button
key={fps.value}
variant={framerate === fps.value ? 'default' : 'outline'}
size="sm"
onClick={() => setFramerate(fps.value)}
>
{fps.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Video Bitrate</Label>
<Input
value={bitrate}
onChange={(e) => setBitrate(e.target.value)}
placeholder="e.g. 1500k, 2500k, 4500k"
/>
<p className="text-xs text-muted-foreground">
Examples: 1500k, 2500k, 4500k, 6000k
</p>
</div>
</div>
)}
@@ -176,6 +219,8 @@ export function VideoStreamTab({ botId, botStatus }: VideoStreamTabProps) {
{isStreaming && streamStatus && (
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>Preset: <strong>{streamStatus.preset}</strong></span>
<span>FPS: <strong>{streamStatus.framerate}</strong></span>
<span>Bitrate: <strong>{streamStatus.bitrate}</strong></span>
{streamStatus.source && (
<span className="truncate max-w-xs">
Source: <strong>{streamStatus.source}</strong>
+13 -2
View File
@@ -235,8 +235,19 @@ export function useVideoStreamStatus(botId: number | null) {
export function useStartVideoStream() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ botId, source, preset }: { botId: number; source: string; preset?: string }) =>
musicBotsApi.startStream(botId, source, preset),
mutationFn: ({
botId,
source,
preset,
framerate,
bitrate,
}: {
botId: number;
source: string;
preset?: string;
framerate?: number;
bitrate?: string;
}) => musicBotsApi.startStream(botId, source, preset, framerate, bitrate),
onSuccess: (_, { botId }) => {
qc.invalidateQueries({ queryKey: ['video-stream-status', botId] });
qc.invalidateQueries({ queryKey: ['music-bot-state', botId] });
+40 -16
View File
@@ -316,12 +316,14 @@ func (s *Sidecar) StartRTP() error {
if err != nil {
return fmt.Errorf("bind video UDP: %w", err)
}
_ = s.videoConn.SetReadBuffer(envIntOrDefault("VIDEO_RTP_READ_BUFFER", 4*1024*1024))
s.videoPort = s.videoConn.LocalAddr().(*net.UDPAddr).Port
s.audioConn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
return fmt.Errorf("bind audio UDP: %w", err)
}
_ = s.audioConn.SetReadBuffer(envIntOrDefault("AUDIO_RTP_READ_BUFFER", 1*1024*1024))
s.audioPort = s.audioConn.LocalAddr().(*net.UDPAddr).Port
log.Printf("[RTP] Video port: %d, Audio port: %d", s.videoPort, s.audioPort)
@@ -815,14 +817,32 @@ func (s *Sidecar) ClosePeer(id string) {
s.peersLock.Unlock()
}
func (s *Sidecar) StartFFmpeg(source string) {
func (s *Sidecar) StartFFmpeg(source string, width int, height int, framerate int, bitrate string) {
s.resetSyncTiming()
s.ffmpegLock.Lock()
defer s.ffmpegLock.Unlock()
s.StopFFmpegLocked()
s.source = source
w := width
h := height
fps := framerate
if w <= 0 {
w = envIntOrDefault("VIDEO_WIDTH", 1280)
}
if h <= 0 {
h = envIntOrDefault("VIDEO_HEIGHT", 720)
}
if fps <= 0 {
fps = envIntOrDefault("VIDEO_FRAMERATE", 30)
}
args := []string{}
if source != "" {
@@ -831,22 +851,22 @@ func (s *Sidecar) StartFFmpeg(source string) {
} else {
args = append(args, "-stream_loop", "-1")
}
args = append(args, "-fflags", "+genpts+discardcorrupt", "-re", "-i", source)
} else {
w := envIntOrDefault("VIDEO_WIDTH", 1280)
h := envIntOrDefault("VIDEO_HEIGHT", 720)
args = append(args, "-re", "-f", "lavfi", "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=1", w, h))
}
w := envIntOrDefault("VIDEO_WIDTH", 1280)
h := envIntOrDefault("VIDEO_HEIGHT", 720)
vBitrate := envOrDefault("VIDEO_BITRATE", "1500k")
vBitrate := strings.TrimSpace(bitrate)
if vBitrate == "" {
vBitrate = envOrDefault("VIDEO_BITRATE", "1500k")
}
audioDelayMs := envIntOrDefault("AUDIO_DELAY_MS", 0)
if source != "" {
vf := fmt.Sprintf(
"fps=30,scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
w, h, w, h,
"fps=%d,scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
fps, w, h, w, h,
)
args = append(args,
"-map", "0:v:0",
@@ -856,15 +876,15 @@ func (s *Sidecar) StartFFmpeg(source string) {
args = append(args,
"-pix_fmt", "yuv420p",
"-c:v", "libvpx",
"-cpu-used", "8",
"-cpu-used", "6",
"-deadline", "realtime",
"-lag-in-frames", "0",
"-error-resilient", "1",
"-b:v", vBitrate,
"-maxrate", "2M",
"-bufsize", "100k",
"-keyint_min", "15",
"-g", "15",
"-maxrate", vBitrate,
"-bufsize", envOrDefault("VIDEO_BUFSIZE", "500k"),
"-keyint_min", "12",
"-g", "12",
"-auto-alt-ref", "0",
"-payload_type", "96",
"-ssrc", "11111111",
@@ -1054,14 +1074,18 @@ func main() {
mux.HandleFunc("POST /source", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Source string `json:"source"`
Source string `json:"source"`
Width int `json:"width"`
Height int `json:"height"`
Framerate int `json:"framerate"`
Bitrate string `json:"bitrate"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
log.Printf("[API] Setting source: %s", req.Source)
sidecar.StartFFmpeg(req.Source)
log.Printf("[API] Setting source: %s (%dx%d @ %dfps)", req.Source, req.Width, req.Height, req.Framerate, req.Bitrate)
sidecar.StartFFmpeg(req.Source, req.Width, req.Height, req.Framerate, req.Bitrate)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})