feat(activities): attach voice and files on activity notes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
|
||||
import { compressVoiceFile } from '@/lib/voice-note';
|
||||
|
||||
export const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const FILE_MIME_BY_EXT: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
};
|
||||
|
||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
const AUDIO_EXT = new Set(['webm', 'ogg', 'mp3', 'm4a', 'aac', 'wav', 'mpeg']);
|
||||
|
||||
export type PreparedAttachment = {
|
||||
kind: 'image' | 'audio' | 'file';
|
||||
name: string;
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
};
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
return name.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '') ?? '';
|
||||
}
|
||||
|
||||
function kindOf(file: File): 'image' | 'audio' | 'file' {
|
||||
const type = file.type.toLowerCase().split(';')[0] ?? '';
|
||||
const ext = extensionOf(file.name);
|
||||
if (type.startsWith('image/') || IMAGE_EXT.has(ext)) return 'image';
|
||||
if (type.startsWith('audio/') || AUDIO_EXT.has(ext)) return 'audio';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export async function prepareActivityAttachment(file: File): Promise<PreparedAttachment> {
|
||||
const kind = kindOf(file);
|
||||
if (kind === 'image') {
|
||||
try {
|
||||
const prepared = await prepareFormFieldFile(file);
|
||||
return { kind, name: prepared.name, file: prepared.file, previewUrl: prepared.dataUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof FormFileTooLargeError) {
|
||||
throw new Error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد');
|
||||
}
|
||||
throw new Error('فشردهسازی عکس ناموفق بود.');
|
||||
}
|
||||
}
|
||||
if (kind === 'audio') {
|
||||
const compressed = await compressVoiceFile(file);
|
||||
return {
|
||||
kind,
|
||||
name: compressed.name,
|
||||
file: compressed,
|
||||
previewUrl: URL.createObjectURL(compressed),
|
||||
};
|
||||
}
|
||||
const ext = extensionOf(file.name);
|
||||
const mime = FILE_MIME_BY_EXT[ext];
|
||||
if (!mime) throw new Error('این نوع فایل مجاز نیست.');
|
||||
if (file.size > ACTIVITY_FILE_MAX_BYTES) throw new Error('حجم فایل باید زیر ۱۰ مگابایت باشد');
|
||||
const named = file.type === mime ? file : new File([file], file.name, { type: mime });
|
||||
return { kind: 'file', name: named.name, file: named, previewUrl: '' };
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/** پیام صوتی به سبک بله و تلگرام: Opus تککاناله، حدود ۳۲ کیلوبیت بر ثانیه. */
|
||||
export const VOICE_BITRATE = 32_000;
|
||||
export const VOICE_MAX_SECONDS = 5 * 60;
|
||||
export const VOICE_MAX_BYTES = Math.floor(1.8 * 1024 * 1024);
|
||||
|
||||
const MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
];
|
||||
|
||||
export function voiceRecorderMime(): string {
|
||||
if (typeof MediaRecorder === 'undefined') return '';
|
||||
return MIME_CANDIDATES.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? '';
|
||||
}
|
||||
|
||||
function extensionFor(mime: string): string {
|
||||
if (mime.includes('mp4')) return 'm4a';
|
||||
if (mime.includes('ogg')) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
function fileFromBlob(blob: Blob, mime: string): File {
|
||||
const type = mime.split(';')[0] || blob.type || 'audio/webm';
|
||||
return new File([blob], `voice.${extensionFor(mime)}`, { type });
|
||||
}
|
||||
|
||||
export type VoiceRecorder = {
|
||||
stop: () => Promise<File>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export async function startVoiceRecorder(handlers: {
|
||||
onTick: (seconds: number) => void;
|
||||
onAutoStop: (file: File) => void;
|
||||
}): Promise<VoiceRecorder> {
|
||||
const mime = voiceRecorderMime();
|
||||
if (!mime || !navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('این مرورگر ضبط صوت را پشتیبانی نمیکند.');
|
||||
}
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: { ideal: 1 },
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new Error('دسترسی به میکروفون داده نشد.');
|
||||
}
|
||||
|
||||
const rec = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
const started = Date.now();
|
||||
let settled = false;
|
||||
let timer = 0;
|
||||
let auto = 0;
|
||||
|
||||
const stopped = new Promise<File>((resolve, reject) => {
|
||||
rec.onstop = () => {
|
||||
const file = fileFromBlob(new Blob(chunks, { type: mime.split(';')[0] }), mime);
|
||||
if (!file.size) {
|
||||
reject(new Error('صدایی ضبط نشد.'));
|
||||
return;
|
||||
}
|
||||
if (file.size > VOICE_MAX_BYTES) {
|
||||
reject(new Error('حجم صوت بعد از فشردهسازی هنوز زیاد است.'));
|
||||
return;
|
||||
}
|
||||
resolve(file);
|
||||
};
|
||||
rec.onerror = () => reject(new Error('ضبط صوت ناموفق بود.'));
|
||||
});
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearInterval(timer);
|
||||
window.clearTimeout(auto);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (rec.state !== 'inactive') rec.stop();
|
||||
};
|
||||
|
||||
timer = window.setInterval(() => {
|
||||
handlers.onTick(Math.min(VOICE_MAX_SECONDS, Math.round((Date.now() - started) / 1000)));
|
||||
}, 250);
|
||||
auto = window.setTimeout(() => {
|
||||
finish();
|
||||
void stopped.then(handlers.onAutoStop).catch(() => undefined);
|
||||
}, VOICE_MAX_SECONDS * 1000);
|
||||
rec.start(250);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
finish();
|
||||
return stopped;
|
||||
},
|
||||
cancel: () => {
|
||||
chunks.length = 0;
|
||||
finish();
|
||||
void stopped.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressVoiceFile(file: File): Promise<File> {
|
||||
const mime = voiceRecorderMime();
|
||||
if (!mime) throw new Error('این مرورگر فشردهسازی صوت را پشتیبانی نمیکند.');
|
||||
const ctx = new AudioContext();
|
||||
let audio: AudioBuffer;
|
||||
try {
|
||||
audio = await ctx.decodeAudioData(await file.arrayBuffer());
|
||||
} catch {
|
||||
await ctx.close();
|
||||
throw new Error('این فایل صوتی قابل خواندن نیست.');
|
||||
}
|
||||
if (audio.duration > VOICE_MAX_SECONDS + 0.5) {
|
||||
await ctx.close();
|
||||
throw new Error('صوت حداکثر ۵ دقیقه میتواند باشد.');
|
||||
}
|
||||
|
||||
const mono = ctx.createBuffer(1, audio.length, audio.sampleRate);
|
||||
const out = mono.getChannelData(0);
|
||||
for (let i = 0; i < audio.length; i += 1) {
|
||||
let sum = 0;
|
||||
for (let channel = 0; channel < audio.numberOfChannels; channel += 1) {
|
||||
sum += audio.getChannelData(channel)[i] ?? 0;
|
||||
}
|
||||
out[i] = sum / audio.numberOfChannels;
|
||||
}
|
||||
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = mono;
|
||||
source.connect(dest);
|
||||
const rec = new MediaRecorder(dest.stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
const encoded = new Promise<Blob>((resolve, reject) => {
|
||||
rec.onstop = () => resolve(new Blob(chunks, { type: mime.split(';')[0] }));
|
||||
rec.onerror = () => reject(new Error('فشردهسازی صوت ناموفق بود.'));
|
||||
});
|
||||
rec.start(250);
|
||||
source.start();
|
||||
await new Promise<void>((resolve) => {
|
||||
source.onended = () => resolve();
|
||||
});
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
if (rec.state !== 'inactive') rec.stop();
|
||||
const blob = await encoded;
|
||||
source.disconnect();
|
||||
await ctx.close();
|
||||
const result = fileFromBlob(blob, mime);
|
||||
if (!result.size) throw new Error('فشردهسازی صوت ناموفق بود.');
|
||||
if (result.size > VOICE_MAX_BYTES) throw new Error('حجم صوت بعد از فشردهسازی هنوز زیاد است.');
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user