feat(#403): add audio playback component for TTS output
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:
2026-02-15 03:05:39 -06:00
parent 28c9e6fe65
commit 74d6c1092e
14 changed files with 2664 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AudioVisualizer } from "./AudioVisualizer";
describe("AudioVisualizer", (): void => {
it("should render the visualizer container", (): void => {
render(<AudioVisualizer audioLevel={0} isActive={false} />);
const container = screen.getByTestId("audio-visualizer");
expect(container).toBeInTheDocument();
});
it("should render visualization bars", (): void => {
render(<AudioVisualizer audioLevel={0.5} isActive={true} />);
const bars = screen.getAllByTestId("visualizer-bar");
expect(bars.length).toBeGreaterThan(0);
});
it("should show inactive state when not active", (): void => {
render(<AudioVisualizer audioLevel={0} isActive={false} />);
const container = screen.getByTestId("audio-visualizer");
expect(container).toBeInTheDocument();
// Bars should be at minimum height when inactive
const bars = screen.getAllByTestId("visualizer-bar");
bars.forEach((bar) => {
const style = bar.getAttribute("style");
expect(style).toContain("height");
});
});
it("should reflect audio level in bar heights when active", (): void => {
render(<AudioVisualizer audioLevel={0.8} isActive={true} />);
const bars = screen.getAllByTestId("visualizer-bar");
// At least one bar should have non-minimal height
const hasActiveBars = bars.some((bar) => {
const style = bar.getAttribute("style") ?? "";
const heightMatch = /height:\s*(\d+)/.exec(style);
return heightMatch?.[1] ? parseInt(heightMatch[1], 10) > 4 : false;
});
expect(hasActiveBars).toBe(true);
});
it("should use calm colors (no aggressive reds)", (): void => {
render(<AudioVisualizer audioLevel={0.5} isActive={true} />);
const container = screen.getByTestId("audio-visualizer");
const allElements = container.querySelectorAll("*");
allElements.forEach((el) => {
const className = (el as HTMLElement).className;
expect(className).not.toMatch(/bg-red-|text-red-/);
});
});
it("should accept custom className", (): void => {
render(<AudioVisualizer audioLevel={0.5} isActive={true} className="custom-class" />);
const container = screen.getByTestId("audio-visualizer");
expect(container.className).toContain("custom-class");
});
it("should render with configurable bar count", (): void => {
render(<AudioVisualizer audioLevel={0.5} isActive={true} barCount={8} />);
const bars = screen.getAllByTestId("visualizer-bar");
expect(bars).toHaveLength(8);
});
});