Replace HTTP audio stream with WebSocket + Web Audio API

HTTP Ogg-Streaming hat zu viel Latenz durch Browser-Buffering.
Neuer Ansatz: Raw PCM S16_LE über WebSocket, Web Audio API spielt
direkt ab. Latenz ~100-200ms statt mehrere Sekunden.

- /audio-ws: WebSocket-Endpunkt, streamt arecord PCM-Chunks (512 Bytes = ~32ms)
- live.html: startAudio() öffnet WebSocket, dekodiert PCM zu Float32,
  plant Playback mit AudioContext.createBufferSource() in 40ms-Queue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Vollmer 2026-05-18 18:23:47 +02:00
parent fa9888f83e
commit e4b8cc55f7
2 changed files with 62 additions and 41 deletions

View File

@ -12,8 +12,10 @@ import time
import os import os
import threading import threading
from flask import Flask, Response, render_template, request, jsonify from flask import Flask, Response, render_template, request, jsonify
from flask_sock import Sock
app = Flask(__name__) app = Flask(__name__)
sock = Sock(app)
STREAM_WIDTH = 1280 STREAM_WIDTH = 1280
STREAM_HEIGHT = 720 STREAM_HEIGHT = 720
@ -105,56 +107,40 @@ def stream():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Audio-Stream (Ogg/Opus via ffmpeg) # Audio-Stream via WebSocket (Raw PCM → Web Audio API)
# Minimale Latenz: kein HTTP-Buffering, kein Codec-Delay
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
AUDIO_DEVICE = "plughw:2,0" AUDIO_DEVICE = "plughw:2,0"
AUDIO_SAMPLERATE = 16000 AUDIO_SAMPLERATE = 16000
AUDIO_CHUNK = 512 # ~32ms bei 16kHz
def generate_audio(): @sock.route("/audio-ws")
cmd = [ def audio_ws(ws):
"ffmpeg",
"-fflags", "nobuffer",
"-flags", "low_delay",
"-f", "alsa",
"-thread_queue_size", "128",
"-i", AUDIO_DEVICE,
"-ar", str(AUDIO_SAMPLERATE),
"-ac", "1",
"-c:a", "libopus",
"-b:a", "24k",
"-frame_duration", "20",
"-page_duration", "20000",
"-flush_packets", "1",
"-f", "ogg",
"-",
]
process = subprocess.Popen( process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL [
"arecord",
"-D", AUDIO_DEVICE,
"-f", "S16_LE",
"-r", str(AUDIO_SAMPLERATE),
"-c", "1",
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
) )
try: try:
while True: while True:
chunk = process.stdout.read(4096) chunk = process.stdout.read(AUDIO_CHUNK)
if not chunk: if not chunk:
break break
yield chunk ws.send(chunk)
except Exception:
pass
finally: finally:
process.kill() process.kill()
@app.route("/audio")
def audio():
return Response(
generate_audio(),
mimetype="audio/ogg; codecs=opus",
headers={
"Cache-Control": "no-cache, no-store",
"X-Accel-Buffering": "no",
},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# WLAN-Verwaltung # WLAN-Verwaltung
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -167,7 +167,6 @@
<a class="btn" href="/">⚙ Einstellungen</a> <a class="btn" href="/">⚙ Einstellungen</a>
</div> </div>
<audio id="audio-stream" src="/audio" preload="none"></audio>
<script> <script>
// UI bei Tap/Klick kurz einblenden // UI bei Tap/Klick kurz einblenden
@ -193,24 +192,60 @@
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
// Audio // Audio via WebSocket + Web Audio API (minimale Latenz)
let audioOn = false; let audioOn = false;
let audioCtx = null;
let audioWs = null;
let nextPlayTime = 0;
const SAMPLE_RATE = 16000;
function toggleAudio() { function toggleAudio() {
const a = document.getElementById("audio-stream");
const btn = document.getElementById("audio-btn"); const btn = document.getElementById("audio-btn");
if (audioOn) { if (audioOn) {
a.pause(); stopAudio();
a.src = "";
btn.textContent = "🔇 Ton an"; btn.textContent = "🔇 Ton an";
audioOn = false; audioOn = false;
} else { } else {
a.src = "/audio"; startAudio();
a.play();
btn.textContent = "🔊 Ton aus"; btn.textContent = "🔊 Ton aus";
audioOn = true; audioOn = true;
} }
} }
function startAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
nextPlayTime = audioCtx.currentTime;
const wsUrl = `ws://${location.host}/audio-ws`;
audioWs = new WebSocket(wsUrl);
audioWs.binaryType = "arraybuffer";
audioWs.onmessage = (event) => {
const pcm16 = new Int16Array(event.data);
const buffer = audioCtx.createBuffer(1, pcm16.length, SAMPLE_RATE);
const data = buffer.getChannelData(0);
for (let i = 0; i < pcm16.length; i++) {
data[i] = pcm16[i] / 32768.0;
}
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
// Kleine Puffer-Queue (~40ms) damit keine Lücken entstehen
if (nextPlayTime < now) nextPlayTime = now + 0.04;
source.start(nextPlayTime);
nextPlayTime += buffer.duration;
};
audioWs.onerror = () => stopAudio();
}
function stopAudio() {
if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
nextPlayTime = 0;
}
// Fullscreen // Fullscreen
function toggleFullscreen() { function toggleFullscreen() {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {