cliamp music player integration with browser audio streaming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:23:52 +00:00
co-authored by Claude Opus 4.6
parent fd4d77a389
commit 30f06e91e4
16 changed files with 1051 additions and 18 deletions
@@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from 'react';
import { Volume2, VolumeX } from 'lucide-react';
type AudioStreamPlayerProps = {
wsUrl: string;
onError?: (message: string) => void;
};
const SAMPLE_RATE = 44100;
const CHANNELS = 2;
const buildWsUrl = (wsPath: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
};
const WORKLET_CODE = `
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.port.onmessage = (e) => {
const incoming = e.data;
const merged = new Float32Array(this.buffer.length + incoming.length);
merged.set(this.buffer);
merged.set(incoming, this.buffer.length);
this.buffer = merged;
const max = ${SAMPLE_RATE * CHANNELS * 2};
if (this.buffer.length > max) {
this.buffer = this.buffer.slice(this.buffer.length - max);
}
};
}
process(inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channels = output.length;
const frameSize = output[0].length;
const samplesNeeded = frameSize * channels;
if (this.buffer.length >= samplesNeeded) {
for (let i = 0; i < frameSize; i++) {
for (let ch = 0; ch < channels; ch++) {
output[ch][i] = this.buffer[i * channels + ch];
}
}
this.buffer = this.buffer.slice(samplesNeeded);
} else {
for (let ch = 0; ch < channels; ch++) {
output[ch].fill(0);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
`;
const workletBlobUrl = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => {
const [muted, setMuted] = useState(false);
const [started, setStarted] = useState(false);
const ctxRef = useRef<AudioContext | null>(null);
const nodeRef = useRef<AudioWorkletNode | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const gainRef = useRef<GainNode | null>(null);
useEffect(() => {
let disposed = false;
let audioCtx: AudioContext | null = null;
const init = async () => {
try {
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
ctxRef.current = audioCtx;
await audioCtx.audioWorklet.addModule(workletBlobUrl);
if (disposed) { audioCtx.close(); return; }
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
outputChannelCount: [CHANNELS],
});
nodeRef.current = workletNode;
const gainNode = audioCtx.createGain();
gainRef.current = gainNode;
workletNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
const ws = new WebSocket(buildWsUrl(wsUrl));
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.addEventListener('open', () => {
if (!disposed) setStarted(true);
});
ws.addEventListener('message', (ev) => {
if (disposed || !(ev.data instanceof ArrayBuffer)) return;
// Resume context if suspended (autoplay policy — will unlock on user gesture)
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
const int16 = new Int16Array(ev.data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i]! / 32768;
}
workletNode.port.postMessage(float32);
});
ws.addEventListener('error', () => {
if (!disposed) onError?.('Audio stream connection failed');
});
ws.addEventListener('close', () => {
if (!disposed) setStarted(false);
});
} catch (err) {
if (!disposed) {
onError?.(err instanceof Error ? err.message : 'Audio playback failed');
}
}
};
init();
return () => {
disposed = true;
try { wsRef.current?.close(); } catch { /* ignore */ }
wsRef.current = null;
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
nodeRef.current = null;
try { audioCtx?.close(); } catch { /* ignore */ }
ctxRef.current = null;
gainRef.current = null;
};
}, [wsUrl]);
useEffect(() => {
if (gainRef.current) {
gainRef.current.gain.value = muted ? 0 : 1;
}
}, [muted]);
return (
<button
onClick={() => setMuted((m) => !m)}
className="p-1.5 rounded hover:bg-duck-dark/10 transition-colors cursor-pointer"
title={muted ? 'Unmute' : 'Mute'}
>
{muted ? (
<VolumeX className={`h-4 w-4 ${started ? 'text-red-500' : 'text-duck-dark/40'}`} />
) : (
<Volume2 className={`h-4 w-4 ${started ? 'text-duck-teal' : 'text-duck-dark/40'}`} />
)}
</button>
);
};
@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
import { X } from 'lucide-react';
import { TerminalView } from '../Terminal/Terminal';
import { AudioStreamPlayer } from './AudioStreamPlayer';
export const CliampPanelHeader = () => {
const [searchParams, setSearchParams] = useSearchParams();
const playPath = searchParams.get('play') ?? '';
const fileName = playPath.split('/').pop() ?? 'cliamp';
const handleClose = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('play');
return next;
});
}, [setSearchParams]);
return (
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-duck-dark/10 bg-background/95">
<span className="text-sm font-medium text-duck-dark truncate flex-1">{fileName}</span>
<AudioStreamPlayer wsUrl="/api/cliamp/audio/ws" />
<button
onClick={handleClose}
className="p-1 rounded hover:bg-duck-dark/10 transition-colors cursor-pointer"
title="Close player"
>
<X className="h-4 w-4 text-duck-dark/60" />
</button>
</div>
);
};
export const CliampPanelBody = () => {
const [searchParams, setSearchParams] = useSearchParams();
const playPath = searchParams.get('play') ?? '';
const wsPath = `/api/cliamp/ws?files=${encodeURIComponent(playPath)}`;
const handleExit = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('play');
return next;
});
}, [setSearchParams]);
return (
<TerminalView
className="h-full w-full"
wsPath={wsPath}
sandboxed={false}
onExit={handleExit}
autoFocus
/>
);
};
@@ -65,6 +65,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
handleTranscribe,
handleExtractAudio,
handleExtract,
handlePlay,
getMatchingTasks,
handleRunTask,
handleCreateWorkspace,
@@ -183,6 +184,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onTranscribe={handleTranscribe}
onExtractAudio={handleExtractAudio}
onExtract={handleExtract}
onPlay={handlePlay}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={handleRunTask}
onCreateWorkspace={handleCreateWorkspace}
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive, ClipboardCopy } from 'lucide-react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive, ClipboardCopy, Music } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
@@ -49,6 +49,7 @@ export type FileItemProps = {
onTranscribe: (entry: DirEntry) => void;
onExtractAudio: (entry: DirEntry) => void;
onExtract: (entry: DirEntry) => void;
onPlay: (entry: DirEntry) => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
@@ -81,6 +82,7 @@ type MenuItemsProps = {
onTranscribe: (e: DirEntry) => void;
onExtractAudio: (e: DirEntry) => void;
onExtract: (e: DirEntry) => void;
onPlay: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
matchingTasks: TaskSummary[];
@@ -101,6 +103,7 @@ const DropdownMenuItems = ({
onTranscribe,
onExtractAudio,
onExtract,
onPlay,
onCut,
onCopy,
matchingTasks,
@@ -113,9 +116,16 @@ const DropdownMenuItems = ({
const showTranscribe = fileType === 'audio';
const showExtractAudio = fileType === 'video';
const showExtract = fileType === 'archive';
const showPlay = fileType === 'audio' || entry.type === 'directory';
return (
<>
{showPlay && (
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
<Music className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
@@ -219,6 +229,7 @@ const ContextMenuItems = ({
onTranscribe,
onExtractAudio,
onExtract,
onPlay,
onCut,
onCopy,
matchingTasks,
@@ -231,9 +242,16 @@ const ContextMenuItems = ({
const showTranscribe = fileType === 'audio';
const showExtractAudio = fileType === 'video';
const showExtract = fileType === 'archive';
const showPlay = fileType === 'audio' || entry.type === 'directory';
return (
<>
{showPlay && (
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
<Music className="mr-2 h-4 w-4" />
Play
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
@@ -438,6 +456,7 @@ export const FileItem = ({
onTranscribe,
onExtractAudio,
onExtract,
onPlay,
matchingTasks,
onRunTask,
onCreateWorkspace,
@@ -519,6 +538,7 @@ export const FileItem = ({
onTranscribe,
onExtractAudio,
onExtract,
onPlay,
onCut,
onCopy,
matchingTasks,
@@ -457,6 +457,11 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
}
};
const handlePlay = (entry: DirEntry) => {
const filePath = entryPath(entry.name);
setSearchParams({ play: filePath });
};
const handleExtract = async (entry: DirEntry) => {
const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting archive...');
@@ -705,6 +710,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
handleTranscribe,
handleExtractAudio,
handleExtract,
handlePlay,
handleGitClone,
handleVideoDownload,
handleCut,
@@ -0,0 +1,41 @@
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.port.onmessage = (e) => {
const incoming = e.data;
const merged = new Float32Array(this.buffer.length + incoming.length);
merged.set(this.buffer);
merged.set(incoming, this.buffer.length);
this.buffer = merged;
};
}
process(_inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channels = output.length;
const frameSize = output[0].length;
const samplesNeeded = frameSize * channels;
if (this.buffer.length >= samplesNeeded) {
// Deinterleave: buffer is interleaved L R L R ...
for (let i = 0; i < frameSize; i++) {
for (let ch = 0; ch < channels; ch++) {
output[ch][i] = this.buffer[i * channels + ch];
}
}
this.buffer = this.buffer.slice(samplesNeeded);
} else {
// Not enough data — output silence
for (let ch = 0; ch < channels; ch++) {
output[ch].fill(0);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
@@ -6,6 +6,12 @@ export const singleViewerLayout: LayoutNode = {
appType: null,
};
export const singleCliampLayout: LayoutNode = {
type: 'panel',
id: 'files-cliamp',
appType: null,
};
export const viewerWithEphemeralLayout: LayoutNode = {
type: 'group',
id: 'files-viewer-group',
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import type { EphemeralPanels } from '../../components/Workspace';
import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer';
import { singleViewerLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts';
import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts';
import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers';
import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel';
const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType'];
const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play'];
export const useFileViewerPanels = (): EphemeralPanels | null => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -28,14 +29,17 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
const ephemeralPath = searchParams.get('ephemeral');
const ephemeral2Path = searchParams.get('ephemeral2');
const chatContext = searchParams.get('chatContext');
const playPath = searchParams.get('play');
const layout = chatContext
? singleChatLayout
: viewPath && ephemeralPath && ephemeral2Path
? viewerWithEphemeralSplitLayout
: viewPath && ephemeralPath
? viewerWithEphemeralLayout
: singleViewerLayout;
const layout = playPath
? singleCliampLayout
: chatContext
? singleChatLayout
: viewPath && ephemeralPath && ephemeral2Path
? viewerWithEphemeralSplitLayout
: viewPath && ephemeralPath
? viewerWithEphemeralLayout
: singleViewerLayout;
const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]);
@@ -76,8 +80,23 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
[setSearchParams],
);
const onClosePlay = useCallback(
() =>
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('play');
return next;
}),
[setSearchParams],
);
const components = useMemo(
() => ({
'files-cliamp': {
header: CliampPanelHeader,
component: CliampPanelBody,
onClose: onClosePlay,
},
'files-viewer': {
provider: ViewerProvider,
header: FileViewerHeader,
@@ -101,11 +120,12 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
onClose: onCloseChat,
},
}),
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat],
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay],
);
if (!viewPath && !chatContext) return null;
return { layout, components, defaultBaseSize: 40, onClose: onCloseViewer };
if (!viewPath && !chatContext && !playPath) return null;
const onClose = playPath ? onClosePlay : onCloseViewer;
return { layout, components, defaultBaseSize: 40, onClose };
};
export type UseFileViewerPanelsType = ReturnType<typeof useFileViewerPanels>;