stt leaves the platform for the transcribe plugin
Speech-to-text is now plugins/transcribe — sidecar, its own config, its own skill and a task for the file browser's context menu. The transcription logic moved BYTE FOR BYTE: 416 lines whose every decision has a reason attached, from the two-pass detect-then-transcribe to the 25 language priming prompts to timeout:false. Verified identical rather than assumed. Removed here: api/stt/, server-settings/stt.ts and its mount, POST /chat/stt, POST /file-browser/transcribe, the FileViewer handler behind the button hidden last release, the Settings screen, the dead useAudioRecording hook and the orphaned locale keys across all six locales. blobToWav stayed. It lived in the same file as the dead hook and is still used by DictateDialog — unrelated to STT, and deleting by file rather than by use is how that would have gone. No permission entry to remove: STT never had one, living under server-admin, chat and files. The registry count is unchanged and docs:check is green. The plugin ships a task that will not appear in the menu yet — the platform loads a plugin's skills, affordances and notifications from its own directory but not its tasks. That loader is the remaining piece of docs/plugin-capabilities.md and is deliberately not built here. 1426 pass, tsgo clean, 18 plugins discovered with none broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -20,7 +20,6 @@ export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
|
||||
export { useChat, type UseChatType } from '../../hooks/useChat';
|
||||
export { useSlashCommands } from './useSlashCommands';
|
||||
export { useAttachments, type UseAttachmentsType } from './useAttachments';
|
||||
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
|
||||
|
||||
export * from './types';
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { useAudioRecording } from './useAudioRecording';
|
||||
|
||||
/**
|
||||
* The microphone is the one resource in this app the browser tells the user about: a recording indicator
|
||||
* stays lit in the tab strip until every track is stopped. Nothing here released it on unmount, so closing
|
||||
* a Chat panel mid-recording left the light on for the life of the tab with no way to switch it off.
|
||||
*
|
||||
* The stubs are the smallest thing that can answer "was the track stopped": a stream that counts stops and
|
||||
* a recorder that reports its state. Neither the real MediaRecorder nor a real device is involved.
|
||||
*/
|
||||
|
||||
const stopped: string[] = [];
|
||||
|
||||
const fakeStream = (label: string) => ({
|
||||
getTracks: () => [{ stop: () => stopped.push(label) }],
|
||||
});
|
||||
|
||||
class FakeMediaRecorder {
|
||||
state: 'recording' | 'inactive' = 'inactive';
|
||||
stream = fakeStream('track');
|
||||
mimeType = 'audio/webm';
|
||||
ondataavailable: ((ev: { data: Blob }) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
this.onstop?.();
|
||||
}
|
||||
}
|
||||
|
||||
const install = () => {
|
||||
stopped.length = 0;
|
||||
Object.assign(globalThis, { MediaRecorder: FakeMediaRecorder });
|
||||
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia: async () => fakeStream('track') },
|
||||
});
|
||||
};
|
||||
|
||||
type Handle = { toggle: () => Promise<void>; recording: boolean };
|
||||
|
||||
const handle: Handle = { toggle: async () => {}, recording: false };
|
||||
|
||||
const Probe = () => {
|
||||
const runner = useAudioRecording(() => {});
|
||||
handle.toggle = runner.toggleRecording;
|
||||
handle.recording = runner.recording;
|
||||
return null;
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('useAudioRecording', () => {
|
||||
test('releases the microphone when its component goes away mid-recording', async () => {
|
||||
install();
|
||||
const view = render(<Probe />);
|
||||
|
||||
await act(async () => {
|
||||
await handle.toggle();
|
||||
});
|
||||
expect(handle.recording).toBe(true);
|
||||
expect(stopped).toEqual([]);
|
||||
|
||||
view.unmount();
|
||||
|
||||
// Not "the user pressed stop" — the panel was closed, swapped or remounted while still recording, and
|
||||
// that is the case that used to leak.
|
||||
expect(stopped).toEqual(['track']);
|
||||
});
|
||||
|
||||
test('unmounting when nothing was ever recorded does nothing', () => {
|
||||
install();
|
||||
const view = render(<Probe />);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(stopped).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||
const { t } = useTranslation();
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording) {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder) return;
|
||||
|
||||
setRecording(false);
|
||||
|
||||
try {
|
||||
if (recorder.state === 'inactive') {
|
||||
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
|
||||
recorder.onstop = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
|
||||
chunksRef.current = [];
|
||||
};
|
||||
recorder.stop();
|
||||
});
|
||||
|
||||
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
if (blob.size === 0) {
|
||||
toast.error(t('chat.composer.noAudio'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const wav = await blobToWav(blob);
|
||||
const formData = new FormData();
|
||||
formData.append('file', wav, 'recording.wav');
|
||||
formData.append('temperature', '0.0');
|
||||
formData.append('temperature_inc', '0.2');
|
||||
formData.append('response_format', 'json');
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN');
|
||||
const res = await fetch('/api/chat/stt', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
const text = (json.text ?? '').trim();
|
||||
if (text) onTranscription(text);
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
recorder.stream?.getTracks().forEach((track) => track.stop());
|
||||
chunksRef.current = [];
|
||||
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (ev) => {
|
||||
if (ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||
};
|
||||
|
||||
recorder.start(250);
|
||||
setRecording(true);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
|
||||
}
|
||||
};
|
||||
|
||||
// Nothing here released the microphone on unmount, and a panel can go away mid-recording — closing the
|
||||
// Chat panel, switching dashboards, a layout change that remounts the slot. The recorder is only ever
|
||||
// stopped by the user pressing the button again, so without this the stream outlives the component and
|
||||
// the browser keeps showing the recording indicator for the life of the tab.
|
||||
useEffect(
|
||||
() => () => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder) return;
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
recorder.stream?.getTracks().forEach((track) => track.stop());
|
||||
mediaRecorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { recording, transcribing, toggleRecording };
|
||||
}
|
||||
|
||||
export type UseAudioRecordingType = ReturnType<typeof useAudioRecording>;
|
||||
|
||||
// ── Helpers ──
|
||||
// The chat composer's own STT-driven recording hook lived here until 2026-08-24, when speech-to-text
|
||||
// left the platform for the `transcribe` plugin. It had no caller — the composer never mounted it — so
|
||||
// it went rather than being ported. `blobToWav` stays: `DictateDialog.tsx` in the file browser uses it
|
||||
// for something that was never STT, just recording a voice memo straight to a `.wav` upload.
|
||||
|
||||
export const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||
const ctx = new AudioContext();
|
||||
|
||||
@@ -12,7 +12,6 @@ export type FileViewerContextValue = {
|
||||
directContent: boolean;
|
||||
ttsLoading: boolean;
|
||||
ocrLoading: boolean;
|
||||
transcribeLoading: boolean;
|
||||
extractAudioLoading: boolean;
|
||||
extractLoading: boolean;
|
||||
autoPlay: boolean;
|
||||
@@ -22,7 +21,6 @@ export type FileViewerContextValue = {
|
||||
setContent: (content: string) => void;
|
||||
handleReadAloud: () => void;
|
||||
handleOcr: () => void;
|
||||
handleTranscribe: () => void;
|
||||
handleExtractAudio: () => void;
|
||||
handleExtract: () => void;
|
||||
handleDownload: () => void;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
FileType2,
|
||||
Volume2,
|
||||
ScanText,
|
||||
FileText,
|
||||
AudioLines,
|
||||
FolderArchive,
|
||||
Save,
|
||||
@@ -28,12 +27,10 @@ export const FileViewerHeader = () => {
|
||||
directContent,
|
||||
ttsLoading,
|
||||
ocrLoading,
|
||||
transcribeLoading,
|
||||
extractAudioLoading,
|
||||
extractLoading,
|
||||
handleReadAloud,
|
||||
handleOcr,
|
||||
handleTranscribe,
|
||||
handleExtractAudio,
|
||||
handleExtract,
|
||||
handleDownload,
|
||||
@@ -98,31 +95,6 @@ export const FileViewerHeader = () => {
|
||||
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
TRANSCRIBE — hidden, not deleted, and the distinction is deliberate.
|
||||
|
||||
Speech-to-text is leaving the platform for a plugin of its own. Until that lands this button calls
|
||||
`POST /file-browser/transcribe`, which refuses on every server here because no STT endpoint is
|
||||
configured anywhere — so it is a control that can only fail, and one of those teaches people to
|
||||
distrust the controls beside it.
|
||||
|
||||
Kept because the wiring is the useful part. When the plugin exists this becomes a call to it, and
|
||||
the handler, the loading state and the sibling-markdown behaviour in FileViewerProvider are all
|
||||
still correct; deleting would mean rediscovering them.
|
||||
|
||||
It may not come back here at all. The other route to the same job is the file browser's context
|
||||
menu, which a plugin reaches with a task — and that path needs no button in this header.
|
||||
*/}
|
||||
{false && fileType === 'audio' && (
|
||||
<button
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribeLoading}
|
||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||
title={t('fileViewer.transcribe')}
|
||||
>
|
||||
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{fileType === 'video' && (
|
||||
<button
|
||||
onClick={handleExtractAudio}
|
||||
|
||||
@@ -55,7 +55,6 @@ export const FileViewerProvider = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ttsLoading, setTtsLoading] = useState(false);
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [transcribeLoading, setTranscribeLoading] = useState(false);
|
||||
const [extractAudioLoading, setExtractAudioLoading] = useState(false);
|
||||
const [extractLoading, setExtractLoading] = useState(false);
|
||||
const [saveResultLoading, setSaveResultLoading] = useState(false);
|
||||
@@ -125,19 +124,6 @@ export const FileViewerProvider = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranscribe = async () => {
|
||||
setTranscribeLoading(true);
|
||||
try {
|
||||
const { transcriptionPath, transcriptionRoot } = await files.transcribe(filePath, { saveNextTo: true });
|
||||
bumpFilesRefresh();
|
||||
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
|
||||
} catch {
|
||||
toast.error(t('fileViewer.transcribeFailed'));
|
||||
} finally {
|
||||
setTranscribeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractAudio = async () => {
|
||||
setExtractAudioLoading(true);
|
||||
try {
|
||||
@@ -194,7 +180,6 @@ export const FileViewerProvider = ({
|
||||
directContent: directContent !== undefined,
|
||||
ttsLoading,
|
||||
ocrLoading,
|
||||
transcribeLoading,
|
||||
extractAudioLoading,
|
||||
extractLoading,
|
||||
autoPlay,
|
||||
@@ -204,7 +189,6 @@ export const FileViewerProvider = ({
|
||||
setContent,
|
||||
handleReadAloud,
|
||||
handleOcr,
|
||||
handleTranscribe,
|
||||
handleExtractAudio,
|
||||
handleExtract,
|
||||
handleDownload,
|
||||
|
||||
@@ -77,13 +77,6 @@ export const useFilesAPI = (root: string = 'home') => {
|
||||
ocr: (path: string, opts?: { saveNextTo?: boolean }) =>
|
||||
client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path, root, ...opts }),
|
||||
|
||||
transcribe: (path: string, opts?: { saveNextTo?: boolean }) =>
|
||||
client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', {
|
||||
path,
|
||||
root,
|
||||
...opts,
|
||||
}),
|
||||
|
||||
extractAudio: (path: string) =>
|
||||
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path, root }),
|
||||
|
||||
|
||||
@@ -26,9 +26,8 @@ export {
|
||||
useChat,
|
||||
useSlashCommands,
|
||||
useAttachments,
|
||||
useAudioRecording,
|
||||
} from './apps/Chat';
|
||||
export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
|
||||
export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType } from './apps/Chat';
|
||||
export * from './apps/Chat/types';
|
||||
export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
||||
export type { SelectedSession } from './apps/ChatHistory';
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
"fileViewer": {
|
||||
"readAloud": "Vorlesen",
|
||||
"ocr": "Text extrahieren (OCR)",
|
||||
"transcribe": "Transkribieren",
|
||||
"extractAudio": "Audio extrahieren",
|
||||
"extract": "Extrahieren",
|
||||
"saveNextTo": "Neben dem Original speichern",
|
||||
@@ -169,7 +168,6 @@
|
||||
"scrollTop": "Nach oben scrollen",
|
||||
"speechFailed": "Sprachaudio konnte nicht erzeugt werden",
|
||||
"ocrFailed": "Text konnte nicht aus dem Bild extrahiert werden",
|
||||
"transcribeFailed": "Audio konnte nicht transkribiert werden",
|
||||
"extractAudioFailed": "Audio konnte nicht aus dem Video extrahiert werden",
|
||||
"extractFailed": "Archiv konnte nicht extrahiert werden",
|
||||
"savedAs": "Gespeichert als “{{name}}”",
|
||||
@@ -404,8 +402,7 @@
|
||||
"removeFromQueue": "Aus der Warteschlange entfernen",
|
||||
"send": "Senden",
|
||||
"startHint": "Senden Sie eine Nachricht, um zu beginnen",
|
||||
"jumpToLatest": "Zur neuesten Nachricht springen",
|
||||
"noAudio": "Es wurde kein Audio aufgenommen"
|
||||
"jumpToLatest": "Zur neuesten Nachricht springen"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "Der Agent dieses Panels existiert nicht mehr.",
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
"fileViewer": {
|
||||
"readAloud": "Read Aloud",
|
||||
"ocr": "Extract Text (OCR)",
|
||||
"transcribe": "Transcribe",
|
||||
"extractAudio": "Extract Audio",
|
||||
"extract": "Extract",
|
||||
"saveNextTo": "Save next to original",
|
||||
@@ -169,7 +168,6 @@
|
||||
"scrollTop": "Scroll to top",
|
||||
"speechFailed": "Failed to generate speech audio",
|
||||
"ocrFailed": "Failed to extract text from image",
|
||||
"transcribeFailed": "Failed to transcribe audio",
|
||||
"extractAudioFailed": "Failed to extract audio from video",
|
||||
"extractFailed": "Failed to extract archive",
|
||||
"savedAs": "Saved as “{{name}}”",
|
||||
@@ -404,8 +402,7 @@
|
||||
"removeFromQueue": "Remove from queue",
|
||||
"send": "Send",
|
||||
"startHint": "Send a message to start",
|
||||
"jumpToLatest": "Jump to latest message",
|
||||
"noAudio": "No audio was captured"
|
||||
"jumpToLatest": "Jump to latest message"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "This panel’s agent no longer exists.",
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
"fileViewer": {
|
||||
"readAloud": "Leer en voz alta",
|
||||
"ocr": "Extraer texto (OCR)",
|
||||
"transcribe": "Transcribir",
|
||||
"extractAudio": "Extraer audio",
|
||||
"extract": "Extraer",
|
||||
"saveNextTo": "Guardar junto al original",
|
||||
@@ -169,7 +168,6 @@
|
||||
"scrollTop": "Ir arriba",
|
||||
"speechFailed": "No se pudo generar el audio de voz",
|
||||
"ocrFailed": "No se pudo extraer el texto de la imagen",
|
||||
"transcribeFailed": "No se pudo transcribir el audio",
|
||||
"extractAudioFailed": "No se pudo extraer el audio del vídeo",
|
||||
"extractFailed": "No se pudo extraer el archivo comprimido",
|
||||
"savedAs": "Guardado como “{{name}}”",
|
||||
@@ -404,8 +402,7 @@
|
||||
"removeFromQueue": "Quitar de la cola",
|
||||
"send": "Enviar",
|
||||
"startHint": "Envía un mensaje para empezar",
|
||||
"jumpToLatest": "Ir al último mensaje",
|
||||
"noAudio": "No se captó ningún audio"
|
||||
"jumpToLatest": "Ir al último mensaje"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "El agente de este panel ya no existe.",
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
"fileViewer": {
|
||||
"readAloud": "Lire à voix haute",
|
||||
"ocr": "Extraire le texte (OCR)",
|
||||
"transcribe": "Transcrire",
|
||||
"extractAudio": "Extraire l'audio",
|
||||
"extract": "Extraire",
|
||||
"saveNextTo": "Enregistrer à côté de l'original",
|
||||
@@ -169,7 +168,6 @@
|
||||
"scrollTop": "Remonter en haut",
|
||||
"speechFailed": "Échec de la génération de l'audio vocal",
|
||||
"ocrFailed": "Échec de l'extraction du texte de l'image",
|
||||
"transcribeFailed": "Échec de la transcription de l'audio",
|
||||
"extractAudioFailed": "Échec de l'extraction de l'audio de la vidéo",
|
||||
"extractFailed": "Échec de l'extraction de l'archive",
|
||||
"savedAs": "Enregistré sous “{{name}}”",
|
||||
@@ -404,8 +402,7 @@
|
||||
"removeFromQueue": "Retirer de la file",
|
||||
"send": "Envoyer",
|
||||
"startHint": "Envoyez un message pour commencer",
|
||||
"jumpToLatest": "Aller au message le plus récent",
|
||||
"noAudio": "Aucun audio n'a été capté"
|
||||
"jumpToLatest": "Aller au message le plus récent"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "L'agent de ce panneau n'existe plus.",
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
"fileViewer": {
|
||||
"readAloud": "Ler em voz alta",
|
||||
"ocr": "Extrair texto (OCR)",
|
||||
"transcribe": "Transcrever",
|
||||
"extractAudio": "Extrair áudio",
|
||||
"extract": "Extrair",
|
||||
"saveNextTo": "Guardar ao lado do original",
|
||||
@@ -169,7 +168,6 @@
|
||||
"scrollTop": "Ir para o topo",
|
||||
"speechFailed": "Falha ao gerar o áudio de fala",
|
||||
"ocrFailed": "Falha ao extrair texto da imagem",
|
||||
"transcribeFailed": "Falha ao transcrever o áudio",
|
||||
"extractAudioFailed": "Falha ao extrair o áudio do vídeo",
|
||||
"extractFailed": "Falha ao extrair o arquivo",
|
||||
"savedAs": "Guardado como “{{name}}”",
|
||||
@@ -404,8 +402,7 @@
|
||||
"removeFromQueue": "Remover da fila",
|
||||
"send": "Enviar",
|
||||
"startHint": "Envie uma mensagem para começar",
|
||||
"jumpToLatest": "Ir para a mensagem mais recente",
|
||||
"noAudio": "Não foi captado áudio"
|
||||
"jumpToLatest": "Ir para a mensagem mais recente"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "O agente deste painel já não existe.",
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
"fileViewer": {
|
||||
"readAloud": "Läs upp",
|
||||
"ocr": "Extrahera text (OCR)",
|
||||
"transcribe": "Transkribera",
|
||||
"extractAudio": "Extrahera ljud",
|
||||
"extract": "Extrahera",
|
||||
"saveNextTo": "Spara bredvid originalet",
|
||||
@@ -169,7 +168,6 @@
|
||||
"scrollTop": "Rulla till toppen",
|
||||
"speechFailed": "Det gick inte att generera talljud",
|
||||
"ocrFailed": "Det gick inte att extrahera text från bilden",
|
||||
"transcribeFailed": "Det gick inte att transkribera ljudet",
|
||||
"extractAudioFailed": "Det gick inte att extrahera ljud från videon",
|
||||
"extractFailed": "Det gick inte att extrahera arkivet",
|
||||
"savedAs": "Sparad som ”{{name}}”",
|
||||
@@ -404,8 +402,7 @@
|
||||
"removeFromQueue": "Ta bort från kön",
|
||||
"send": "Skicka",
|
||||
"startHint": "Skicka ett meddelande för att börja",
|
||||
"jumpToLatest": "Hoppa till senaste meddelandet",
|
||||
"noAudio": "Inget ljud spelades in"
|
||||
"jumpToLatest": "Hoppa till senaste meddelandet"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "Den här panelens agent finns inte längre.",
|
||||
|
||||
Reference in New Issue
Block a user