Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 9e635d9b46 test(app): pin the silent dictation reproduction conditions 2026-10-31 07:48:02 +00:00
paseo-bot[bot] 3e859625ed test(app): verify dictation retries through the real daemon
Observe actual dictation frames instead of constructing protocol success
inside Playwright. Silence is finalized by the daemon's existing filter,
so the test needs no transcription response replacement.

Keep the completion decision and recording ownership behind a typed port
for deterministic empty, successful, and partial-result unit coverage.
2026-10-31 07:42:30 +00:00
paseo-bot[bot]andNightMachinery 83b1a3cbe2 fix(app): keep empty dictation feedback factual and readable
An empty transcript does not identify whether silence filtering or speech
recognition produced it. Preserve the recording and offer Retry without
mirroring the daemon's independently configured silence threshold.

Show the finished result in place of the recording meter so its explanation
fits in the composer. Verify exact PCM replay across repeated retries,
discard on a narrow screen, and partial-text fallback through browser flows.

Co-authored-by: NightMachinery <feraidoonmehri@gmail.com>
2026-10-31 07:20:43 +00:00
NightMachinery 0132f9f3e3 fix(app): explain empty dictation results and retain recordings
Keep empty transcription results visible and preserve captured audio for retry.
2026-10-31 07:20:31 +00:00
18 changed files with 398 additions and 33 deletions
@@ -0,0 +1,80 @@
import { expect, test } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
discardDictation,
expectEmptyDictation,
installSyntheticMicrophone,
observeDictation,
recordDictation,
retryDictation,
} from "../support/helpers/dictation";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// Silence is finalized by the real daemon without calling a transcription service.
// A closed local endpoint makes an unexpected transcription request fail the test.
test.use({
e2eDaemonEnvironment: {
PASEO_DICTATION_ENABLED: "1",
PASEO_DICTATION_SILENCE_PEAK_THRESHOLD: "300",
OPENAI_API_KEY: "unused-for-silent-recordings",
OPENAI_BASE_URL: "http://127.0.0.1:9/v1",
},
});
test.describe("Dictation empty result", () => {
test("the daemon accepts the same recording through repeated empty-result retries", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "dictation-empty-",
title: "Empty dictation",
});
await installSyntheticMicrophone(page, { silent: true });
const observed = observeDictation(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await recordDictation(page, observed);
await expectEmptyDictation(page);
await retryDictation(page);
await expectEmptyDictation(page);
await retryDictation(page);
await expectEmptyDictation(page);
expect(observed.recordings).toHaveLength(3);
expect(observed.recordings[0].audio.length).toBeGreaterThan(0);
expect(observed.recordings[0].finalSeq).toBe(observed.recordings[0].audio.length - 1);
expect(observed.recordings[0].text).toBe("");
expect(observed.recordings[1]).toEqual(observed.recordings[0]);
expect(observed.recordings[2]).toEqual(observed.recordings[0]);
await discardDictation(page);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue("");
} finally {
await agent.cleanup();
}
});
test.describe("narrow composer", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("explains and discards an empty result", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "dictation-silent-",
title: "Silent dictation",
});
await installSyntheticMicrophone(page, { silent: true });
const observed = observeDictation(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await recordDictation(page, observed);
await expectEmptyDictation(page);
await discardDictation(page);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue("");
} finally {
await agent.cleanup();
}
});
});
});
@@ -0,0 +1,106 @@
import { expect, type Page } from "../fixtures";
/**
* Replaces the browser microphone with an oscillator. `silent` keeps the stream
* running at zero gain, which is what a muted or wrongly routed input delivers.
*/
export async function installSyntheticMicrophone(
page: Page,
options: { silent?: boolean } = {},
): Promise<void> {
await page.addInitScript((silent: boolean) => {
const mediaDevices = navigator.mediaDevices;
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async () => {
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
gain.gain.value = silent ? 0 : 1;
const destination = context.createMediaStreamDestination();
oscillator.connect(gain);
gain.connect(destination);
oscillator.start();
return destination.stream;
},
});
}, options.silent ?? false);
}
/** Observe real browser/daemon traffic without intercepting or answering it. */
export function observeDictation(page: Page) {
const streams = new Map<string, { audio: Map<number, string>; finalSeq: number }>();
const recordings: Array<{ audio: string[]; finalSeq: number; text: string }> = [];
let resolveAudio!: () => void;
const audio = new Promise<void>((resolve) => {
resolveAudio = resolve;
});
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = sessionMessage(payload);
if (!request || typeof request.dictationId !== "string") return;
const id = request.dictationId;
if (request.type === "dictation_stream_start") {
streams.set(id, { audio: new Map(), finalSeq: -1 });
}
if (request.type === "dictation_stream_chunk") {
streams.get(id)!.audio.set(request.seq as number, request.audio as string);
resolveAudio();
}
if (request.type === "dictation_stream_finish") {
streams.get(id)!.finalSeq = request.finalSeq as number;
}
});
socket.on("framereceived", ({ payload }) => {
const response = sessionMessage(payload);
if (response?.type !== "dictation_stream_final") return;
const final = response.payload as { dictationId: string; text: string };
const stream = streams.get(final.dictationId)!;
recordings.push({
audio: [...stream.audio.entries()].sort(([a], [b]) => a - b).map(([, chunk]) => chunk),
finalSeq: stream.finalSeq,
text: final.text,
});
});
});
return { waitForAudio: () => audio, recordings };
}
function sessionMessage(payload: string | Buffer): Record<string, unknown> | null {
try {
const envelope = JSON.parse(typeof payload === "string" ? payload : payload.toString("utf8"));
return envelope.type === "session" ? envelope.message : null;
} catch {
return null;
}
}
export async function recordDictation(page: Page, observed: { waitForAudio(): Promise<void> }) {
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
await observed.waitForAudio();
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
}
export async function expectEmptyDictation(page: Page) {
const message = page.getByText("No transcription was returned. Tap retry to try again.", {
exact: true,
});
await expect(message).toBeVisible();
const bounds = await message.boundingBox();
const cancel = await page
.getByRole("button", { name: "Cancel dictation", exact: true })
.boundingBox();
expect(bounds).not.toBeNull();
expect(cancel).not.toBeNull();
expect(bounds!.y).toBeGreaterThanOrEqual(cancel!.y);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(cancel!.y + cancel!.height);
}
export async function retryDictation(page: Page) {
await page.getByRole("button", { name: "Retry dictation", exact: true }).click();
}
export async function discardDictation(page: Page) {
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
}
@@ -182,8 +182,8 @@ export async function startIsolatedHostDaemon(
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
env: {
...withDisabledE2ESpeechEnv(process.env),
...options.environment,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: serverId,
@@ -192,7 +192,7 @@ export async function startIsolatedHostDaemon(
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
},
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
@@ -4,9 +4,11 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
import type { DictationEmptyReason } from "@/dictation/result";
interface DictationControlsProps {
volume: number;
@@ -24,6 +26,30 @@ interface DictationControlsProps {
disabled?: boolean;
}
const EMPTY_REASON_MESSAGE_KEYS = {
"no-audio": "message.dictation.empty.noAudio",
"no-transcript": "message.dictation.empty.noTranscript",
} as const satisfies Record<DictationEmptyReason, string>;
// The line under the meter once an attempt has finished without text.
function resolveResultText(input: {
t: TFunction;
status: DictationStatus;
errorText?: string;
emptyReason?: DictationEmptyReason | null;
}): string | null {
const { t, status, errorText, emptyReason } = input;
if (status === "empty") {
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-transcript"]);
}
if (status !== "failed") {
return null;
}
return errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry");
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -147,18 +173,26 @@ export function DictationOverlay({
isProcessing,
status,
errorText,
emptyReason,
onCancel,
onAccept,
onAcceptAndSend,
onRetry,
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & {
errorText?: string;
emptyReason?: DictationEmptyReason | null;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const isEmpty = status === "empty";
// An empty result is a finished attempt the user has to acknowledge, like a failure.
const isFailed = status === "failed" || isEmpty;
const canRetry = !isEmpty || emptyReason !== "no-audio";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const resultText = resolveResultText({ t, status, errorText, emptyReason });
const containerStyle = useMemo(
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
@@ -202,26 +236,26 @@ export function DictationOverlay({
</Pressable>
<View style={overlayStyles.centerContainer}>
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{!resultText ? (
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
) : null}
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
{resultText ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry")}
{resultText}
</Text>
) : null}
</View>
@@ -232,7 +266,7 @@ export function DictationOverlay({
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
{!actionsDisabled && isFailed && canRetry ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
+15 -3
View File
@@ -562,6 +562,7 @@ function MessageInputOverlay({
isDictationProcessing,
dictationStatus,
dictationError,
dictationEmptyReason,
onCancelRecording,
onAcceptRecording,
onAcceptAndSendRecording,
@@ -585,6 +586,7 @@ function MessageInputOverlay({
isDictationProcessing: boolean;
dictationStatus: React.ComponentProps<typeof DictationOverlay>["status"];
dictationError: string | null;
dictationEmptyReason: React.ComponentProps<typeof DictationOverlay>["emptyReason"];
onCancelRecording: () => Promise<void>;
onAcceptRecording: () => Promise<void>;
onAcceptAndSendRecording: () => Promise<void>;
@@ -593,6 +595,7 @@ function MessageInputOverlay({
onRealtimeVoiceStop: () => void;
}) {
if (showDictationOverlay) {
const awaitsAcknowledgement = dictationStatus === "failed" || dictationStatus === "empty";
return (
<DictationOverlay
volume={dictationVolume}
@@ -601,11 +604,12 @@ function MessageInputOverlay({
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined}
emptyReason={dictationEmptyReason}
onCancel={onCancelRecording}
onAccept={onAcceptRecording}
onAcceptAndSend={onAcceptAndSendRecording}
onRetry={dictationStatus === "failed" ? onRetryFailedRecording : undefined}
onDiscard={dictationStatus === "failed" ? onDiscardFailedRecording : undefined}
onRetry={awaitsAcknowledgement ? onRetryFailedRecording : undefined}
onDiscard={awaitsAcknowledgement ? onDiscardFailedRecording : undefined}
/>
);
}
@@ -1032,7 +1036,12 @@ function computeShouldShowDictationOverlay(
isDictationProcessing: boolean,
dictationStatus: string,
): boolean {
return isDictating || isDictationProcessing || dictationStatus === "failed";
return (
isDictating ||
isDictationProcessing ||
dictationStatus === "failed" ||
dictationStatus === "empty"
);
}
function computeIsDictationStartEnabled(
@@ -1533,6 +1542,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
duration: dictationDuration,
error: dictationError,
status: dictationStatus,
emptyReason: dictationEmptyReason,
startDictation,
cancelDictation,
confirmDictation,
@@ -2180,6 +2190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictationProcessing={isDictationProcessing}
dictationStatus={dictationStatus}
dictationError={dictationError}
dictationEmptyReason={dictationEmptyReason}
onCancelRecording={handleCancelRecording}
onAcceptRecording={handleAcceptRecording}
onAcceptAndSendRecording={handleAcceptAndSendRecording}
@@ -2201,6 +2212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictationProcessing,
dictationStatus,
dictationError,
dictationEmptyReason,
handleCancelRecording,
handleAcceptRecording,
handleAcceptAndSendRecording,
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { completeDictation, type DictationRecording } from "./result";
class RecordedAudio implements DictationRecording {
constructor(private segments: string[]) {}
getSegmentCount() {
return this.segments.length;
}
clearAll() {
this.segments = [];
}
}
describe("completing dictation", () => {
it("keeps a recording through repeated empty results until a retry returns text", () => {
const recording = new RecordedAudio(["first PCM chunk", "second PCM chunk"]);
const empty = { finalText: " ", partialText: "", recording };
const result = completeDictation(empty);
expect(recording.getSegmentCount()).toBe(2);
expect(result).toEqual({ status: "empty", reason: "no-transcript" });
expect(completeDictation(empty)).toEqual({ status: "empty", reason: "no-transcript" });
expect(recording.getSegmentCount()).toBe(2);
expect(completeDictation({ ...empty, finalText: " recovered " })).toEqual({
status: "transcribed",
text: "recovered",
});
expect(recording.getSegmentCount()).toBe(0);
});
it("keeps usable partial text when the final response is empty", () => {
const recording = new RecordedAudio(["PCM chunk"]);
expect(
completeDictation({ finalText: "", partialText: " recognized words ", recording }),
).toEqual({
status: "transcribed",
text: "recognized words",
});
expect(recording.getSegmentCount()).toBe(0);
});
it("reports a missing recording without inventing a reason for the empty result", () => {
expect(
completeDictation({ finalText: "", partialText: "", recording: new RecordedAudio([]) }),
).toEqual({
status: "empty",
reason: "no-audio",
});
});
});
+26
View File
@@ -0,0 +1,26 @@
export type DictationEmptyReason = "no-audio" | "no-transcript";
export interface DictationRecording {
getSegmentCount(): number;
clearAll(): void;
}
export type DictationResult =
| { status: "empty"; reason: DictationEmptyReason }
| { status: "transcribed"; text: string };
export function completeDictation(input: {
finalText: string;
partialText: string;
recording: DictationRecording;
}): DictationResult {
const text = input.finalText.trim() || input.partialText.trim();
if (!text) {
return {
status: "empty",
reason: input.recording.getSegmentCount() === 0 ? "no-audio" : "no-transcript",
};
}
input.recording.clearAll();
return { status: "transcribed", text };
}
+10 -1
View File
@@ -1,6 +1,14 @@
import type { DictationEmptyReason } from "@/dictation/result";
import { i18n } from "@/i18n/i18next";
export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
export type DictationStatus =
| "idle"
| "recording"
| "paused"
| "uploading"
| "failed"
// Finished without text; captured audio remains available for retry.
| "empty";
export interface UseDictationOptions {
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
@@ -22,6 +30,7 @@ export interface UseDictationResult {
duration: number;
error: string | null;
status: DictationStatus;
emptyReason: DictationEmptyReason | null;
startDictation: () => Promise<void>;
cancelDictation: () => Promise<void>;
confirmDictation: () => Promise<void>;
+22 -9
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DictationStreamSender } from "@/dictation/dictation-stream-sender";
import { completeDictation, type DictationEmptyReason } from "@/dictation/result";
import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source";
import { generateMessageId } from "@/types/stream";
import { AttemptGuard } from "@/utils/attempt-guard";
@@ -33,6 +34,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle");
const [emptyReason, setEmptyReason] = useState<DictationEmptyReason | null>(null);
const latestPartialTranscriptRef = useRef("");
const onTranscriptRef = useRef(onTranscript);
@@ -131,12 +133,17 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
[setError],
);
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
const clearTranscriptState = useCallback(() => {
latestPartialTranscriptRef.current = "";
setPartialTranscript("");
setEmptyReason(null);
}, []);
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
clearTranscriptState();
}, [clearTranscriptState]);
const startNewStream = useCallback(async (reason: string) => {
await senderRef.current?.restartStream(reason);
}, []);
@@ -192,16 +199,20 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setDuration(0);
setStatus("idle");
const transcriptText =
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim();
clearStreamingState();
if (!transcriptText) {
const result = completeDictation({
finalText: text,
partialText: latestPartialTranscriptRef.current,
recording: senderRef.current!,
});
if (result.status === "empty") {
setStatus("empty");
setEmptyReason(result.reason);
return;
}
onTranscriptRef.current?.(transcriptText, { requestId });
clearTranscriptState();
onTranscriptRef.current?.(result.text, { requestId });
},
[clearStreamingState],
[clearTranscriptState],
);
const handleDictationFailure = useCallback(
@@ -395,6 +406,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return;
}
setError(null);
setEmptyReason(null);
setStatus("uploading");
setIsProcessing(true);
isProcessingRef.current = true;
@@ -462,6 +474,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
duration,
error,
status: status === "recording" && audio.isPaused ? "paused" : status,
emptyReason,
startDictation,
cancelDictation,
confirmDictation,
+4
View File
@@ -371,6 +371,10 @@ export const ar: TranslationResources = {
insertAndSend: "أدخل النسخ وأرسل",
failed: "فشل الإملاء:{{error}}",
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
empty: {
noAudio: "لم يُسجَّل أي صوت. هل يستخدم تطبيق آخر الميكروفون؟",
noTranscript: "لم يتم إرجاع نص. اضغط على إعادة المحاولة.",
},
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
+4
View File
@@ -368,6 +368,10 @@ export const en = {
insertAndSend: "Insert transcription and send",
failed: "Dictation failed: {{error}}",
failedRetry: "Dictation failed. Tap retry.",
empty: {
noAudio: "No audio was recorded. Is another app using the microphone?",
noTranscript: "No transcription was returned. Tap retry to try again.",
},
},
question: {
responseFailed: "Could not send your response. Try again.",
+4
View File
@@ -376,6 +376,10 @@ export const es: TranslationResources = {
insertAndSend: "Insertar transcripción y enviar",
failed: "Fallo en el dictado:{{error}}",
failedRetry: "El dictado falló. Toca reintentar.",
empty: {
noAudio: "No se grabó audio. ¿Otra app está usando el micrófono?",
noTranscript: "No se devolvió ninguna transcripción. Toca reintentar.",
},
},
question: {
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
+4
View File
@@ -376,6 +376,10 @@ export const fr: TranslationResources = {
insertAndSend: "Insérer la transcription et envoyer",
failed: "Échec de la dictée : {{error}}",
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
empty: {
noAudio: "Aucun son n'a été enregistré. Une autre app utilise-t-elle le micro ?",
noTranscript: "Aucune transcription reçue. Appuyez sur Réessayer.",
},
},
question: {
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
+4
View File
@@ -376,6 +376,10 @@ export const ja: TranslationResources = {
insertAndSend: "文字起こしを挿入して送信",
failed: "音声入力に失敗しました: {{error}}",
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
empty: {
noAudio: "音声が録音されませんでした。別のアプリがマイクを使用していませんか?",
noTranscript: "文字起こしが返されませんでした。再試行をタップしてください。",
},
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
+4
View File
@@ -372,6 +372,10 @@ export const ko: TranslationResources = {
insertAndSend: "전사 삽입 후 보내기",
failed: "받아쓰기 실패: {{error}}",
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
empty: {
noAudio: "녹음된 오디오가 없습니다. 다른 앱이 마이크를 사용 중인가요?",
noTranscript: "전사 결과가 없습니다. 다시 시도하려면 누르세요.",
},
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
+4
View File
@@ -375,6 +375,10 @@ export const ptBR: TranslationResources = {
insertAndSend: "Inserir transcrição e enviar",
failed: "Falha no ditado: {{error}}",
failedRetry: "Falha no ditado. Toque para tentar novamente.",
empty: {
noAudio: "Nenhum áudio foi gravado. Outro app está usando o microfone?",
noTranscript: "Nenhuma transcrição foi retornada. Toque para tentar novamente.",
},
},
question: {
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
+4
View File
@@ -375,6 +375,10 @@ export const ru: TranslationResources = {
insertAndSend: "Вставить транскрипцию и отправить",
failed: "Диктовка не удалась: {{error}}",
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
empty: {
noAudio: "Звук не записан. Возможно, микрофон занят другим приложением?",
noTranscript: "Текст не получен. Нажмите «Повторить».",
},
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
+4
View File
@@ -371,6 +371,10 @@ export const zhCN: TranslationResources = {
insertAndSend: "插入转写并发送",
failed: "听写失败:{{error}}",
failedRetry: "听写失败。点按重试。",
empty: {
noAudio: "未录到音频。是否有其他应用正在使用麦克风?",
noTranscript: "未返回转写文本。点按重试。",
},
},
question: {
responseFailed: "未能发送你的回复,请重试。",