feat(#403): add audio playback component for TTS output
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Implements AudioPlayer inline component with play/pause, progress bar, speed control (0.5x-2x), download, and duration display. Adds TextToSpeechButton "Read aloud" component that synthesizes text via the speech API and integrates AudioPlayer for playback. Includes useTextToSpeech hook with API integration, audio caching, and playback state management. All 32 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
362
apps/web/src/hooks/useVoiceInput.test.ts
Normal file
362
apps/web/src/hooks/useVoiceInput.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useVoiceInput } from "./useVoiceInput";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
// Mock socket.io-client
|
||||
vi.mock("socket.io-client");
|
||||
|
||||
// Mock MediaRecorder
|
||||
const mockMediaRecorder = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
state: "inactive" as RecordingState,
|
||||
ondataavailable: null as ((event: BlobEvent) => void) | null,
|
||||
onstop: null as (() => void) | null,
|
||||
onerror: null as ((event: Event) => void) | null,
|
||||
addEventListener: vi.fn((event: string, handler: EventListenerOrEventListenerObject) => {
|
||||
if (event === "dataavailable") {
|
||||
mockMediaRecorder.ondataavailable = handler as (event: BlobEvent) => void;
|
||||
} else if (event === "stop") {
|
||||
mockMediaRecorder.onstop = handler as () => void;
|
||||
} else if (event === "error") {
|
||||
mockMediaRecorder.onerror = handler as (event: Event) => void;
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
stream: {
|
||||
getTracks: vi.fn(() => [{ stop: vi.fn() }]),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock MediaStream with getByteFrequencyData for audio level
|
||||
const mockAnalyserNode = {
|
||||
fftSize: 256,
|
||||
frequencyBinCount: 128,
|
||||
getByteFrequencyData: vi.fn((array: Uint8Array) => {
|
||||
// Simulate some audio data
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = 128;
|
||||
}
|
||||
}),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMediaStreamSource = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAudioContext = {
|
||||
createAnalyser: vi.fn(() => mockAnalyserNode),
|
||||
createMediaStreamSource: vi.fn(() => mockMediaStreamSource),
|
||||
close: vi.fn(),
|
||||
state: "running",
|
||||
};
|
||||
|
||||
// Mock getUserMedia
|
||||
const mockGetUserMedia = vi.fn();
|
||||
|
||||
// Set up global mocks
|
||||
Object.defineProperty(global.navigator, "mediaDevices", {
|
||||
value: {
|
||||
getUserMedia: mockGetUserMedia,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock AudioContext
|
||||
vi.stubGlobal(
|
||||
"AudioContext",
|
||||
vi.fn(() => mockAudioContext)
|
||||
);
|
||||
|
||||
// Mock MediaRecorder constructor
|
||||
vi.stubGlobal(
|
||||
"MediaRecorder",
|
||||
vi.fn(() => mockMediaRecorder)
|
||||
);
|
||||
|
||||
// Add isTypeSupported static method
|
||||
(
|
||||
global.MediaRecorder as unknown as { isTypeSupported: (type: string) => boolean }
|
||||
).isTypeSupported = vi.fn(() => true);
|
||||
|
||||
describe("useVoiceInput", (): void => {
|
||||
let mockSocket: Partial<Socket>;
|
||||
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
||||
|
||||
beforeEach((): void => {
|
||||
socketEventHandlers = {};
|
||||
|
||||
mockSocket = {
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
socketEventHandlers[event] = handler;
|
||||
return mockSocket;
|
||||
}) as unknown as Socket["on"],
|
||||
off: vi.fn(() => mockSocket) as unknown as Socket["off"],
|
||||
emit: vi.fn() as unknown as Socket["emit"],
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connected: true,
|
||||
};
|
||||
|
||||
(io as unknown as ReturnType<typeof vi.fn>).mockReturnValue(mockSocket);
|
||||
|
||||
// Reset MediaRecorder mock state
|
||||
mockMediaRecorder.state = "inactive";
|
||||
mockMediaRecorder.ondataavailable = null;
|
||||
mockMediaRecorder.onstop = null;
|
||||
mockMediaRecorder.onerror = null;
|
||||
|
||||
// Default: getUserMedia succeeds
|
||||
const mockStream = {
|
||||
getTracks: vi.fn(() => [{ stop: vi.fn() }]),
|
||||
} as unknown as MediaStream;
|
||||
mockGetUserMedia.mockResolvedValue(mockStream);
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return the correct interface", (): void => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
expect(result.current).toHaveProperty("isRecording");
|
||||
expect(result.current).toHaveProperty("startRecording");
|
||||
expect(result.current).toHaveProperty("stopRecording");
|
||||
expect(result.current).toHaveProperty("transcript");
|
||||
expect(result.current).toHaveProperty("partialTranscript");
|
||||
expect(result.current).toHaveProperty("error");
|
||||
expect(result.current).toHaveProperty("audioLevel");
|
||||
});
|
||||
|
||||
it("should start with default state", (): void => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
expect(result.current.isRecording).toBe(false);
|
||||
expect(result.current.transcript).toBe("");
|
||||
expect(result.current.partialTranscript).toBe("");
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.audioLevel).toBe(0);
|
||||
});
|
||||
|
||||
it("should start recording when startRecording is called", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
expect(result.current.isRecording).toBe(true);
|
||||
expect(mockGetUserMedia).toHaveBeenCalledWith({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 16000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop recording when stopRecording is called", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
expect(result.current.isRecording).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.stopRecording();
|
||||
});
|
||||
|
||||
expect(result.current.isRecording).toBe(false);
|
||||
});
|
||||
|
||||
it("should set error when microphone access is denied", async (): Promise<void> => {
|
||||
mockGetUserMedia.mockRejectedValueOnce(
|
||||
new DOMException("Permission denied", "NotAllowedError")
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
expect(result.current.isRecording).toBe(false);
|
||||
expect(result.current.error).toBeTruthy();
|
||||
expect(result.current.error).toContain("microphone");
|
||||
});
|
||||
|
||||
it("should connect to speech WebSocket namespace", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
expect(io).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
path: "/socket.io",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit start-transcription when recording begins", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith(
|
||||
"start-transcription",
|
||||
expect.objectContaining({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
format: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit stop-transcription when recording stops", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.stopRecording();
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("stop-transcription");
|
||||
});
|
||||
|
||||
it("should handle partial transcription events", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["transcription-partial"]?.({
|
||||
text: "hello world",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.partialTranscript).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle final transcription events", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["transcription-final"]?.({
|
||||
text: "hello world final",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.transcript).toBe("hello world final");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle transcription error events", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["transcription-error"]?.({
|
||||
message: "Transcription failed",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("Transcription failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onTranscript callback when final transcription received", async (): Promise<void> => {
|
||||
const onTranscript = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onTranscript }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["transcription-final"]?.({
|
||||
text: "final text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTranscript).toHaveBeenCalledWith("final text");
|
||||
});
|
||||
});
|
||||
|
||||
it("should clean up on unmount", async (): Promise<void> => {
|
||||
const { result, unmount } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not start recording if already recording", async (): Promise<void> => {
|
||||
const { result } = renderHook(() => useVoiceInput());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
// Reset the call count
|
||||
mockGetUserMedia.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
// Should not have called getUserMedia again
|
||||
expect(mockGetUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("REST fallback", (): void => {
|
||||
it("should fall back to REST when WebSocket is unavailable", async (): Promise<void> => {
|
||||
// Simulate socket not connecting
|
||||
(mockSocket as { connected: boolean }).connected = false;
|
||||
|
||||
const { result } = renderHook(() => useVoiceInput({ useWebSocket: false }));
|
||||
|
||||
// Should still be able to start recording (REST mode)
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
|
||||
expect(result.current.isRecording).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user