mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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>
This commit is contained in:
co-authored by
NightMachinery
parent
0132f9f3e3
commit
83b1a3cbe2
@@ -1,62 +1,84 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
discardDictation,
|
||||
expectEmptyDictation,
|
||||
installDictationTranscriptHarness,
|
||||
installSyntheticMicrophone,
|
||||
recordDictation,
|
||||
retryDictation,
|
||||
} from "../support/helpers/dictation";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const TRANSCRIPT = "Recovered on the second attempt";
|
||||
const TRANSCRIPT = "Recovered on the second retry";
|
||||
|
||||
test.describe("Dictation empty result", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("explains an empty transcript and retries the same recording", async ({ page }) => {
|
||||
test("explains an empty result and replays the same audio through repeated retries", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-empty-",
|
||||
title: "Empty dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page);
|
||||
const harness = await installDictationTranscriptHarness(page, ["", TRANSCRIPT]);
|
||||
const harness = await installDictationTranscriptHarness(page, ["", "", TRANSCRIPT]);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Start dictation" }).click();
|
||||
await harness.waitForAudio();
|
||||
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("No speech was recognized. Tap retry to try again.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retry dictation", exact: true }).click();
|
||||
await recordDictation(page, harness);
|
||||
await expectEmptyDictation(page);
|
||||
await retryDictation(page);
|
||||
await expectEmptyDictation(page);
|
||||
await retryDictation(page);
|
||||
|
||||
await expect(composerLocator(page)).toHaveValue(TRANSCRIPT);
|
||||
expect(harness.recordings).toHaveLength(3);
|
||||
expect(harness.recordings[0].length).toBeGreaterThan(0);
|
||||
expect(harness.recordings[1]).toEqual(harness.recordings[0]);
|
||||
expect(harness.recordings[2]).toEqual(harness.recordings[0]);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("says when the microphone only delivered silence", async ({ page }) => {
|
||||
test.describe("narrow composer", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("explains and discards an empty result from a silent microphone", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-silent-",
|
||||
title: "Silent dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page, { silent: true });
|
||||
const harness = await installDictationTranscriptHarness(page, [""]);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await recordDictation(page, harness);
|
||||
await expectEmptyDictation(page);
|
||||
await discardDictation(page);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the latest partial text when the final response is empty", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-silent-",
|
||||
title: "Silent dictation",
|
||||
repoPrefix: "dictation-partial-",
|
||||
title: "Partial dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page);
|
||||
const harness = await installDictationTranscriptHarness(page, [""], {
|
||||
partialTranscript: "Words already recognized",
|
||||
});
|
||||
await installSyntheticMicrophone(page, { silent: true });
|
||||
const harness = await installDictationTranscriptHarness(page, [""]);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Start dictation" }).click();
|
||||
await harness.waitForAudio();
|
||||
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Only silence was recorded. Check your microphone.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await recordDictation(page, harness);
|
||||
await expect(composerLocator(page)).toHaveValue("Words already recognized");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "../fixtures";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
@@ -78,8 +78,14 @@ export async function installSyntheticMicrophone(
|
||||
* Answers dictation stream requests in place of the daemon. Each finish is
|
||||
* answered with the next queued transcript; everything else reaches the daemon.
|
||||
*/
|
||||
export async function installDictationTranscriptHarness(page: Page, transcripts: string[]) {
|
||||
export async function installDictationTranscriptHarness(
|
||||
page: Page,
|
||||
transcripts: string[],
|
||||
options: { partialTranscript?: string } = {},
|
||||
) {
|
||||
const queue = [...transcripts];
|
||||
const streams = new Map<string, Map<number, string>>();
|
||||
const recordings: string[][] = [];
|
||||
let resolveAudioChunk!: () => void;
|
||||
const audioChunk = new Promise<void>((resolve) => {
|
||||
resolveAudioChunk = resolve;
|
||||
@@ -90,10 +96,15 @@ export async function installDictationTranscriptHarness(page: Page, transcripts:
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = sessionMessage(message);
|
||||
const type = request?.type;
|
||||
const dictationId = typeof request?.dictationId === "string" ? request.dictationId : null;
|
||||
if (!request) {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
const type = request.type;
|
||||
const dictationId = typeof request.dictationId === "string" ? request.dictationId : null;
|
||||
|
||||
if (type === "dictation_stream_start" && dictationId) {
|
||||
streams.set(dictationId, new Map());
|
||||
sendSessionMessage(ws, {
|
||||
type: "dictation_stream_ack",
|
||||
payload: { dictationId, ackSeq: -1 },
|
||||
@@ -106,10 +117,22 @@ export async function installDictationTranscriptHarness(page: Page, transcripts:
|
||||
type: "dictation_stream_ack",
|
||||
payload: { dictationId, ackSeq: seq },
|
||||
});
|
||||
streams.get(dictationId)?.set(seq, String(request.audio));
|
||||
if (options.partialTranscript) {
|
||||
sendSessionMessage(ws, {
|
||||
type: "dictation_stream_partial",
|
||||
payload: { dictationId, text: options.partialTranscript },
|
||||
});
|
||||
}
|
||||
resolveAudioChunk();
|
||||
return;
|
||||
}
|
||||
if (type === "dictation_stream_finish" && dictationId) {
|
||||
recordings.push(
|
||||
[...(streams.get(dictationId)?.entries() ?? [])]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, audio]) => audio),
|
||||
);
|
||||
sendSessionMessage(ws, {
|
||||
type: "dictation_stream_finish_accepted",
|
||||
payload: { dictationId, timeoutMs: 5_000 },
|
||||
@@ -127,5 +150,34 @@ export async function installDictationTranscriptHarness(page: Page, transcripts:
|
||||
server.onMessage((message) => ws.send(enableDictationCapability(message)));
|
||||
});
|
||||
|
||||
return { waitForAudio: () => audioChunk };
|
||||
return { waitForAudio: () => audioChunk, recordings };
|
||||
}
|
||||
|
||||
export async function recordDictation(page: Page, harness: { waitForAudio(): Promise<void> }) {
|
||||
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
|
||||
await harness.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();
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ interface DictationControlsProps {
|
||||
|
||||
const EMPTY_REASON_MESSAGE_KEYS = {
|
||||
"no-audio": "message.dictation.empty.noAudio",
|
||||
"silent-audio": "message.dictation.empty.silentAudio",
|
||||
"no-speech": "message.dictation.empty.noSpeech",
|
||||
"no-transcript": "message.dictation.empty.noTranscript",
|
||||
} as const satisfies Record<DictationEmptyReason, string>;
|
||||
|
||||
// The line under the meter once an attempt has finished without text.
|
||||
@@ -41,7 +40,7 @@ function resolveResultText(input: {
|
||||
}): string | null {
|
||||
const { t, status, errorText, emptyReason } = input;
|
||||
if (status === "empty") {
|
||||
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-speech"]);
|
||||
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-transcript"]);
|
||||
}
|
||||
if (status !== "failed") {
|
||||
return null;
|
||||
@@ -237,16 +236,18 @@ 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")}
|
||||
|
||||
@@ -1,50 +1,12 @@
|
||||
import { Buffer } from "buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDictationEmptyReason } from "@/dictation/empty-transcript-reason";
|
||||
import {
|
||||
DICTATION_SILENCE_PEAK_THRESHOLD,
|
||||
isAudiblePeak,
|
||||
pcm16PeakFromBase64,
|
||||
} from "@/dictation/pcm-level";
|
||||
|
||||
function pcm16Base64(samples: number[]): string {
|
||||
const bytes = Buffer.alloc(samples.length * 2);
|
||||
samples.forEach((sample, index) => bytes.writeInt16LE(sample, index * 2));
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
|
||||
describe("pcm16PeakFromBase64", () => {
|
||||
it("returns the largest absolute sample, including negative ones", () => {
|
||||
expect(pcm16PeakFromBase64(pcm16Base64([0, 120, -4000, 2500]))).toBe(4000);
|
||||
});
|
||||
|
||||
it("reads digital silence as zero", () => {
|
||||
expect(pcm16PeakFromBase64(pcm16Base64([0, 0, 0, 0]))).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores a trailing odd byte", () => {
|
||||
const bytes = Buffer.concat([Buffer.from(pcm16Base64([-700]), "base64"), Buffer.from([0x7f])]);
|
||||
expect(pcm16PeakFromBase64(bytes.toString("base64"))).toBe(700);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAudiblePeak", () => {
|
||||
it("uses the daemon's default silence threshold", () => {
|
||||
expect(isAudiblePeak(DICTATION_SILENCE_PEAK_THRESHOLD - 1)).toBe(false);
|
||||
expect(isAudiblePeak(DICTATION_SILENCE_PEAK_THRESHOLD)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDictationEmptyReason", () => {
|
||||
it("reports missing audio when the microphone delivered nothing", () => {
|
||||
expect(resolveDictationEmptyReason({ segmentCount: 0, peak: 0 })).toBe("no-audio");
|
||||
it("reports when the microphone delivered no audio", () => {
|
||||
expect(resolveDictationEmptyReason({ segmentCount: 0 })).toBe("no-audio");
|
||||
});
|
||||
|
||||
it("reports silent audio when nothing crossed the silence threshold", () => {
|
||||
expect(resolveDictationEmptyReason({ segmentCount: 40, peak: 120 })).toBe("silent-audio");
|
||||
});
|
||||
|
||||
it("reports no recognized speech when audible audio produced no text", () => {
|
||||
expect(resolveDictationEmptyReason({ segmentCount: 40, peak: 9000 })).toBe("no-speech");
|
||||
it("does not infer the server's reason from recorded audio", () => {
|
||||
expect(resolveDictationEmptyReason({ segmentCount: 40 })).toBe("no-transcript");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import { isAudiblePeak } from "./pcm-level";
|
||||
/** What the client knows after a dictation finishes without text. */
|
||||
export type DictationEmptyReason = "no-audio" | "no-transcript";
|
||||
|
||||
/**
|
||||
* Why a finished dictation produced no text.
|
||||
*
|
||||
* - `no-audio`: the microphone delivered no audio at all.
|
||||
* - `silent-audio`: audio arrived, but it never rose above the daemon's silence
|
||||
* threshold, so the daemon dropped it before speech-to-text.
|
||||
* - `no-speech`: audible audio reached speech-to-text, which returned no words.
|
||||
*/
|
||||
export type DictationEmptyReason = "no-audio" | "silent-audio" | "no-speech";
|
||||
|
||||
export function resolveDictationEmptyReason(input: {
|
||||
segmentCount: number;
|
||||
peak: number;
|
||||
}): DictationEmptyReason {
|
||||
if (input.segmentCount === 0) {
|
||||
return "no-audio";
|
||||
}
|
||||
if (!isAudiblePeak(input.peak)) {
|
||||
return "silent-audio";
|
||||
}
|
||||
return "no-speech";
|
||||
export function resolveDictationEmptyReason(input: { segmentCount: number }): DictationEmptyReason {
|
||||
return input.segmentCount === 0 ? "no-audio" : "no-transcript";
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
// Mirrors the daemon's default `PASEO_DICTATION_SILENCE_PEAK_THRESHOLD`
|
||||
// (packages/server/src/server/dictation/dictation-stream-manager.ts). Audio whose
|
||||
// peak stays below it is cleared by the daemon and never reaches speech-to-text.
|
||||
// The `volume` level from the audio sources can't be used for this: native reports
|
||||
// a dB curve and web a linear RMS, so the same input reads differently per platform.
|
||||
export const DICTATION_SILENCE_PEAK_THRESHOLD = 300;
|
||||
|
||||
export function pcm16PeakFromBase64(base64Pcm: string): number {
|
||||
const bytes = Buffer.from(base64Pcm, "base64");
|
||||
let peak = 0;
|
||||
for (let offset = 0; offset + 1 < bytes.length; offset += 2) {
|
||||
const sample = Math.abs(bytes.readInt16LE(offset));
|
||||
if (sample > peak) {
|
||||
peak = sample;
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
export function isAudiblePeak(peak: number): boolean {
|
||||
return peak >= DICTATION_SILENCE_PEAK_THRESHOLD;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export type DictationStatus =
|
||||
| "paused"
|
||||
| "uploading"
|
||||
| "failed"
|
||||
// Finished, but produced no text. `emptyReason` says why; the audio is kept for retry.
|
||||
// Finished without text; captured audio remains available for retry.
|
||||
| "empty";
|
||||
|
||||
export interface UseDictationOptions {
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { Buffer } from "buffer";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
import { useDictation, type UseDictationOptions } from "./use-dictation";
|
||||
|
||||
const audioSource = vi.hoisted(() => ({
|
||||
config: null as DictationAudioSourceConfig | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-dictation-audio-source", () => ({
|
||||
useDictationAudioSource: (config: DictationAudioSourceConfig) => {
|
||||
audioSource.config = config;
|
||||
return {
|
||||
start: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
volume: 0,
|
||||
isPaused: false,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
function pcm16Base64(samples: number[]): string {
|
||||
const bytes = Buffer.alloc(samples.length * 2);
|
||||
samples.forEach((sample, index) => bytes.writeInt16LE(sample, index * 2));
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
|
||||
class FakeDictationClient {
|
||||
isConnected = true;
|
||||
transcript = "";
|
||||
finishes: Array<{ dictationId: string; finalSeq: number }> = [];
|
||||
private readonly rawListeners = new Set<(message: SessionOutboundMessage) => void>();
|
||||
|
||||
async startDictationStream(): Promise<void> {}
|
||||
|
||||
sendDictationStreamChunk(dictationId: string, seq: number): void {
|
||||
const ack = { type: "dictation_stream_ack", payload: { dictationId, ackSeq: seq } };
|
||||
for (const listener of this.rawListeners) {
|
||||
listener(ack as SessionOutboundMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async finishDictationStream(dictationId: string, finalSeq: number) {
|
||||
this.finishes.push({ dictationId, finalSeq });
|
||||
return { dictationId, text: this.transcript };
|
||||
}
|
||||
|
||||
cancelDictationStream(): void {}
|
||||
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void {
|
||||
this.rawListeners.add(handler);
|
||||
return () => this.rawListeners.delete(handler);
|
||||
}
|
||||
|
||||
subscribeConnectionStatus(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDictation(client: FakeDictationClient) {
|
||||
const onTranscript = vi.fn();
|
||||
const view = renderHook(() =>
|
||||
useDictation({
|
||||
client: client as unknown as UseDictationOptions["client"],
|
||||
onTranscript,
|
||||
}),
|
||||
);
|
||||
return { view, onTranscript };
|
||||
}
|
||||
|
||||
async function record(view: ReturnType<typeof renderDictation>["view"], segments: string[]) {
|
||||
await act(async () => {
|
||||
await view.result.current.startDictation();
|
||||
});
|
||||
await act(async () => {
|
||||
for (const segment of segments) {
|
||||
audioSource.config?.onPcmSegment(segment);
|
||||
}
|
||||
});
|
||||
await act(async () => {
|
||||
await view.result.current.confirmDictation();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useDictation empty results", () => {
|
||||
beforeEach(() => {
|
||||
audioSource.config = null;
|
||||
});
|
||||
|
||||
it("explains a silent recording and keeps its audio for retry", async () => {
|
||||
const client = new FakeDictationClient();
|
||||
const { view, onTranscript } = renderDictation(client);
|
||||
|
||||
await record(view, [pcm16Base64([0, 40, -60]), pcm16Base64([10, -20, 0])]);
|
||||
|
||||
expect(view.result.current.status).toBe("empty");
|
||||
expect(view.result.current.emptyReason).toBe("silent-audio");
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
|
||||
client.transcript = "hello there";
|
||||
await act(async () => {
|
||||
await view.result.current.retryFailedDictation();
|
||||
});
|
||||
|
||||
expect(client.finishes.map((finish) => finish.finalSeq)).toEqual([1, 1]);
|
||||
expect(onTranscript).toHaveBeenCalledWith("hello there", expect.anything());
|
||||
expect(view.result.current.status).toBe("idle");
|
||||
expect(view.result.current.emptyReason).toBeNull();
|
||||
});
|
||||
|
||||
it("says when audible speech produced no text", async () => {
|
||||
const client = new FakeDictationClient();
|
||||
const { view } = renderDictation(client);
|
||||
|
||||
await record(view, [pcm16Base64([0, 9000, -12000])]);
|
||||
|
||||
expect(view.result.current.status).toBe("empty");
|
||||
expect(view.result.current.emptyReason).toBe("no-speech");
|
||||
});
|
||||
|
||||
it("says when the microphone delivered no audio", async () => {
|
||||
const client = new FakeDictationClient();
|
||||
const { view } = renderDictation(client);
|
||||
|
||||
await record(view, []);
|
||||
|
||||
expect(view.result.current.status).toBe("empty");
|
||||
expect(view.result.current.emptyReason).toBe("no-audio");
|
||||
expect(client.finishes).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears the empty result when dismissed", async () => {
|
||||
const client = new FakeDictationClient();
|
||||
const { view } = renderDictation(client);
|
||||
|
||||
await record(view, [pcm16Base64([5, 5])]);
|
||||
act(() => {
|
||||
view.result.current.discardFailedDictation();
|
||||
});
|
||||
|
||||
expect(view.result.current.status).toBe("idle");
|
||||
expect(view.result.current.emptyReason).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveDictationEmptyReason,
|
||||
type DictationEmptyReason,
|
||||
} from "@/dictation/empty-transcript-reason";
|
||||
import { pcm16PeakFromBase64 } from "@/dictation/pcm-level";
|
||||
import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { AttemptGuard } from "@/utils/attempt-guard";
|
||||
@@ -40,8 +39,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [emptyReason, setEmptyReason] = useState<DictationEmptyReason | null>(null);
|
||||
const latestPartialTranscriptRef = useRef("");
|
||||
// Loudest sample captured in the current recording; explains an empty transcript.
|
||||
const recordingPeakRef = useRef(0);
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
useEffect(() => {
|
||||
@@ -141,7 +138,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const clearStreamingState = useCallback(() => {
|
||||
senderRef.current?.clearAll();
|
||||
recordingPeakRef.current = 0;
|
||||
latestPartialTranscriptRef.current = "";
|
||||
setPartialTranscript("");
|
||||
setEmptyReason(null);
|
||||
@@ -206,12 +202,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim();
|
||||
|
||||
if (!transcriptText) {
|
||||
// Keep the audio so the user can retry, and say why nothing came back.
|
||||
// Keep the audio until the user retries or discards this empty result.
|
||||
setStatus("empty");
|
||||
setEmptyReason(
|
||||
resolveDictationEmptyReason({
|
||||
segmentCount: senderRef.current?.getSegmentCount() ?? 0,
|
||||
peak: recordingPeakRef.current,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -246,7 +241,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const audio = useDictationAudioSource({
|
||||
onPcmSegment: (audioData) => {
|
||||
recordingPeakRef.current = Math.max(recordingPeakRef.current, pcm16PeakFromBase64(audioData));
|
||||
senderRef.current?.enqueueSegment(audioData);
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -373,8 +373,7 @@ export const ar: TranslationResources = {
|
||||
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
|
||||
empty: {
|
||||
noAudio: "لم يُسجَّل أي صوت. هل يستخدم تطبيق آخر الميكروفون؟",
|
||||
silentAudio: "سُجِّل صمت فقط. تحقّق من الميكروفون.",
|
||||
noSpeech: "لم يُتعرَّف على أي كلام. اضغط على إعادة المحاولة للمحاولة مجددًا.",
|
||||
noTranscript: "لم يتم إرجاع نص. اضغط على إعادة المحاولة.",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -370,8 +370,7 @@ export const en = {
|
||||
failedRetry: "Dictation failed. Tap retry.",
|
||||
empty: {
|
||||
noAudio: "No audio was recorded. Is another app using the microphone?",
|
||||
silentAudio: "Only silence was recorded. Check your microphone.",
|
||||
noSpeech: "No speech was recognized. Tap retry to try again.",
|
||||
noTranscript: "No transcription was returned. Tap retry to try again.",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -378,8 +378,7 @@ export const es: TranslationResources = {
|
||||
failedRetry: "El dictado falló. Toca reintentar.",
|
||||
empty: {
|
||||
noAudio: "No se grabó audio. ¿Otra app está usando el micrófono?",
|
||||
silentAudio: "Solo se grabó silencio. Revisa tu micrófono.",
|
||||
noSpeech: "No se reconoció ninguna voz. Toca reintentar para volver a intentarlo.",
|
||||
noTranscript: "No se devolvió ninguna transcripción. Toca reintentar.",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -378,8 +378,7 @@ export const fr: TranslationResources = {
|
||||
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 ?",
|
||||
silentAudio: "Seul du silence a été enregistré. Vérifiez votre micro.",
|
||||
noSpeech: "Aucune parole reconnue. Appuyez sur Réessayer pour recommencer.",
|
||||
noTranscript: "Aucune transcription reçue. Appuyez sur Réessayer.",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -378,8 +378,7 @@ export const ja: TranslationResources = {
|
||||
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
|
||||
empty: {
|
||||
noAudio: "音声が録音されませんでした。別のアプリがマイクを使用していませんか?",
|
||||
silentAudio: "無音しか録音されませんでした。マイクを確認してください。",
|
||||
noSpeech: "音声を認識できませんでした。再試行をタップしてください。",
|
||||
noTranscript: "文字起こしが返されませんでした。再試行をタップしてください。",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -374,8 +374,7 @@ export const ko: TranslationResources = {
|
||||
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
|
||||
empty: {
|
||||
noAudio: "녹음된 오디오가 없습니다. 다른 앱이 마이크를 사용 중인가요?",
|
||||
silentAudio: "무음만 녹음되었습니다. 마이크를 확인하세요.",
|
||||
noSpeech: "음성이 인식되지 않았습니다. 다시 시도하려면 누르세요.",
|
||||
noTranscript: "전사 결과가 없습니다. 다시 시도하려면 누르세요.",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -377,8 +377,7 @@ export const ptBR: TranslationResources = {
|
||||
failedRetry: "Falha no ditado. Toque para tentar novamente.",
|
||||
empty: {
|
||||
noAudio: "Nenhum áudio foi gravado. Outro app está usando o microfone?",
|
||||
silentAudio: "Só foi gravado silêncio. Verifique seu microfone.",
|
||||
noSpeech: "Nenhuma fala foi reconhecida. Toque para tentar novamente.",
|
||||
noTranscript: "Nenhuma transcrição foi retornada. Toque para tentar novamente.",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -377,8 +377,7 @@ export const ru: TranslationResources = {
|
||||
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
|
||||
empty: {
|
||||
noAudio: "Звук не записан. Возможно, микрофон занят другим приложением?",
|
||||
silentAudio: "Записана только тишина. Проверьте микрофон.",
|
||||
noSpeech: "Речь не распознана. Нажмите «Повторить», чтобы попробовать снова.",
|
||||
noTranscript: "Текст не получен. Нажмите «Повторить».",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
@@ -373,8 +373,7 @@ export const zhCN: TranslationResources = {
|
||||
failedRetry: "听写失败。点按重试。",
|
||||
empty: {
|
||||
noAudio: "未录到音频。是否有其他应用正在使用麦克风?",
|
||||
silentAudio: "只录到了静音。请检查麦克风。",
|
||||
noSpeech: "未识别到语音。点按重试再试一次。",
|
||||
noTranscript: "未返回转写文本。点按重试。",
|
||||
},
|
||||
},
|
||||
question: {
|
||||
|
||||
Reference in New Issue
Block a user