Voice (dictate, read aloud) and Draw a picture over /v1, with the app's models scope
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

The server kept voice and images on /v1 (models scope, which the app's token
already asks for); /me's services and scopes decide what is offered. Rust
sends the recording to /v1/audio/transcriptions, reads /v1/audio/speech back
as a data: URL (CSP media-src data:), and for /v1/images/generations takes
only the id of each of the person's own files from the answer and fetches
/v1/files/<id>/content from the app's own server. The microphone is allowed
only within seconds of the page's own Dictate (voice_arm); WebKitGTK's
media stream is switched on through its settings getter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 14:02:56 +00:00
1 parent 67ce6076c5
commit 2b87fa4c29
16 files changed
+1024 -4

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
// `models` scope (src-tauri/src/server.rs):
//
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
//
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, why } from "./bridge.js"
let me = null
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
/** Whether a message can be dictated here. */
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
/** Whether a reply can be read aloud here. */
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
function say(text) {
const box = $("thread-note")
if (!box) return
box.textContent = text
box.hidden = !text
}
// --- Dictate --------------------------------------------------------------------------------------
let recorder = null // the recording in progress
let busy = false // a recording being turned into words
function drawMic() {
const b = $("dictate")
if (!b) return
b.hidden = !canDictate()
const on = !!recorder
b.classList.toggle("is-recording", on)
b.classList.toggle("is-busy", busy)
b.disabled = busy
b.setAttribute("aria-pressed", String(on))
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
b.setAttribute("aria-label", label)
b.title = label
}
async function start() {
say("")
let stream
try {
await invoke("voice_arm")
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
return
}
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
let rec
try {
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
} catch (err) {
stream.getTracks().forEach((x) => x.stop())
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
return
}
const chunks = []
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
rec.addEventListener("stop", async () => {
stream.getTracks().forEach((x) => x.stop())
recorder = null
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
if (!blob.size) return drawMic()
busy = true
drawMic()
try {
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
intoComposer(text)
} catch (err) {
say(why(err))
}
busy = false
drawMic()
})
recorder = rec
rec.start()
drawMic()
}
/** The words heard, where the cursor is in the message box, a space either side where needed. */
function intoComposer(text) {
text = String(text || "").trim()
if (!text) return
const box = $("composer-text")
const at = box.selectionStart ?? box.value.length
const end = box.selectionEnd ?? at
const before = box.value.slice(0, at)
const after = box.value.slice(end)
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
box.value = before + words + after
box.selectionStart = box.selectionEnd = before.length + words.length
box.dispatchEvent(new Event("input"))
box.focus()
}
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
export function stopDictating() {
if (recorder?.state === "recording") recorder.stop()
}
// --- Read aloud -----------------------------------------------------------------------------------
let playing = null // { id, audio, button }
/** A reply's words without the markdown around them, and without its code. */
export function plain(markdown) {
return String(markdown || "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/[*_`#>|~]/g, "")
.replace(/\s+/g, " ")
.trim()
}
function stopReading() {
if (!playing) return
playing.audio?.pause()
playing.button.setAttribute("aria-pressed", "false")
playing.button.classList.remove("is-busy")
playing = null
}
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
export function readAloudButton(id, text) {
if (!canSpeak()) return null
const label = t("Read this aloud")
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
b.addEventListener("click", async () => {
if (playing?.id === id) return stopReading()
stopReading()
const words = plain(text())
if (!words) return
const mine = { id, audio: null, button: b }
playing = mine
b.setAttribute("aria-pressed", "true")
b.classList.add("is-busy")
try {
const url = await invoke("speak", { text: words })
if (playing !== mine) return
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
mine.audio = new Audio(url)
b.classList.remove("is-busy")
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
await mine.audio.play()
} catch (err) {
if (playing === mine) stopReading()
say(why(err?.message ? { detail: err.message } : err))
}
})
return b
}
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
export function voiceFor(account) {
me = account
drawMic()
}
export function initVoice() {
const b = $("dictate")
b.replaceChildren(icon(ICONS.mic))
b.addEventListener("click", () => {
if (busy) return
if (recorder) recorder.stop()
else start()
})
drawMic()
}