Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Frontend/client/src/i18n/translations/de/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { TranslationCatalog } from "@/i18n/types";

export const settingsTranslations = {
"Live partials and completed turns · up to 60 minutes · Meta Model API key":
"Live-Zwischenstände und abgeschlossene Äußerungen · bis 60 Minuten · Meta-Model-API-Schlüssel",
"Transcribes after stop · files up to 10 minutes · Meta Model API key":
"Transkription nach dem Stoppen · Dateien bis 10 Minuten · Meta-Model-API-Schlüssel",
"Native speaker labels and approximate turn timestamps. Maximum 10 minutes per recording.":
"Native Sprechertrennung und ungefähre Zeitstempel je Äußerung. Maximal 10 Minuten pro Aufnahme.",
// Languages and shared settings states.
"Auto-detect": "Automatisch erkennen",
German: "Deutsch",
Expand Down
44 changes: 44 additions & 0 deletions Frontend/client/src/lib/meta-transcription-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";

import { settingsTranslations } from "@/i18n/translations/de/settings";
import {
META_CREDENTIAL_REQUIREMENT,
META_MEETING_FINAL_STT_OPTION,
META_TRANSCRIPTION_OPTIONS,
metaFrontendModelForService,
metaSettingsPatchForModel,
} from "@/lib/meta-transcription-settings";

test("Meta offers separate realtime and async routes for one published model", () => {
assert.deepEqual(
META_TRANSCRIPTION_OPTIONS.map((option) => option.group),
["cloud_streaming", "cloud_async"],
);
for (const option of META_TRANSCRIPTION_OPTIONS) {
assert.equal(option.model, "muse-voice-transcribe-1.0");
assert.equal(option.usdPerHour, 0.18);
assert.equal(metaFrontendModelForService(option.service), option.value);
assert.deepEqual(metaSettingsPatchForModel(option.value), { defaultSttService: option.service });
assert.ok(settingsTranslations[option.routeNote]);
assert.equal("wordErrorRatePercent" in option, false);
}
assert.equal(metaSettingsPatchForModel("meta-unknown"), null);
assert.equal(metaFrontendModelForService("meta"), null);
});

test("both Meta STT routes reuse the existing Meta Model API credential", () => {
assert.deepEqual(META_CREDENTIAL_REQUIREMENT, {
provider: "Meta Model API",
label: "Meta Model API key",
helpKey: "meta",
});
});

test("Meta Meeting option does not promise five-hour or word-level support", () => {
assert.equal(META_MEETING_FINAL_STT_OPTION.value, "meta_stt_async");
assert.equal(META_MEETING_FINAL_STT_OPTION.nativeDiarization, true);
assert.equal(META_MEETING_FINAL_STT_OPTION.fiveHourSupported, false);
assert.match(META_MEETING_FINAL_STT_OPTION.detail, /10 minutes/);
assert.ok(settingsTranslations[META_MEETING_FINAL_STT_OPTION.detail]);
});
49 changes: 49 additions & 0 deletions Frontend/client/src/lib/meta-transcription-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Both transports use the same published model; Async is not a second model.
export const META_TRANSCRIPTION_OPTIONS = [
{
value: "meta-realtime",
service: "meta_stt",
label: "Meta Muse Voice Transcribe Realtime",
model: "muse-voice-transcribe-1.0",
group: "cloud_streaming",
icon: "meta",
usdPerHour: 0.18,
routeNote: "Live partials and completed turns · up to 60 minutes · Meta Model API key",
},
{
value: "meta-async",
service: "meta_stt_async",
label: "Meta Muse Voice Transcribe Async",
model: "muse-voice-transcribe-1.0",
group: "cloud_async",
icon: "meta",
usdPerHour: 0.18,
routeNote: "Transcribes after stop · files up to 10 minutes · Meta Model API key",
},
] as const;

export const META_CREDENTIAL_REQUIREMENT = {
provider: "Meta Model API",
label: "Meta Model API key",
helpKey: "meta",
} as const;

export const META_MEETING_FINAL_STT_OPTION = {
value: "meta_stt_async",
label: "Meta Muse Voice Transcribe",
model: "muse-voice-transcribe-1.0",
credentialModel: "meta-async",
recommended: false,
nativeDiarization: true,
fiveHourSupported: false,
detail: "Native speaker labels and approximate turn timestamps. Maximum 10 minutes per recording.",
} as const;

export function metaFrontendModelForService(service: string) {
return META_TRANSCRIPTION_OPTIONS.find((option) => option.service === service)?.value ?? null;
}

export function metaSettingsPatchForModel(value: string) {
const option = META_TRANSCRIPTION_OPTIONS.find((candidate) => candidate.value === value);
return option ? { defaultSttService: option.service } : null;
}
26 changes: 26 additions & 0 deletions Frontend/client/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ import {
geminiFrontendModelForService,
geminiSettingsPatchForModel,
} from "@/lib/gemini-transcription-settings";
import {
META_TRANSCRIPTION_OPTIONS,
META_CREDENTIAL_REQUIREMENT,
META_MEETING_FINAL_STT_OPTION,
metaFrontendModelForService,
metaSettingsPatchForModel,
} from "@/lib/meta-transcription-settings";

type Translate = ReturnType<typeof useI18n>["t"];
type FormatDate = ReturnType<typeof useI18n>["formatDate"];
Expand Down Expand Up @@ -255,6 +262,7 @@ const TRANSCRIPTION_MODEL_OPTIONS = [
{ value: "soniox-async", label: "Soniox Async" },
{ value: "modulate-realtime", label: "Modulate.AI Multilingual Realtime" },
{ value: "modulate-async", label: "Modulate.AI Multilingual Batch" },
...META_TRANSCRIPTION_OPTIONS.map(({ value, label }) => ({ value, label })),
{
value: GEMINI_REALTIME_TRANSCRIPTION_OPTION.value,
label: GEMINI_REALTIME_TRANSCRIPTION_OPTION.label,
Expand Down Expand Up @@ -892,6 +900,13 @@ function createProviderModelOptions(

return [
benchmarkOption("elevenlabs", "ElevenLabs Live", 6.5, 3.6, "cloud_streaming", "elevenlabs"),
...META_TRANSCRIPTION_OPTIONS.map((option) => ({
...option,
model: providerModels[option.value] || option.model,
detail: `${formatEstimatedEuroFromUsd(option.usdPerHour, localeTag)}/h`,
routeNote: t(option.routeNote),
hourlyCostEur: option.usdPerHour * USD_TO_EUR_FOR_ESTIMATES,
})),
benchmarkOption(
GEMINI_REALTIME_TRANSCRIPTION_OPTION.value,
GEMINI_REALTIME_TRANSCRIPTION_OPTION.label,
Expand Down Expand Up @@ -974,6 +989,7 @@ function createProviderModelOptions(
}

const MEETING_FINAL_STT_OPTIONS = [
META_MEETING_FINAL_STT_OPTION,
{
value: "soniox_async",
label: "Soniox Async",
Expand Down Expand Up @@ -2556,6 +2572,9 @@ export default function Settings() {

const requiredCredentialForTranscriptionModel = (model: string): CredentialRequirement | null => {
switch (model) {
case "meta-realtime":
case "meta-async":
return META_CREDENTIAL_REQUIREMENT;
case "soniox-realtime":
case "soniox-async":
return { provider: "Soniox", label: "Soniox API key", helpKey: "soniox" };
Expand Down Expand Up @@ -2760,6 +2779,10 @@ export default function Settings() {
if (service === "modulate" || service === "modulate_async") {
return service === "modulate_async" ? "modulate-async" : "modulate-realtime";
}
const metaModel = metaFrontendModelForService(service);
if (metaModel) {
return metaModel;
}
if (service === "mistral" || service === "mistral_async") {
return service === "mistral_async" ? "mistral-async" : "mistral-realtime";
}
Expand Down Expand Up @@ -3320,6 +3343,7 @@ export default function Settings() {
}
const previousValue = transcriptionModel;
const geminiSettingsPatch = geminiSettingsPatchForModel(value);
const metaSettingsPatch = metaSettingsPatchForModel(value);
setTranscriptionModel(value);
try {
if (value === "soniox-async") {
Expand All @@ -3330,6 +3354,8 @@ export default function Settings() {
await updateSettings({ defaultSttService: "modulate_async" });
} else if (value === "modulate-realtime") {
await updateSettings({ defaultSttService: "modulate" });
} else if (metaSettingsPatch) {
await updateSettings(metaSettingsPatch);
} else if (geminiSettingsPatch) {
await updateSettings(geminiSettingsPatch);
} else if (value === "mistral-async") {
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,48 @@ fee, not a markup on the model's inference price.
[See the OpenRouter model page.](https://openrouter.ai/microsoft/mai-transcribe-1.5)
[See the OpenRouter fee details.](https://openrouter.ai/docs/faq)

### Meta Muse Voice Transcribe

Choose **Meta Muse Voice Transcribe Realtime** for live microphone transcription,
or **Meta Muse Voice Transcribe Async** to transcribe after stopping. Both use
`muse-voice-transcribe-1.0`, released by Meta on September 1, 2026, and the
existing **Meta Model API** credential in Settings (`MODEL_API_KEY`). Model API
access must be enabled for your Meta project; a Muse Spark entitlement alone
does not prove Voice access.

Realtime displays replaceable partials and injects only completed speech turns.
It does not run local VAD or speaker detection. Async buffers one recording and
submits one HTTP upload; Meta documents no separate async model or job-polling
endpoint. File, YouTube and Meeting final transcription use that same upload
route, with native speaker labels when requested.

Limits: 60 minutes per realtime session; 10 minutes per upload. Uploads must fit
32 MB including multipart overhead. Scriber prepares mono PCM16 WAV at 16 kHz
when necessary and also accepts verified 24 kHz mono PCM16 WAV unchanged.
There are 25 documented languages including German; use automatic language
detection or a supported language hint. Custom vocabulary becomes Meta's
`keywords`. Turn timestamps are approximate processed-audio boundaries, not
word timestamps. The published rate is $0.18 per audio hour for both endpoints.

Optional environment configuration (the UI stores credentials through the
existing secret configuration path):

```dotenv
MODEL_API_KEY=your-meta-model-api-key
SCRIBER_DEFAULT_STT=meta_stt
# Use meta_stt_async above for transcription after stop.
SCRIBER_META_STT_MODEL=muse-voice-transcribe-1.0
```

Unknown model IDs fail closed until their audio contract is verified. Cancellation
never submits buffered audio. Failed uploads and interrupted streams are not
automatically replayed. No local model download or new ML dependency is needed.

[Meta announcement](https://research.meta.ai/blog/introducing-muse-voice-transcribe),
[Voice guide and limits](https://dev.meta.ai/docs/speech-to-text),
[HTTP API](https://dev.meta.ai/docs/api-reference/voice/transcribe),
[WebSocket protocol](https://dev.meta.ai/docs/api-reference/voice/realtime).

### Modulate.AI multilingual transcription

Modulate.AI is available for multilingual batch and realtime transcription.
Expand Down
38 changes: 38 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ architecture notes and should be updated when ownership boundaries change.

## Runtime Overview

### Meta Muse Voice Transcribe (verified 2026-09-02)

`src/meta_stt.py` owns the two Voice API contracts for
`muse-voice-transcribe-1.0`. `meta_stt` authenticates the WebSocket in its first
JSON frame, sends paced raw mono PCM16, and uses provider-native ENDPOINTING.
Interim hypotheses are replaceable `InterimTranscriptionFrame` previews, never
durable text. Completed turns are keyed by `turnId`, emitted in speech-start
order, and deduplicated. Neither `transcript.final` nor `speechEnd` commits a
turn. Stop drains admitted audio, sends `endStream`, and waits for close 1000;
errors or unfinished turns cannot become successful completion. The receiver
belongs to an `AsyncTaskSupervisor`, and the application retains ownership of
the shared HTTP session.
Connection setup, audio sends and socket closure share one lock. Audio waits
for handshake acknowledgement and rechecks shutdown state after waiting for
startup or pacing; cancellation cannot leak a socket or send queued audio.

`meta_stt_async` buffers one recording in the existing disk-backed PCM spool
and submits one multipart HTTP request on normal stop. Cancel discards it.
The same HTTP path serves File, YouTube and Meeting finals. It uses buffered
JSON (`Accept: application/json`), not SSE or a speculative job-polling API.
Both routes use `MODEL_API_KEY`; no model weights or additional dependencies
are required. The immutable route binds the model, vocabulary, language,
endpoint identity and exact audio-format capability. WAV pass-through also
checks sample rate and channel count. `DIARIZATION` is requested only for
speaker-enabled file/Meeting results; normal live dictation has no speakers.
Native turns remain `provider_segment` evidence and never become fabricated
word timestamps. Uploads are limited to ten minutes and 32 MB including
multipart overhead; realtime sessions are limited to one hour. There is no
automatic replay after failure, no session resumption and no automatic
long-recording splitting.

Protocol sources: [guide](https://dev.meta.ai/docs/speech-to-text),
[upload](https://dev.meta.ai/docs/api-reference/voice/transcribe),
[realtime](https://dev.meta.ai/docs/api-reference/voice/realtime),
[schemas](https://dev.meta.ai/docs/api-reference/voice/schemas).

### Application components

Scriber is a hybrid desktop app:

- Tauri 2 shell for installed Windows desktop runtime.
Expand Down
9 changes: 9 additions & 0 deletions docs/ROADMAP_AND_KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ It tracks current status only.

## Recently Completed

Meta Muse Voice Transcribe integration (2026-09-02): separate Realtime and Async
choices use the same `muse-voice-transcribe-1.0` model and Meta Model API key.
Async means one non-blocking HTTP upload, not a separate Meta model or hosted
background job. Current constraints: ten minutes per file/Meeting final,
60 minutes per realtime session, no automatic splitting/resume, and no word
timestamps. HTTP SSE is available from Meta but not used by Scriber; realtime
previews use the WebSocket endpoint. Protocol tests are local; production
account access and installed microphone smoke still require verification.

Desktop runtime:

- Tauri is the primary Windows desktop runtime.
Expand Down
13 changes: 13 additions & 0 deletions docs/TESTING_AND_RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ notes.

## Core Test Commands

Muse Voice Transcribe regression coverage is in `tests/test_meta_stt.py`.
It exercises real local aiohttp HTTP/WebSocket servers: multipart settings and
auth, PCM framing, cumulative previews, overlapping turn completion, endStream
drain, authentication rejection, disconnects, timeout, privacy-safe errors,
no retry, cancel-without-upload, model/language validation and upload limits.
Startup-race tests initialize the real Pipecat processor and use server-side
barriers at both WebSocket upgrade and handshake acknowledgement. They check
waiting audio and cancellation before client-session cleanup can hide leaks.
Run it together with audio-preparation, route-artifact, config and pipeline-stop
tests. Live Meta validation requires an explicitly configured `MODEL_API_KEY`
with Voice access; local protocol tests do not prove account entitlement or
recognition quality. No installer was built as part of this provider change.

Run from repository root unless specified.

Python (always through Scriber's project environment, never bare `python`):
Expand Down
2 changes: 2 additions & 0 deletions src/api/upload_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
"openrouter_stt": (300 * 1024 * 1024, "300MB"),
"modulate": (100 * 1024 * 1024, "100MB"),
"modulate_async": (100 * 1024 * 1024, "100MB"),
"meta_stt": (32_000_000 - 65_536, "32MB including multipart overhead"),
"meta_stt_async": (32_000_000 - 65_536, "32MB including multipart overhead"),
}


Expand Down
11 changes: 11 additions & 0 deletions src/audio_prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ def resolve_provider_audio_selection(
else None
)
original = probe.audio_format if effective_limit is None or probe.byte_length <= effective_limit else None
if provider in {"meta_stt", "meta_stt_async"}:
if probe.duration_ms is not None and probe.duration_ms > 600_000:
raise ProviderAudioPreparationError("Meta STT recordings must not exceed 10 minutes.")
if probe.channels != 1 or probe.sample_rate not in (16_000, 24_000):
original = None
selection = select_audio_input_format(
capability,
route_kind=ProviderAudioRouteKind.BATCH,
Expand Down Expand Up @@ -386,6 +391,12 @@ async def prepare_provider_audio_file(
and frozen_selection.audio_format != probe.audio_format
):
raise ProviderAudioPreparationError("Frozen pass-through format does not match the probed source.")
if (
provider in {"meta_stt", "meta_stt_async"}
and frozen_selection.mode == AudioSelectionMode.ORIGINAL_PASSTHROUGH
and (probe.channels != 1 or probe.sample_rate not in (16_000, 24_000))
):
raise ProviderAudioPreparationError("Frozen Meta WAV must be mono PCM16 at 16 or 24 kHz.")
selected = frozen_selection

generated = selected.mode != AudioSelectionMode.ORIGINAL_PASSTHROUGH
Expand Down
9 changes: 9 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ class Config:
DEFAULT_OPENROUTER_STT_MODEL = "microsoft/mai-transcribe-1.5"
DEFAULT_GEMINI_STT_MODEL = "gemini-3.5-transcribe"
DEFAULT_GEMINI_REALTIME_STT_MODEL = "gemini-3.5-transcribe-live"
DEFAULT_META_STT_MODEL = "muse-voice-transcribe-1.0"
META_STT_MODEL = os.getenv("SCRIBER_META_STT_MODEL", DEFAULT_META_STT_MODEL)
_LEGACY_DEFAULT_GEMINI_STT_MODELS: ClassVar[set[str]] = {"gemini-2.5-flash"}

# API Keys
Expand Down Expand Up @@ -409,6 +411,8 @@ class Config:
"speechmatics_async": "SPEECHMATICS_API_KEY",
"modulate": "MODULATE_API_KEY",
"modulate_async": "MODULATE_API_KEY",
"meta_stt": "MODEL_API_KEY",
"meta_stt_async": "MODEL_API_KEY",
"onnx_local": None, # No API key needed for local models
}

Expand Down Expand Up @@ -438,6 +442,8 @@ class Config:
"speechmatics_async": "Speechmatics (Batch)",
"modulate": "Modulate (Realtime Multilingual)",
"modulate_async": "Modulate (Batch Multilingual)",
"meta_stt": "Meta Muse Voice Transcribe (Realtime)",
"meta_stt_async": "Meta Muse Voice Transcribe (Async)",
"onnx_local": "Local (ONNX)",
}

Expand Down Expand Up @@ -845,6 +851,8 @@ def configured(value: object, fallback: str) -> str:
"soniox-async": configured(cls.SONIOX_ASYNC_MODEL, cls.DEFAULT_SONIOX_ASYNC_MODEL),
"modulate-realtime": "velma-2-stt-streaming",
"modulate-async": "velma-2-stt-batch",
"meta-realtime": cls.META_STT_MODEL,
"meta-async": cls.META_STT_MODEL,
"gemini-stt": configured(cls.GEMINI_STT_MODEL, cls.DEFAULT_GEMINI_STT_MODEL),
"gemini-realtime": configured(
cls.GEMINI_REALTIME_STT_MODEL,
Expand Down Expand Up @@ -1042,6 +1050,7 @@ def add(k, v):
add("GROQ_API_KEY", cls.GROQ_API_KEY or "")
add("SPEECHMATICS_API_KEY", cls.SPEECHMATICS_API_KEY or "")
add("MODULATE_API_KEY", cls.MODULATE_API_KEY or "")
add("SCRIBER_META_STT_MODEL", cls.META_STT_MODEL)

add("SCRIBER_HOTKEY", cls.HOTKEY)
add("SCRIBER_POST_PROCESSING_HOTKEY", cls.POST_PROCESSING_HOTKEY)
Expand Down
Loading
Loading