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:
parent
fa9888f83e
commit
e4b8cc55f7
54
app/main.py
54
app/main.py
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue