feat: stream audio in chunks - new ember player and visualizer

This commit is contained in:
rishikanthc
2025-12-11 12:09:26 -08:00
committed by Rishikanth Chandrasekaran
parent 80a7808d37
commit 503a6d714f
6 changed files with 550 additions and 20 deletions

View File

@@ -1425,13 +1425,37 @@ func (h *Handler) GetAudioFile(c *gin.Context) {
c.Header("Content-Type", "audio/mpeg")
}
// Add CORS headers for audio
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-API-Key")
// Add CORS headers for audio visualization and streaming
origin := c.Request.Header.Get("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
} else {
// Fallback for non-browser/direct access
c.Header("Access-Control-Allow-Origin", "*")
}
c.Header("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length")
c.Header("Accept-Ranges", "bytes")
// Serve the audio file
c.File(job.AudioPath)
// Open the file
file, err := os.Open(audioPath)
if err != nil {
fmt.Printf("ERROR: Failed to open audio file: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open audio file"})
return
}
defer file.Close()
// Get file stats
fileInfo, err := file.Stat()
if err != nil {
fmt.Printf("ERROR: Failed to stat audio file: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stat audio file"})
return
}
// Use http.ServeContent for efficient streaming and range request support
http.ServeContent(c.Writer, c.Request, filepath.Base(audioPath), fileInfo.ModTime(), file)
}
// @Summary Login
@@ -1476,6 +1500,20 @@ func (h *Handler) Login(c *gin.Context) {
return
}
// Set access token cookie for streaming/media access
// We use Lax mode to allow top-level navigation authentication if needed, but Strict is safer for API.
// Since we use this for <audio> src which is a cross-origin-like request (even if same origin technically),
// SameSite=Strict should work for same-site.
http.SetCookie(c.Writer, &http.Cookie{
Name: "scriberr_access_token",
Value: token,
Path: "/",
Expires: time.Now().Add(24 * time.Hour), // Match your token duration constant
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteStrictMode,
})
response := LoginResponse{Token: token}
response.User.ID = user.ID
response.User.Username = user.Username

View File

@@ -27,22 +27,29 @@ func AuthMiddleware(authService *auth.AuthService) gin.HandlerFunc {
}
// Check for JWT token
var token string
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if authHeader != "" {
// Extract token from "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
token = parts[1]
}
}
// Fallback to cookie if no header
if token == "" {
if cookie, err := c.Cookie("scriberr_access_token"); err == nil {
token = cookie
}
}
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authentication"})
c.Abort()
return
}
// Extract token from "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
token := parts[1]
claims, err := authService.ValidateToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})

BIN
server

Binary file not shown.

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from "react";
interface AudioVisualizerProps {
audioRef: React.RefObject<HTMLAudioElement | null>;
isPlaying: boolean;
isHovering?: boolean;
hoverPercent?: number;
}
// Global cache to prevent "InvalidStateError" when React re-renders
// This ensures we don't try to create a new SourceNode for an audio element that already has one.
const audioSourceMap = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>();
export function AudioVisualizer({
audioRef,
isPlaying,
isHovering = false,
hoverPercent = 0,
}: AudioVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const contextRef = useRef<AudioContext | null>(null);
const analyzerRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number | null>(null);
const peakPositionsRef = useRef<number[]>([]);
const peakDropsRef = useRef<number[]>([]);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
// 1. Handle Window/Container Resizing
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
const { clientWidth, clientHeight } = containerRef.current;
setDimensions({
width: clientWidth * window.devicePixelRatio,
height: clientHeight * window.devicePixelRatio,
});
}
};
updateSize();
const observer = new ResizeObserver(updateSize);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// 2. Initialize Audio Context & Analyzer
useEffect(() => {
if (!audioRef.current) return;
const initAudio = () => {
if (!contextRef.current) {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
contextRef.current = new AudioContextClass();
}
const ctx = contextRef.current;
if (!analyzerRef.current) {
const analyzer = ctx.createAnalyser();
analyzer.fftSize = 256;
analyzer.smoothingTimeConstant = 0.8;
analyzerRef.current = analyzer;
}
const audioEl = audioRef.current!;
// Check cache to reuse existing MediaElementSource
if (audioSourceMap.has(audioEl)) {
try {
const source = audioSourceMap.get(audioEl)!;
source.connect(analyzerRef.current!);
analyzerRef.current!.connect(ctx.destination);
} catch (e) { /* ignore already connected errors */ }
} else {
try {
const source = ctx.createMediaElementSource(audioEl);
source.connect(analyzerRef.current!);
analyzerRef.current!.connect(ctx.destination);
audioSourceMap.set(audioEl, source);
} catch (e) {
console.error("Audio Graph Error:", e);
}
}
};
initAudio();
if (isPlaying && contextRef.current?.state === "suspended") {
contextRef.current.resume();
}
}, [audioRef, isPlaying]);
// 3. The Drawing Loop (The "Electric Ember" Design)
useEffect(() => {
if (!canvasRef.current || !analyzerRef.current || dimensions.width === 0)
return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
const SCALE = window.devicePixelRatio;
const TILE_SIZE = 5 * SCALE;
const COL_GAP = 2 * SCALE;
const ROW_GAP = 2 * SCALE;
const TOTAL_ROW_HEIGHT = TILE_SIZE + ROW_GAP;
const COL_WIDTH = TILE_SIZE + COL_GAP;
// --- THEME GRADIENT (Electric Ember) ---
const gradient = ctx.createLinearGradient(0, 0, 0, dimensions.height);
gradient.addColorStop(0, "#FFAB40"); // Top: Amber
gradient.addColorStop(0.5, "#FF6D1F"); // Mid: Orange
gradient.addColorStop(1, "#FF3D00"); // Bottom: Deep Red
const dataArray = new Uint8Array(analyzerRef.current.frequencyBinCount);
const draw = () => {
if (!analyzerRef.current) return;
if (isPlaying) {
analyzerRef.current.getByteFrequencyData(dataArray);
}
ctx.clearRect(0, 0, dimensions.width, dimensions.height);
const barCount = Math.floor(dimensions.width / COL_WIDTH);
if (peakPositionsRef.current.length !== barCount) {
peakPositionsRef.current = new Array(barCount).fill(0);
peakDropsRef.current = new Array(barCount).fill(0);
}
const maxTilesColumn = Math.floor(dimensions.height / TOTAL_ROW_HEIGHT);
for (let i = 0; i < barCount; i++) {
const binIndex = Math.floor(i * (dataArray.length / barCount) * 0.7);
let value = dataArray[binIndex] || 0;
value = Math.min(255, value * 1.2);
const x = i * COL_WIDTH;
const activeTiles = Math.floor((value / 255) * maxTilesColumn);
// Peak Logic
if (activeTiles > peakPositionsRef.current[i]) {
peakPositionsRef.current[i] = activeTiles;
peakDropsRef.current[i] = 0;
} else {
peakDropsRef.current[i]++;
if (peakDropsRef.current[i] > 5) { // Hold peak for 5 frames
peakPositionsRef.current[i] = Math.max(0, peakPositionsRef.current[i] - 1);
peakDropsRef.current[i] = 0;
}
}
const peakTile = peakPositionsRef.current[i];
for (let j = 0; j < maxTilesColumn; j++) {
const y = dimensions.height - j * TOTAL_ROW_HEIGHT - TILE_SIZE;
if (j < activeTiles) {
// Main Bar
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.8;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, TILE_SIZE, TILE_SIZE, 1 * SCALE);
} else {
ctx.rect(x, y, TILE_SIZE, TILE_SIZE);
}
ctx.fill();
ctx.globalAlpha = 1.0;
} else if (j === peakTile && peakTile > 0 && isPlaying) {
// Floating Peak
ctx.fillStyle = "#FFAB40";
ctx.globalAlpha = 0.5;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, TILE_SIZE, TILE_SIZE, 1 * SCALE);
} else {
ctx.rect(x, y, TILE_SIZE, TILE_SIZE);
}
ctx.fill();
ctx.globalAlpha = 1.0;
}
}
}
if (isPlaying || peakPositionsRef.current.some((p) => p > 0)) {
rafRef.current = requestAnimationFrame(draw);
}
};
draw();
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [isPlaying, isHovering, hoverPercent, dimensions]);
return (
<div ref={containerRef} className="w-full h-full">
<canvas
ref={canvasRef}
width={dimensions.width}
height={dimensions.height}
style={{ width: "100%", height: "100%" }}
className="block"
/>
</div>
);
}

View File

@@ -0,0 +1,275 @@
import { useRef, useState, useEffect, forwardRef, useImperativeHandle } from "react";
import { Play, Pause, AlertCircle } from "lucide-react";
import { AudioVisualizer } from "./AudioVisualizer";
import { cn } from "@/lib/utils";
export interface EmberPlayerRef {
seekTo: (time: number) => void;
playPause: () => void;
isPlaying: () => boolean;
}
export interface EmberPlayerProps {
src?: string;
audioId?: string;
className?: string;
onTimeUpdate?: (time: number) => void;
onPlayStateChange?: (isPlaying: boolean) => void;
}
export const EmberPlayer = forwardRef<EmberPlayerRef, EmberPlayerProps>(
({ src, audioId, className, onTimeUpdate, onPlayStateChange }, ref) => {
const audioRef = useRef<HTMLAudioElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
// Visualizer Interaction State
const [hoverTime, setHoverTime] = useState(0);
const [isHovering, setIsHovering] = useState(false);
const [isDragging, setIsDragging] = useState(false);
// --- 1. Parent Control (ForwardRef) ---
useImperativeHandle(ref, () => ({
seekTo: (time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
}
},
playPause: () => togglePlay(),
isPlaying: () => isPlaying
}));
// --- 2. URL Logic ---
let streamUrl = src;
if (!streamUrl && audioId) {
streamUrl = `/api/v1/transcription/${audioId}/audio`;
}
// --- 3. Audio Handlers ---
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play().catch(e => {
console.error("Playback failed:", e);
setError("Playback failed.");
});
}
};
const handleTimeUpdate = () => {
if (audioRef.current && !isDragging) {
const time = audioRef.current.currentTime;
setCurrentTime(time);
onTimeUpdate?.(time);
}
};
const handleLoadedMetadata = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
setError(null);
}
};
// --- 4. Advanced Scrubber Logic ---
const calculateTimeFromEvent = (e: React.MouseEvent | MouseEvent) => {
if (!progressRef.current || !duration) return 0;
const rect = progressRef.current.getBoundingClientRect();
let x = e.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
return (x / rect.width) * duration;
};
const handleScrubberMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
const time = calculateTimeFromEvent(e);
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
}
};
// Global mouse listeners for dragging outside the component
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isDragging && audioRef.current && progressRef.current) {
const time = calculateTimeFromEvent(e);
audioRef.current.currentTime = time;
setCurrentTime(time);
}
};
const handleGlobalMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener("mousemove", handleGlobalMouseMove);
window.addEventListener("mouseup", handleGlobalMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleGlobalMouseMove);
window.removeEventListener("mouseup", handleGlobalMouseUp);
};
}, [isDragging, duration]);
const handleHoverMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || !duration) return;
const rect = progressRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = Math.min(Math.max(0, x / rect.width), 1);
setHoverTime(percent * duration);
};
// Sync State
useEffect(() => {
onPlayStateChange?.(isPlaying);
}, [isPlaying, onPlayStateChange]);
const formatTime = (time: number) => {
if (isNaN(time)) return "00:00";
const min = Math.floor(time / 60);
const sec = Math.floor(time % 60);
return `${min.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
};
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
const hoverPercent = duration > 0 ? hoverTime / duration : 0;
if (error) {
return (
<div className="w-full h-32 flex items-center justify-center text-[var(--error)] bg-[var(--error)]/10 rounded-[var(--radius-card)] border border-[var(--error)]/20">
<AlertCircle className="w-5 h-5 mr-2" />
{error}
</div>
);
}
return (
<div
className={cn(
// Base Container Styles - designed to fill the parent card
"relative w-full overflow-hidden rounded-[var(--radius-card)]",
// Use standard background matching the design system
"bg-transparent",
className
)}
>
{/* Visualizer Layer (Background) */}
{/* Removed mix-blend-screen which makes viz invisible on white. Added minimal opacity for subtlety */}
<div className="absolute inset-0 z-0 h-full w-full pointer-events-none opacity-40">
<AudioVisualizer
audioRef={audioRef}
isPlaying={isPlaying}
isHovering={isHovering}
hoverPercent={hoverPercent}
/>
</div>
{/* Controls Layer */}
<div className="relative z-10 flex flex-col px-1 py-1 gap-3">
{/* Top Row: Button & Time */}
<div className="flex items-center justify-between">
<button
onClick={togglePlay}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[image:var(--brand-gradient)] text-white shadow-lg shadow-orange-500/20 hover:scale-105 active:scale-95 transition-all focus:outline-none cursor-pointer"
>
{isPlaying ? (
<Pause size={20} fill="currentColor" />
) : (
<Play size={20} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex flex-col items-end">
<span className="font-mono text-xs font-medium text-[var(--text-secondary)] tabular-nums tracking-wide">
{formatTime(currentTime)}{" "}
<span className="text-[var(--text-tertiary)] mx-0.5">/</span>{" "}
<span className="text-[var(--text-tertiary)]">
{formatTime(duration)}
</span>
</span>
<span className="text-[10px] text-[var(--text-tertiary)] font-bold uppercase tracking-widest mt-0.5 opacity-80">
{isPlaying ? "Playing" : "Ready"}
</span>
</div>
</div>
{/* Bottom Row: Interactive Scrubber */}
<div
ref={progressRef}
className="relative w-full h-5 flex items-center group cursor-pointer mt-1"
onMouseMove={handleHoverMove}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseDown={handleScrubberMouseDown}
>
{/* Tooltip */}
<div
className={cn(
"absolute bottom-full mb-3 px-2 py-1 rounded bg-[var(--text-primary)] text-[10px] font-mono text-[var(--bg-main)] shadow-sm pointer-events-none transition-opacity duration-200 z-30",
isHovering ? "opacity-100" : "opacity-0"
)}
style={{
left: `${duration > 0 ? (hoverTime / duration) * 100 : 0}%`,
transform: "translateX(-50%)",
}}
>
{formatTime(hoverTime)}
</div>
{/* Track Background - Darker for visibility on white */}
<div className="absolute w-full h-[4px] bg-[var(--border-focus)] rounded-full overflow-hidden group-hover:h-[6px] transition-all">
{/* Progress Fill */}
<div
className="h-full bg-[image:var(--brand-gradient)] shadow-sm transition-all duration-100 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Thumb Indicator - White with Shadow, visible on track */}
<div
className={cn(
"absolute h-3.5 w-3.5 bg-white border border-[var(--border-subtle)] rounded-full shadow-md ml-[-7px] pointer-events-none transition-all duration-100 ease-linear",
isHovering || isDragging
? "scale-100 opacity-100"
: "scale-0 opacity-0"
)}
style={{ left: `${progressPercent}%` }}
/>
</div>
</div>
{/* Hidden Audio Element */}
<audio
ref={audioRef}
src={streamUrl}
preload="metadata"
crossOrigin="use-credentials" // Sends cookies AND allows Web Audio API access (with backend support)
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
onError={(e) => {
console.error("Audio Load Error", e);
setError("Unable to load audio stream.");
}}
/>
</div>
);
}
);
EmberPlayer.displayName = "EmberPlayer";
EmberPlayer.displayName = "EmberPlayer";

View File

@@ -6,8 +6,7 @@ import { Header } from "@/components/Header";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { AudioPlayer } from "@/components/audio/AudioPlayer";
import type { AudioPlayerRef } from "@/components/audio/AudioPlayer";
import { EmberPlayer, type EmberPlayerRef } from "@/components/audio/EmberPlayer";
import { cn } from "@/lib/utils";
// Custom Hooks
@@ -32,7 +31,7 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
const navigate = useNavigate();
// Refs
const audioPlayerRef = useRef<AudioPlayerRef>(null);
const audioPlayerRef = useRef<EmberPlayerRef>(null);
// State
const [currentTime, setCurrentTime] = useState(0);
@@ -286,9 +285,10 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
3. Audio Player:
- Floating Card style
- 1px hairline border
- Replaced with EmberPlayer (Streaming + Viz)
*/}
<div className="sticky top-6 z-40 glass-card rounded-[var(--radius-card)] border-[var(--border-subtle)] shadow-[var(--shadow-card)] p-4 md:p-6 mb-8 transition-all duration-300 hover:shadow-[var(--shadow-float)]">
<AudioPlayer
<EmberPlayer
ref={audioPlayerRef}
audioId={audioId}
onTimeUpdate={handleTimeUpdate}