feat(web): the Dashboard shows the audio wiring verdict

An Audio wiring card (Windows hosts) below the status tiles: a readiness
badge (Ready / No microphone / No game audio / Not wired), the friendly
names carrying each role, and the degradation notes that were previously
visible only in the host log — mic withheld for game audio, the known-
degraded last resort, a narrowing endpoint. api/openapi.json regenerated
from the host build (AudioWiring + RuntimeStatus.audio); en+de messages.
This commit is contained in:
2026-08-07 12:00:13 +02:00
parent 3870cdd1da
commit 15dddf7ec0
4 changed files with 134 additions and 0 deletions
+56
View File
@@ -4045,6 +4045,51 @@
}
}
},
"AudioWiring": {
"type": "object",
"description": "The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).",
"required": [
"readiness",
"mic_withheld",
"last_resort"
],
"properties": {
"last_resort": {
"type": "boolean",
"description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes."
},
"loopback": {
"type": [
"string",
"null"
],
"description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable."
},
"mic": {
"type": [
"string",
"null"
],
"description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable."
},
"mic_withheld": {
"type": "boolean",
"description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable."
},
"narrowing": {
"type": [
"string",
"null"
],
"description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does."
},
"readiness": {
"type": "string",
"description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.",
"example": "full"
}
}
},
"AvailableCompositor": {
"type": "object",
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
@@ -6805,6 +6850,17 @@
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
"minimum": 0
},
"audio": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/AudioWiring",
"description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime."
}
]
},
"audio_streaming": {
"type": "boolean",
"description": "True while the audio stream thread is running."
+10
View File
@@ -73,6 +73,16 @@
"status_paired_count": "Gekoppelte Geräte",
"status_pin_waiting": "Wartet",
"status_pin_none": "Keine",
"audio_wiring_title": "Audio-Verkabelung",
"audio_output": "Spielaudio",
"audio_microphone": "Mikrofon",
"audio_unavailable": "Nicht verfügbar",
"audio_ready": "Bereit",
"audio_ready_no_mic": "Kein Mikrofon",
"audio_no_output": "Kein Spielaudio",
"audio_none": "Nicht verkabelt",
"audio_mic_withheld": "Der Mikrofon-Endpunkt überträgt gerade das Spielaudio — installiere Steam, damit der Host sein eigenes Mikrofon anlegen kann (Steam muss nie laufen).",
"audio_last_resort": "Spielaudio läuft über einen eingeschränkten Ersatz-Endpunkt und kann stumm bleiben, bis ein Ausgabegerät erscheint.",
"status_pin_pending": "Kopplungs-PIN ausstehend",
"stream_codec": "Codec",
"stream_resolution": "Auflösung",
+10
View File
@@ -73,6 +73,16 @@
"status_paired_count": "Paired clients",
"status_pin_waiting": "Waiting",
"status_pin_none": "None",
"audio_wiring_title": "Audio wiring",
"audio_output": "Game audio",
"audio_microphone": "Microphone",
"audio_unavailable": "Unavailable",
"audio_ready": "Ready",
"audio_ready_no_mic": "No microphone",
"audio_no_output": "No game audio",
"audio_none": "Not wired",
"audio_mic_withheld": "The microphone endpoint is carrying game audio — install Steam so the host can mint its own microphone (Steam never has to run).",
"audio_last_resort": "Game audio is on a degraded fallback endpoint and may be silent until an output device appears.",
"status_pin_pending": "Pairing PIN pending",
"stream_codec": "Codec",
"stream_resolution": "Resolution",
+58
View File
@@ -2,6 +2,7 @@ import Section from "@unom/ui/section";
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import type { AudioWiring } from "@/api/gen/model/audioWiring";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state";
@@ -87,6 +88,12 @@ export const DashboardView: FC<{
</Card>
</div>
{/* The wiring verdict (Windows hosts): WHICH endpoints carry game
audio and the microphone, and the degradations that used to be
visible only in the host log a silent host looks identical to a
quiet game without this. */}
{s.audio && <AudioWiringCard audio={s.audio} />}
{/* Above the session card: a game the host is about to close is the most
time-sensitive thing on this page. */}
<RunningGames
@@ -193,6 +200,57 @@ export const DashboardView: FC<{
);
};
/**
* One line per role plus a readiness badge; the degradation notes are spelled out because the
* failure they describe (silent audio, a mic that quietly vanished) is invisible everywhere
* else except the host log.
*/
const AudioWiringCard: FC<{ audio: AudioWiring }> = ({ audio }) => {
const badge: { variant: "success" | "secondary" | "destructive"; text: string } =
audio.readiness === "full"
? { variant: "success", text: m.audio_ready() }
: audio.readiness === "audio_only"
? { variant: "secondary", text: m.audio_ready_no_mic() }
: audio.readiness === "mic_only"
? { variant: "destructive", text: m.audio_no_output() }
: { variant: "destructive", text: m.audio_none() };
const notes = [
audio.mic_withheld ? m.audio_mic_withheld() : undefined,
audio.last_resort ? m.audio_last_resort() : undefined,
audio.narrowing,
].filter((n): n is string => !!n);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2">
<Volume2 className="size-4" />
{m.audio_wiring_title()}
</CardTitle>
<Badge variant={badge.variant}>{badge.text}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<dl className="grid gap-4 sm:grid-cols-2">
<Field
label={m.audio_output()}
value={audio.loopback ?? m.audio_unavailable()}
/>
<Field
label={m.audio_microphone()}
value={audio.mic ?? m.audio_unavailable()}
/>
</dl>
{notes.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
)}
</CardContent>
</Card>
);
};
const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
icon,
label,