feat(media): wire Recorder upload to /api/media/audio; fix multer TS types; add S3 download helper
This commit is contained in:
parent
c94461b460
commit
4ad9c311a2
@ -6,6 +6,8 @@ export default function Recorder() {
|
|||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
|
const [uploadKey, setUploadKey] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
const requestStream = async (): Promise<MediaStream | null> => {
|
const requestStream = async (): Promise<MediaStream | null> => {
|
||||||
@ -39,6 +41,7 @@ export default function Recorder() {
|
|||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return url;
|
return url;
|
||||||
});
|
});
|
||||||
|
setAudioBlob(blob);
|
||||||
// stop all tracks to release mic
|
// stop all tracks to release mic
|
||||||
stream.getTracks().forEach(t => t.stop());
|
stream.getTracks().forEach(t => t.stop());
|
||||||
};
|
};
|
||||||
@ -52,6 +55,31 @@ export default function Recorder() {
|
|||||||
setRecording(false);
|
setRecording(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadAudio = async () => {
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
setUploadKey(null);
|
||||||
|
if (!audioBlob) {
|
||||||
|
setError('No audio to upload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('audio', audioBlob, 'recording.webm');
|
||||||
|
const res = await fetch('/api/media/audio', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`Upload failed: ${res.status} ${txt}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setUploadKey(data.key || 'uploaded');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Upload failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
||||||
@ -64,6 +92,7 @@ export default function Recorder() {
|
|||||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||||
<Button variant="contained" disabled={recording} onClick={startRecording}>Start</Button>
|
<Button variant="contained" disabled={recording} onClick={startRecording}>Start</Button>
|
||||||
<Button variant="outlined" disabled={!recording} onClick={stopRecording}>Stop</Button>
|
<Button variant="outlined" disabled={!recording} onClick={stopRecording}>Stop</Button>
|
||||||
|
<Button variant="text" disabled={!audioBlob} onClick={uploadAudio}>Upload</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
|
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
|
||||||
{audioUrl && (
|
{audioUrl && (
|
||||||
@ -71,6 +100,11 @@ export default function Recorder() {
|
|||||||
<audio controls src={audioUrl} />
|
<audio controls src={audioUrl} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{uploadKey && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
|
Uploaded as key: {uploadKey}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import type { File as MulterFile } from 'multer';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { uploadBuffer } from './storage/s3';
|
import { uploadBuffer } from './storage/s3';
|
||||||
|
|
||||||
@ -8,7 +7,7 @@ const router = express.Router();
|
|||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
router.post('/audio', upload.single('audio'), async (
|
router.post('/audio', upload.single('audio'), async (
|
||||||
req: express.Request & { file?: MulterFile },
|
req: express.Request,
|
||||||
res: express.Response
|
res: express.Response
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
function getS3Client() {
|
export function getS3Client() {
|
||||||
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
|
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
|
||||||
const region = process.env.S3_REGION || 'us-east-1';
|
const region = process.env.S3_REGION || 'us-east-1';
|
||||||
const accessKeyId = process.env.S3_ACCESS_KEY || '';
|
const accessKeyId = process.env.S3_ACCESS_KEY || '';
|
||||||
@ -34,3 +34,18 @@ export async function uploadBuffer(params: {
|
|||||||
await s3.send(cmd);
|
await s3.send(cmd);
|
||||||
return { bucket: params.bucket, key: params.key };
|
return { bucket: params.bucket, key: params.key };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadObject(params: { bucket: string; key: string }): Promise<{ buffer: Buffer; contentType: string }> {
|
||||||
|
const s3 = getS3Client();
|
||||||
|
const cmd = new GetObjectCommand({ Bucket: params.bucket, Key: params.key });
|
||||||
|
const res = await s3.send(cmd);
|
||||||
|
const contentType = res.ContentType || 'application/octet-stream';
|
||||||
|
const body = res.Body as unknown as NodeJS.ReadableStream;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
body.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
body.on('end', resolve);
|
||||||
|
body.on('error', reject);
|
||||||
|
});
|
||||||
|
return { buffer: Buffer.concat(chunks), contentType };
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user