fix: say why the microphone is unavailable, instead of freezing the button

`navigator.mediaDevices` only exists in a secure context — HTTPS, or
localhost. Over plain http on a LAN address the property is undefined, so
`_startRecording` threw on its first line, the catch wrote one console line
and returned, and `_recording` stayed false: the button sat there unchanged
with nothing to read anywhere a user would look.

The unavailable cases are now named before the attempt rather than guessed
at afterwards — insecure context, unsupported browser, denied permission,
anything else — and surfaced in the chat through `_pushError`, which every
chat surface already shares. The button is deliberately still rendered when
the context is insecure: hiding it would read as "transcription is not
configured", which is the wrong diagnosis to hand someone.

Adds docs/voice.md, since "why doesn't the microphone work" is a question
the assistant will be asked and the answer is entirely outside Skald.
This commit is contained in:
2026-08-04 15:19:50 +01:00
parent f900d803f2
commit e29dc40202
6 changed files with 74 additions and 1 deletions
+4
View File
@@ -80,6 +80,10 @@ export default {
'chat.rejected_by_user': 'Denied by user.',
'chat.truncated': 'Response truncated by the token limit (↓{tokens} tok).',
'chat.not_connected': 'Not connected — reconnecting. Your message is still in the box: press Enter again in a moment.',
'chat.mic.insecure': 'Your browser only allows the microphone over a secure connection. Open Skald at http://localhost (on the machine itself) or put it behind HTTPS.',
'chat.mic.unsupported': 'This browser does not support voice recording.',
'chat.mic.denied': 'Microphone access was denied. Allow it for this site in your browser settings, then try again.',
'chat.mic.failed': 'Could not start recording: {error}',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Open in viewer',
+4
View File
@@ -80,6 +80,10 @@ export default {
'chat.rejected_by_user': 'Refusé par l\'utilisateur.',
'chat.truncated': 'Réponse tronquée par la limite de tokens (↓{tokens} tok).',
'chat.not_connected': 'Non connecté — reconnexion en cours. Votre message est resté dans le champ : appuyez de nouveau sur Entrée dans un instant.',
'chat.mic.insecure': 'Votre navigateur n\'autorise le microphone que sur une connexion sécurisée. Ouvrez Skald sur http://localhost (depuis la machine elle-même) ou placez-le derrière HTTPS.',
'chat.mic.unsupported': 'Ce navigateur ne prend pas en charge l\'enregistrement vocal.',
'chat.mic.denied': 'Accès au microphone refusé. Autorisez-le pour ce site dans les réglages du navigateur, puis réessayez.',
'chat.mic.failed': 'Impossible de démarrer l\'enregistrement : {error}',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
+4
View File
@@ -80,6 +80,10 @@ export default {
'chat.rejected_by_user': 'Negata dall\'utente.',
'chat.truncated': 'Risposta troncata dal limite di token (↓{tokens} tok).',
'chat.not_connected': 'Non connesso — riconnessione in corso. Il messaggio è rimasto nella casella: premi di nuovo Invio tra un istante.',
'chat.mic.insecure': 'Il browser consente il microfono solo su connessione sicura. Apri Skald su http://localhost (dalla macchina stessa) oppure mettilo dietro HTTPS.',
'chat.mic.unsupported': 'Questo browser non supporta la registrazione vocale.',
'chat.mic.denied': 'Accesso al microfono negato. Consentilo per questo sito nelle impostazioni del browser e riprova.',
'chat.mic.failed': 'Impossibile avviare la registrazione: {error}',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Apri nel visualizzatore',
+28
View File
@@ -1042,8 +1042,31 @@ export class ChatSession extends LightElement {
}
}
/**
* Why the microphone can be missing even when the server has a transcribe
* model: `navigator.mediaDevices` is only exposed in a **secure context**
* (HTTPS, or localhost/127.0.0.1) — over http on a LAN address the property
* is `undefined`, not a denied permission. That used to die in the `catch`
* below as a bare console line, leaving the button frozen with no
* explanation, so the unavailable cases are named here instead of guessed at.
*/
_micUnavailableReason() {
if (!navigator.mediaDevices?.getUserMedia) {
return window.isSecureContext === false
? t('chat.mic.insecure')
: t('chat.mic.unsupported');
}
if (typeof MediaRecorder === 'undefined') return t('chat.mic.unsupported');
return null;
}
async _startRecording(fromShortcut = false) {
if (this._recording) return;
const unavailable = this._micUnavailableReason();
if (unavailable) {
this._pushError(unavailable);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this._audioChunks = [];
@@ -1072,6 +1095,11 @@ export class ChatSession extends LightElement {
this._recording = true;
} catch (err) {
console.error('mic error:', err);
this._pushError(
err?.name === 'NotAllowedError' || err?.name === 'SecurityError'
? t('chat.mic.denied')
: t('chat.mic.failed', { error: err?.message || err?.name || 'unknown' }),
);
}
}