+ Your recordings are transcribed by OpenRouter, which charges a fraction of a cent per minute of audio. To turn voice input on, add some
+ credit to an OpenRouter account, then
+ create an API key and paste it below.
+
+
+ {#snippet label()}
+ OpenRouter API Key
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+ {#snippet info()}
+ Your key is stored in your {PLATFORM_NAME} settings, encrypted to your nostr identity.
+ {/snippet}
+
+
+
+
+
+
+
diff --git a/src/app/components/EventReply.svelte b/src/app/components/EventReply.svelte
index bb8df47e..3631070c 100644
--- a/src/app/components/EventReply.svelte
+++ b/src/app/components/EventReply.svelte
@@ -78,10 +78,16 @@
content = json
}
- const editor = makeEditor({url, submit, uploading, content, onChange})
+ const empty = writable(true)
+
+ const editor = makeEditor({url, submit, uploading, content, onChange, empty})
$effect(() => {
- draftKey.set({content})
+ if ($empty) {
+ draftKey.clear()
+ } else {
+ draftKey.set({content})
+ }
})
onMount(() => {
diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/RoomCompose.svelte
index 3769a9dc..6dae07a5 100644
--- a/src/app/components/RoomCompose.svelte
+++ b/src/app/components/RoomCompose.svelte
@@ -2,7 +2,7 @@
import {writable} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import type {EventContent} from "@welshman/util"
- import {isMobile, preventDefault} from "@lib/html"
+ import {escapeHtml, isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import WidgetAdd from "@assets/icons/widget-add.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
@@ -12,6 +12,7 @@
import type {TippyController} from "@lib/components/Tippy.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
+ import DictationButton from "@app/components/DictationButton.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {DraftKey, type Draft} from "@app/drafts"
@@ -53,6 +54,14 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
+ // Tiptap parses a string handed to insertContent as html, so an angle bracket in the
+ // transcript would eat the rest of the sentence.
+ const insertTranscript = async (text: string) => {
+ const ed = await editor
+
+ ed.chain().focus().insertContent(escapeHtml(text)).run()
+ }
+
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide()
@@ -67,21 +76,25 @@
onSubmit({content, tags})
draftKey?.clear()
- ed.chain().clearContent().run()
+ ed.chain().clearContent(true).run()
}
let tippy: Maybe = $state()
let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
+ let recording = $state(false)
const onChange = (json: object) => {
content = json
}
+ const empty = writable(true)
+
const editor = makeEditor({
url,
content,
+ empty,
submit,
uploading,
onChange,
@@ -89,7 +102,11 @@
})
$effect(() => {
- draftKey?.set({content})
+ if ($empty) {
+ draftKey?.clear()
+ } else {
+ draftKey?.set({content})
+ }
})
onMount(async () => {
@@ -137,11 +154,15 @@
-
+ {#if recording || $empty}
+
+ {:else}
+
+ {/if}
diff --git a/src/app/dictation.ts b/src/app/dictation.ts
new file mode 100644
index 00000000..2e607031
--- /dev/null
+++ b/src/app/dictation.ts
@@ -0,0 +1,92 @@
+import {getSetting} from "@app/settings"
+
+const TRANSCRIPTION_MODEL = "openai/whisper-large-v3-turbo"
+
+// OpenRouter chooses a decoder using the uploaded file's extension, and MediaRecorder's output
+// format varies by engine — webm on chromium, mp4 on webkit.
+const EXTENSIONS_BY_MIME_TYPE: Record = {
+ "audio/webm": "webm",
+ "audio/ogg": "ogg",
+ "audio/mp4": "m4a",
+ "audio/mpeg": "mp3",
+ "audio/wav": "wav",
+}
+
+// Reports how loud the microphone is once per frame, so the caller can show the speaker that we're
+// hearing them. Levels follow the waveform's envelope — jumping to each peak, then decaying — since
+// the raw root mean square drops to nothing in the gaps between words.
+export const startDictation = async (onLevel: (level: number) => void) => {
+ const stream = await navigator.mediaDevices.getUserMedia({audio: true})
+ const recorder = new MediaRecorder(stream)
+ const chunks: Blob[] = []
+
+ recorder.addEventListener("dataavailable", event => chunks.push(event.data))
+ recorder.start()
+
+ const context = new AudioContext()
+ const analyser = context.createAnalyser()
+
+ analyser.fftSize = 512
+
+ context.createMediaStreamSource(stream).connect(analyser)
+
+ const samples = new Uint8Array(analyser.frequencyBinCount)
+
+ let level = 0
+
+ const measure = () => {
+ analyser.getByteTimeDomainData(samples)
+
+ let sumSquares = 0
+
+ for (const sample of samples) {
+ sumSquares += ((sample - 128) / 128) ** 2
+ }
+
+ level = Math.max(Math.sqrt(sumSquares / samples.length), level * 0.92)
+
+ onLevel(level)
+
+ frame = requestAnimationFrame(measure)
+ }
+
+ let frame = requestAnimationFrame(measure)
+
+ return () =>
+ new Promise(resolve => {
+ recorder.addEventListener("stop", () => {
+ cancelAnimationFrame(frame)
+ context.close()
+
+ for (const track of stream.getTracks()) {
+ track.stop()
+ }
+
+ resolve(new Blob(chunks, {type: recorder.mimeType}))
+ })
+
+ recorder.stop()
+ })
+}
+
+export const transcribe = async (audio: Blob) => {
+ const [mimeType] = audio.type.split(";")
+ const body = new FormData()
+
+ body.append("model", TRANSCRIPTION_MODEL)
+ body.append("file", audio, `dictation.${EXTENSIONS_BY_MIME_TYPE[mimeType] || "webm"}`)
+
+ const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
+ method: "POST",
+ headers: {Authorization: `Bearer ${getSetting("openrouter_key")}`},
+ body,
+ })
+
+ const {text, error}: {text?: string; error?: {message?: string}} = await response.json()
+
+ if (!response.ok) {
+ throw new Error(error?.message || `OpenRouter returned a ${response.status}.`)
+ }
+
+ return text?.trim() ?? ""
+}
diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts
index 85031c09..93e8faed 100644
--- a/src/app/editor/index.ts
+++ b/src/app/editor/index.ts
@@ -25,11 +25,14 @@ import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS} from "@app/env"
import {pushToast} from "@app/toast"
+const isEmpty = (editor: Editor) => editor.getText({blockSeparator: "\n"}).trim() === ""
+
export const makeEditor = async ({
encryptFiles = false,
aggressive = false,
charCount,
content = "",
+ empty,
onChange,
placeholder = "",
url,
@@ -41,6 +44,7 @@ export const makeEditor = async ({
aggressive?: boolean
charCount?: Writable
content?: string | object
+ empty?: Writable
onChange?: (json: object) => void
placeholder?: string
url?: string
@@ -192,9 +196,16 @@ export const makeEditor = async ({
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
+ empty?.set(isEmpty(editor))
onChange?.(editor.getJSON())
},
})
+ // Seed the caller's store from the document tiptap actually parsed — a restored draft is a
+ // document even when it holds no text, so the caller can't tell from `content` alone. Callers
+ // clear their draft when this reads true, so it has to be set before they render: keep every
+ // await in this function inside a callback, below the constructor.
+ empty?.set(isEmpty(ed))
+
return ed
}
diff --git a/src/app/settings.ts b/src/app/settings.ts
index dee35519..224eb3f1 100644
--- a/src/app/settings.ts
+++ b/src/app/settings.ts
@@ -32,6 +32,7 @@ export type SettingsValues = {
font_size: number
alerts: SpaceNotificationSettings[]
zap_amounts: number[]
+ openrouter_key: string
}
export const defaultSettings: SettingsValues = {
@@ -44,6 +45,7 @@ export const defaultSettings: SettingsValues = {
font_size: 1.1,
alerts: [],
zap_amounts: [21, 210, 2100, 21000],
+ openrouter_key: "",
}
export class Settings extends DerivedPlugin {
diff --git a/src/lib/components/base.css b/src/lib/components/base.css
index b46cb330..6aed662c 100644
--- a/src/lib/components/base.css
+++ b/src/lib/components/base.css
@@ -120,6 +120,24 @@
animation: none;
}
}
+/* Breathes a control in and out while it's listening to the user's voice. */
+@utility pulse-scale {
+ animation: pulse-scale 200ms ease-in-out infinite;
+}
+@keyframes pulse-scale {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .pulse-scale {
+ animation: none;
+ }
+}
@utility pt-sai {
padding-top: var(--sait);
}
diff --git a/src/routes/settings/content/+page.svelte b/src/routes/settings/content/+page.svelte
index ef15bc33..f7bd6510 100644
--- a/src/routes/settings/content/+page.svelte
+++ b/src/routes/settings/content/+page.svelte
@@ -2,11 +2,13 @@
import {BlossomServerLists, MuteLists, publish} from "@welshman/app"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
+ import Microphone from "@assets/icons/microphone.svg?dataurl"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import ToggleInput from "@lib/components/ToggleInput.svelte"
import InputList from "@lib/components/InputList.svelte"
+ import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Card from "@lib/components/Card.svelte"
import Button from "@lib/components/Button.svelte"
@@ -119,6 +121,29 @@
{/snippet}
+
+ {#snippet label()}
+