diff --git a/.changeset/add_voice_message_func.md b/.changeset/add_voice_message_func.md new file mode 100644 index 000000000..ae7b3a529 --- /dev/null +++ b/.changeset/add_voice_message_func.md @@ -0,0 +1,5 @@ +--- +sable: minor +--- + +add voice message composing diff --git a/.gitignore b/.gitignore index 1c1104414..414b4a31e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ devAssets *.tfbackend !*.tfbackend.example crash.log + +# the following line was added with the "git ignore" tool by itsrye.dev, version 0.1.0 +.lh diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 129484b42..e0d64afd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,12 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: - serialize-javascript: '>=7.0.3' - rollup: '>=4.59.0' - minimatch: '>=10.2.3' - lodash: '>=4.17.23' - esbuild: '>=0.25.0' brace-expansion: '>=1.1.12' + esbuild: '>=0.25.0' + lodash: '>=4.17.23' + minimatch: '>=10.2.3' + rollup: '>=4.59.0' + serialize-javascript: '>=7.0.3' importers: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 47b97ee36..875f7e44e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,20 +1,19 @@ -engineStrict: true -minimumReleaseAge: 1440 - allowBuilds: '@swc/core': true esbuild: true sharp: true unrs-resolver: true workerd: true +engineStrict: true +minimumReleaseAge: 1440 overrides: - serialize-javascript: '>=7.0.3' - rollup: '>=4.59.0' - minimatch: '>=10.2.3' - lodash: '>=4.17.23' - esbuild: '>=0.25.0' brace-expansion: '>=1.1.12' + esbuild: '>=0.25.0' + lodash: '>=4.17.23' + minimatch: '>=10.2.3' + rollup: '>=4.59.0' + serialize-javascript: '>=7.0.3' peerDependencyRules: allowedVersions: diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx new file mode 100644 index 000000000..f324d444c --- /dev/null +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -0,0 +1,92 @@ +import { VoiceRecorder } from '$plugins/voice-recorder-kit'; +import FocusTrap from 'focus-trap-react'; +import { Box, Icon, Icons, Text, color, config } from 'folds'; +import { useRef } from 'react'; + +type AudioMessageRecorderProps = { + onRecordingComplete: (audioBlob: Blob) => void; + onRequestClose: () => void; + onWaveformUpdate: (waveform: number[]) => void; + onAudioLengthUpdate: (length: number) => void; +}; + +// We use a react voice recorder library to handle the recording of audio messages, as it provides a simple API and handles the complexities of recording audio in the browser. +// The component is wrapped in a focus trap to ensure that keyboard users can easily navigate and interact with the recorder without accidentally losing focus or interacting with other parts of the UI. +// The styling is kept simple and consistent with the rest of the app, using Folds' design tokens for colors, spacing, and typography. +// we use a modified version of https://www.npmjs.com/package/react-voice-recorder-kit for the recording +export function AudioMessageRecorder({ + onRecordingComplete, + onRequestClose, + onWaveformUpdate, + onAudioLengthUpdate, +}: AudioMessageRecorderProps) { + const containerRef = useRef(null); + const isDismissedRef = useRef(false); + + // uses default styling, we use at other places + return ( + { + isDismissedRef.current = true; + onRequestClose(); + }, + clickOutsideDeactivates: true, + allowOutsideClick: true, + fallbackFocus: () => containerRef.current!, + }} + > +
+ + Audio Message Recorder + { + if (isDismissedRef.current) return; + // closes the recorder and sends the audio file back to the parent component to be uploaded and sent as a message + onRecordingComplete(audioFile); + onWaveformUpdate(waveform); + onAudioLengthUpdate(audioLength); + }} + buttonBackgroundColor={color.SurfaceVariant.Container} + buttonHoverBackgroundColor={color.SurfaceVariant.ContainerHover} + iconColor={color.Primary.Main} + // icons for the recorder, we use Folds' icon library to keep the styling consistent with the rest of the app + customPauseIcon={} + customPlayIcon={} + customDeleteIcon={} + customStopIcon={} + customRepeatIcon={} + customResumeIcon={} + style={{ + backgroundColor: color.Surface.ContainerActive, + }} + /> + +
+
+ ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 48d0e661b..b16b3e585 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -157,6 +157,7 @@ import { getVideoMsgContent, } from './msgContent'; import { CommandAutocomplete } from './CommandAutocomplete'; +import { AudioMessageRecorder } from './AudioMessageRecorder'; const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { if (!replyDraft) return {}; @@ -195,6 +196,7 @@ export const RoomInput = forwardRef( const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const commands = useCommands(mx, room); const emojiBtnRef = useRef(null); + const micBtnRef = useRef(null); const roomToParents = useAtomValue(roomToParentsAtom); const nicknames = useAtomValue(nicknamesAtom); @@ -225,6 +227,9 @@ export const RoomInput = forwardRef( const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); + const [showAudioRecorder, setShowAudioRecorder] = useState(false); + const [audioMsgWaveform, setAudioMsgWaveform] = useState(undefined); + const [audioMsgLength, setAudioMsgLength] = useState(undefined); const [autocompleteQuery, setAutocompleteQuery] = useState>(); const [isQuickTextReact, setQuickTextReact] = useState(false); @@ -406,7 +411,7 @@ export const RoomInput = forwardRef( return getVideoMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc); + return getAudioMsgContent(fileItem, upload.mxc, audioMsgWaveform, audioMsgLength); } return getFileMsgContent(fileItem, upload.mxc); }); @@ -948,6 +953,47 @@ export const RoomInput = forwardRef( > + setShowAudioRecorder(!showAudioRecorder)} + > + + + {showAudioRecorder && ( + { + setShowAudioRecorder(false); + }} + onRecordingComplete={(audioBlob) => { + const file = new File( + [audioBlob], + `sable-audio-message-${Date.now()}.ogg`, + { + type: audioBlob.type, + } + ); + handleFiles([file]); + // Close the recorder after handling the file, to give some feedback that the recording was successful + setShowAudioRecorder(false); + }} + onAudioLengthUpdate={(len) => setAudioMsgLength(len)} + onWaveformUpdate={(w) => setAudioMsgWaveform(w)} + /> + } + /> + )} {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( { +export type AudioMsgContent = IContent & { + waveform?: number[]; + audioLength?: number; +}; + +export const getAudioMsgContent = ( + item: TUploadItem, + mxc: string, + waveform?: number[], + audioLength?: number +): AudioMsgContent => { const { file, encInfo } = item; const content: IContent = { msgtype: MsgType.Audio, @@ -155,6 +165,10 @@ export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => mimetype: file.type, size: file.size, }, + 'org.matrix.msc1767.audio': { + waveform: waveform?.map((v) => Math.round(v * 1024)), // scale waveform values to fit in 10 bits (0-1024) for more efficient storage, as per MSC1767 spec + duration: item.metadata.markedAsSpoiler || !audioLength ? 0 : audioLength * 1000, // if marked as spoiler, set duration to 0 to hide it in clients that support msc1767 + }, }; if (encInfo) { content.file = { diff --git a/src/app/plugins/voice-recorder-kit/README.md b/src/app/plugins/voice-recorder-kit/README.md new file mode 100644 index 000000000..49b9edd3b --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/README.md @@ -0,0 +1,492 @@ +# react-voice-recorder-kit + +A lightweight React library for voice recording with audio waveform visualization and no UI framework dependencies + +* No UI framework dependencies (Pure React + Inline CSS) +* Animated audio waveform visualization (40 bars) +* Ready-to-use component +* Fully customizable hook +* TypeScript support +* Compatible with Next.js, Vite, CRA, and more + +--- + +## Screenshots + +### Initial State (Ready to Record) +

+ Initial State +

+ +### Recording in Progress +![Recording](assets/Screenshot%202025-12-05%20173347.png) + +### Paused State +![Paused](assets/Screenshot%202025-12-05%20173407.png) + +### Recorded Audio Ready to Play (Custom Styled) +![Completed Recording Custom](assets/Screenshot%202025-12-05%20173519.png) + +--- + +## Installation + +```bash +npm install react-voice-recorder-kit +# or +pnpm add react-voice-recorder-kit +# or +yarn add react-voice-recorder-kit +``` + +Requires **React 18+** + +--- + +## Quick Start (Using Component) + +```tsx +'use client' + +import { useState } from 'react' +import { VoiceRecorder } from 'react-voice-recorder-kit' + +export default function Page() { + const [file, setFile] = useState(null) + const [url, setUrl] = useState(null) + const [waveform, setWaveform] = useState([]) + const [audioLength, setAudioLength] = useState(0) + + return ( +
+

React Voice Recorder Kit

+ + { + setFile(audioFile) + setUrl(audioUrl) + setWaveform(waveform) + setAudioLength(audioLength) + }} + onDelete={() => { + setFile(null) + setUrl(null) + setWaveform([]) + setAudioLength(0) + }} + /> + + {url && ( +
+
+ )} +
+ ) +} +``` + +--- + +## Usage in Next.js (App Router) + +```tsx +'use client' + +import { VoiceRecorder } from 'react-voice-recorder-kit' + +export default function VoicePage() { + return ( +
+ +
+ ) +} +``` + +--- + +## Component API + +### Main Props + +| Prop | Type | Default | Description | +| --------- | --------------------------------- | --------- | ---------------------------------------------- | +| autoStart | boolean | true | Auto-start recording on mount | +| onStop | (payload: { audioFile: Blob; audioUrl: string; waveform: number[]; audioLength: number }) => void | undefined | Callback after recording stops (all values batched) | +| onDelete | () => void | undefined | Callback after recording is deleted | +| width | string \| number | '100%' | Component width | +| height | string \| number | undefined | Component height | +| style | CSSProperties | undefined | Additional styles for container | + +### Styling Props + +| Prop | Type | Default | Description | +| --------------------------- | --------------------------------------- | ------------------------------------------------------------ | ------------------------------------ | +| backgroundColor | string | '#ffffff' | Background color | +| borderColor | string | '#e5e7eb' | Border color | +| borderRadius | string \| number | 4 | Border radius | +| padding | string \| number | '6px 10px' | Internal padding | +| gap | string \| number | 8 | Gap between elements | +| recordingIndicatorColor | string | '#ef4444' | Recording indicator color | +| idleIndicatorColor | string | '#9ca3af' | Idle indicator color | +| timeTextColor | string | undefined | Time text color | +| timeFontSize | string \| number | 12 | Time font size | +| timeFontWeight | string \| number | 500 | Time font weight | +| timeFontFamily | string | 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' | Time font family | +| visualizerBarColor | string \| (level: number, index: number) => string | '#4b5563' | Waveform bar color | +| visualizerBarWidth | number | 3 | Waveform bar width | +| visualizerBarGap | number | 4 | Gap between bars | +| visualizerBarHeight | number | 40 | Waveform bar height | +| visualizerHeight | number | 40 | Total waveform height | +| buttonSize | number | 28 | Button size | +| buttonBackgroundColor | string | '#ffffff' | Button background color | +| buttonBorderColor | string | '#e5e7eb' | Button border color | +| buttonBorderRadius | string \| number | 999 | Button border radius | +| buttonHoverBackgroundColor | string | undefined | Button hover background color | +| buttonGap | number | 4 | Gap between buttons | +| errorTextColor | string | '#dc2626' | Error text color | +| errorFontSize | string \| number | 10 | Error font size | +| errorFontFamily | string | 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' | Error font family | +| iconSize | number | 18 | Icon size | +| iconColor | string | undefined | Icon color | + +### Custom Icon Props + +| Prop | Type | Default | Description | +| --------------- | --------- | --------- | ------------------------ | +| customPlayIcon | ReactNode | undefined | Custom play icon | +| customPauseIcon | ReactNode | undefined | Custom pause icon | +| customStopIcon | ReactNode | undefined | Custom stop icon | +| customResumeIcon| ReactNode | undefined | Custom resume icon | +| customDeleteIcon| ReactNode | undefined | Custom delete icon | +| customRepeatIcon| ReactNode | undefined | Custom repeat icon | + +--- + +## Component Usage Examples + +### Example 1: Simple Usage + +```tsx +import { VoiceRecorder } from 'react-voice-recorder-kit' + +function SimpleRecorder() { + return +} +``` + +### Example 2: Custom Styling + +```tsx +import { VoiceRecorder } from 'react-voice-recorder-kit' + +function CustomStyledRecorder() { + return ( + + ) +} +``` + +### Example 3: Using with Callbacks + +```tsx +import { useState } from 'react' +import { VoiceRecorder } from 'react-voice-recorder-kit' + +function RecorderWithCallbacks() { + const [audioFile, setAudioFile] = useState(null) + + return ( + { + if (audioFile instanceof File) { + console.log('Recording stopped:', audioFile.name) + } + console.log('Audio length (s):', audioLength) + console.log('Waveform points:', waveform.length) + setAudioFile(audioFile) + }} + onDelete={() => { + console.log('Recording deleted') + setAudioFile(null) + }} + /> + ) +} +``` + +### Example 4: Dynamic Color Waveform + +```tsx +import { VoiceRecorder } from 'react-voice-recorder-kit' + +function DynamicColorRecorder() { + return ( + { + const hue = (level * 120).toString() + return `hsl(${hue}, 70%, 50%)` + }} + /> + ) +} +``` + +--- + +## Using the Hook (useVoiceRecorder) + +For full control over the UI, you can use the hook directly. + +### Import + +```ts +import { useVoiceRecorder } from 'react-voice-recorder-kit' +``` + +### Options + +```ts +type UseVoiceRecorderOptions = { + autoStart?: boolean + onStop?: (payload: { audioFile: Blob; audioUrl: string; waveform: number[]; audioLength: number }) => void + onDelete?: () => void +} +``` + +### Return Values + +```ts +type UseVoiceRecorderReturn = { + state: RecorderState + isRecording: boolean + isStopped: boolean + isTemporaryStopped: boolean + isPlaying: boolean + isPaused: boolean + seconds: number + levels: number[] + error: string | null + audioUrl: string | null + audioFile: File | null + waveform: number[] | null + start: () => void + handlePause: () => void + handleStopTemporary: () => void + handleStop: () => void + handleResume: () => void + handlePreviewPlay: () => void + handlePlay: () => void + handleRestart: () => void + handleDelete: () => void + handleRecordAgain: () => void +} +``` + +| Property | Type | Description | +| ----------------- | -------------- | ---------------------------------------------- | +| state | RecorderState | Current state: 'idle' \| 'recording' \| 'paused' \| 'reviewing' \| 'playing' | +| isRecording | boolean | Is currently recording | +| isStopped | boolean | Is recording stopped | +| isTemporaryStopped| boolean | Is recording temporarily stopped | +| isPlaying | boolean | Is currently playing | +| isPaused | boolean | Is recording paused | +| seconds | number | Time in seconds | +| levels | number[] | Array of 40 audio levels (0 to 1) | +| error | string \| null | Error message if any | +| audioUrl | string \| null | URL of recorded audio file | +| audioFile | File \| null | Recorded audio file | +| waveform | number[] \| null | Downsampled waveform points for the recording | +| start | () => void | Start recording | +| handlePause | () => void | Pause recording | +| handleStopTemporary| () => void | Temporary stop and review | +| handleStop | () => void | Stop and save recording | +| handleResume | () => void | Resume recording after pause | +| handlePreviewPlay | () => void | Play preview (in paused state) | +| handlePlay | () => void | Play recorded file | +| handleRestart | () => void | Restart recording | +| handleDelete | () => void | Delete recording and return to initial state | +| handleRecordAgain | () => void | Record again (same as handleRestart) | + +--- + +## Complete Hook Usage Example + +```tsx +'use client' + +import { useVoiceRecorder } from 'react-voice-recorder-kit' + +export default function CustomRecorder() { + const { + state, + isRecording, + isPaused, + isStopped, + isPlaying, + seconds, + levels, + audioUrl, + audioFile, + error, + start, + handlePause, + handleResume, + handleStop, + handlePlay, + handleDelete, + handleRestart + } = useVoiceRecorder({ autoStart: false }) + + const formatTime = (secs: number) => { + const minutes = Math.floor(secs / 60) + const sec = secs % 60 + return `${minutes}:${sec.toString().padStart(2, '0')}` + } + + return ( +
+

Custom Voice Recorder

+ +
+ Status: {state} | Time: {formatTime(seconds)} +
+ +
+ {!isRecording && !isStopped && ( + + )} + + {isRecording && !isPaused && ( + <> + + + + + )} + + {isPaused && ( + <> + + + + + )} + + {isStopped && audioUrl && ( + <> + + + + + )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {levels.map((level, index) => { + const height = 5 + level * 35 + return ( +
+ ) + })} +
+ + {audioUrl && ( +
+
+ )} +
+ ) +} +``` + +--- + +## Recording States (RecorderState) + +The component and hook have 5 different states: + +- **idle**: Initial state, ready to start +- **recording**: Currently recording +- **paused**: Recording paused (can be resumed) +- **reviewing**: Recording completed and under review +- **playing**: Playing recorded file + +--- + +## Features + +* Voice recording using MediaRecorder API +* Animated audio waveform visualization during recording and playback +* Support for pause and resume +* Support for playing recorded files +* Time display in MM:SS format +* Error handling and error message display +* Ready-to-use UI with control buttons +* Fully customizable styling and sizing +* No external dependencies +* Support for custom icons +* Dynamic color waveforms + +--- + +## Important Notes + +1. Requires microphone access in the browser +2. Recorded files are saved in WebM format +3. In paused state, you can play a preview of the recording +4. You can dynamically set bar colors using `visualizerBarColor` +5. All created URLs are automatically cleaned up + +--- + +## License + +MIT + +(orignal by Mohammadreza Fallahfaal: https://github.com/mohamad-fallah/react-voice-recorder-kit) diff --git a/src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx b/src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx new file mode 100644 index 000000000..a51f634a9 --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx @@ -0,0 +1,620 @@ +import type { CSSProperties } from 'react'; +import { useMemo, useRef, useEffect, useState } from 'react'; +import { useVoiceRecorder } from './useVoiceRecorder'; +import type { VoiceRecorderProps } from './types'; +import { PlayIcon, PauseIcon, StopIcon, RepeatIcon, DeleteIcon, ResumeIcon } from './icons'; + +function VoiceRecorder(props: VoiceRecorderProps) { + const { + width, + height, + style, + backgroundColor = '#ffffff', + borderColor = '#e5e7eb', + borderRadius = 4, + padding = '6px 10px', + gap = 8, + recordingIndicatorColor = '#ef4444', + idleIndicatorColor = '#9ca3af', + timeTextColor, + timeFontSize = 12, + timeFontWeight = 500, + timeFontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + visualizerBarColor = '#4b5563', + visualizerBarWidth = 3, + visualizerBarGap = 4, + visualizerBarHeight = 40, + visualizerHeight = 40, + buttonSize = 28, + buttonBackgroundColor = '#ffffff', + buttonBorderColor = '#e5e7eb', + buttonBorderRadius = 999, + buttonHoverBackgroundColor, + buttonGap = 4, + errorTextColor = '#dc2626', + errorFontSize = 10, + errorFontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + customPlayIcon, + customPauseIcon, + customStopIcon, + customResumeIcon, + customDeleteIcon, + customRepeatIcon, + iconSize = 18, + iconColor, + ...recorderOptions + } = props; + + const { + state, + isRecording, + isStopped, + isPlaying, + seconds, + levels, + error, + handlePause, + handleStopTemporary, + handleStop, + handleResume, + handlePreviewPlay, + handlePlay, + handleRestart, + handleDelete, + } = useVoiceRecorder(recorderOptions); + + const containerRef = useRef(null); + const [visualizerWidth, setVisualizerWidth] = useState(0); + const visualizerRef = useRef(null); + + useEffect(() => { + const updateWidth = () => { + if (visualizerRef.current) { + const availableWidth = visualizerRef.current.offsetWidth; + setVisualizerWidth(Math.max(0, availableWidth)); + } + }; + + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + if (visualizerRef.current) { + resizeObserver.observe(visualizerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [width, isStopped, error]); + + const formattedTime = useMemo(() => { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }, [seconds]); + + const barWidth = visualizerBarWidth; + const barGap = visualizerBarGap; + + const maxBars = useMemo(() => { + if (visualizerWidth <= 0) { + return Math.max(levels.length, 40); + } + const calculatedBars = Math.floor(visualizerWidth / (barWidth + barGap)); + return Math.max(calculatedBars, 1); + }, [visualizerWidth, levels.length, barWidth, barGap]); + + const displayedLevels = useMemo(() => { + if (maxBars <= 0 || levels.length === 0) { + return Array.from({ length: Math.max(maxBars, 40) }, () => 0.15); + } + + if (maxBars <= levels.length) { + const step = levels.length / maxBars; + return Array.from({ length: maxBars }, (_, i) => { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + const slice = levels.slice(start, end); + return slice.length > 0 ? Math.max(...slice) : 0.15; + }); + } + + const step = (levels.length - 1) / (maxBars - 1); + return Array.from({ length: maxBars }, (_, i) => { + const position = i * step; + const lowerIndex = Math.floor(position); + const upperIndex = Math.min(Math.ceil(position), levels.length - 1); + const fraction = position - lowerIndex; + + if (lowerIndex === upperIndex) { + return levels[lowerIndex] || 0.15; + } + + return ( + (levels[lowerIndex] || 0.15) * (1 - fraction) + (levels[upperIndex] || 0.15) * fraction + ); + }); + }, [levels, maxBars]); + + const containerStyle: CSSProperties = useMemo( + () => ({ + display: 'flex', + alignItems: 'center', + gap: typeof gap === 'number' ? `${gap}px` : gap, + backgroundColor, + borderRadius: typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius, + border: `1px solid ${borderColor}`, + padding: typeof padding === 'number' ? `${padding}px` : padding, + width: width ?? '100%', + height, + boxSizing: 'border-box', + ...style, + }), + [width, height, style, backgroundColor, borderColor, borderRadius, padding, gap] + ); + + return ( +
+ + + {formattedTime} + +
+ {displayedLevels.map((level) => ( + + ))} +
+ + {state === 'recording' && ( +
+ + + +
+ )} + + {state === 'paused' && ( +
+ + + + +
+ )} + + {state === 'reviewing' && ( +
+ + + +
+ )} + + {state === 'playing' && ( +
+ +
+ )} + + {error && ( + + {error} + + )} +
+ ); +} + +export default VoiceRecorder; diff --git a/src/app/plugins/voice-recorder-kit/icons.tsx b/src/app/plugins/voice-recorder-kit/icons.tsx new file mode 100644 index 000000000..29248a00a --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/icons.tsx @@ -0,0 +1,76 @@ +import type { FC } from 'react'; + +export const PlayIcon: FC<{ size?: number }> = function ({ size = 18 }) { + return ( + + + + + ); +}; + +export const ResumeIcon: FC<{ size?: number }> = function ({ size = 18 }) { + return ( + + + + ); +}; + +export const PauseIcon: FC<{ size?: number }> = function ({ size = 18 }) { + return ( + + + + + ); +}; + +export const StopIcon: FC<{ size?: number }> = function ({ size = 18 }) { + return ( + + + + ); +}; + +export const DeleteIcon: FC<{ size?: number }> = function ({ size = 18 }) { + return ( + + + + + + + + ); +}; + +export const RepeatIcon: FC<{ size?: number }> = function ({ size = 18 }) { + return ( + + + + ); +}; diff --git a/src/app/plugins/voice-recorder-kit/index.ts b/src/app/plugins/voice-recorder-kit/index.ts new file mode 100644 index 000000000..00564f2bf --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/index.ts @@ -0,0 +1,9 @@ +export { useVoiceRecorder } from './useVoiceRecorder'; +export { default as VoiceRecorder } from './VoiceRecorder'; +export type { + UseVoiceRecorderOptions, + UseVoiceRecorderReturn, + RecorderState, + VoiceRecorderProps, + VoiceRecorderStopPayload, +} from './types'; diff --git a/src/app/plugins/voice-recorder-kit/types.ts b/src/app/plugins/voice-recorder-kit/types.ts new file mode 100644 index 000000000..42cccd271 --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/types.ts @@ -0,0 +1,80 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export type RecorderState = 'idle' | 'recording' | 'paused' | 'reviewing' | 'playing'; + +export type VoiceRecorderStopPayload = { + audioFile: Blob; + audioUrl: string; + waveform: number[]; + audioLength: number; +}; + +export type UseVoiceRecorderOptions = { + autoStart?: boolean; + onStop?: (payload: VoiceRecorderStopPayload) => void; + onDelete?: () => void; +}; + +export type UseVoiceRecorderReturn = { + state: RecorderState; + isRecording: boolean; + isStopped: boolean; + isTemporaryStopped: boolean; + isPlaying: boolean; + isPaused: boolean; + seconds: number; + levels: number[]; + error: string | null; + audioUrl: string | null; + audioFile: File | null; + waveform: number[] | null; + start: () => void; + handlePause: () => void; + handleStopTemporary: () => void; + handleStop: () => void; + handleResume: () => void; + handlePreviewPlay: () => void; + handlePlay: () => void; + handleRestart: () => void; + handleDelete: () => void; + handleRecordAgain: () => void; +}; + +export type VoiceRecorderProps = UseVoiceRecorderOptions & { + width?: string | number; + height?: string | number; + style?: CSSProperties; + backgroundColor?: string; + borderColor?: string; + borderRadius?: string | number; + padding?: string | number; + gap?: string | number; + recordingIndicatorColor?: string; + idleIndicatorColor?: string; + timeTextColor?: string; + timeFontSize?: string | number; + timeFontWeight?: string | number; + timeFontFamily?: string; + visualizerBarColor?: string | ((level: number, index: number) => string); + visualizerBarWidth?: number; + visualizerBarGap?: number; + visualizerBarHeight?: number; + visualizerHeight?: number; + buttonSize?: number; + buttonBackgroundColor?: string; + buttonBorderColor?: string; + buttonBorderRadius?: string | number; + buttonHoverBackgroundColor?: string; + buttonGap?: number; + errorTextColor?: string; + errorFontSize?: string | number; + errorFontFamily?: string; + customPlayIcon?: ReactNode; + customPauseIcon?: ReactNode; + customStopIcon?: ReactNode; + customResumeIcon?: ReactNode; + customDeleteIcon?: ReactNode; + customRepeatIcon?: ReactNode; + iconSize?: number; + iconColor?: string; +}; diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts new file mode 100644 index 000000000..454762463 --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts @@ -0,0 +1,799 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + UseVoiceRecorderOptions, + UseVoiceRecorderReturn, + RecorderState, + VoiceRecorderStopPayload, +} from './types'; + +const BAR_COUNT = 40; +const WAVEFORM_POINT_COUNT = 100; + +// downsample an array of samples to a target count by averaging blocks of samples together +function downsampleWaveform(samples: number[], targetCount: number): number[] { + if (samples.length === 0) return Array.from({ length: targetCount }, () => 0); + if (samples.length <= targetCount) { + const padded = [...samples]; + while (padded.length < targetCount) padded.push(0); + return padded; + } + const result: number[] = []; + const blockSize = samples.length / targetCount; + for (let i = 0; i < targetCount; i += 1) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let sum = 0; + for (let j = start; j < end; j += 1) { + sum += samples[j]; + } + result.push(sum / (end - start)); + } + return result; +} + +export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoiceRecorderReturn { + const { autoStart = true, onStop, onDelete } = options; + + const [isRecording, setIsRecording] = useState(false); + const [isStopped, setIsStopped] = useState(false); + const [isTemporaryStopped, setIsTemporaryStopped] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [seconds, setSeconds] = useState(0); + const [levels, setLevels] = useState(() => + Array.from({ length: BAR_COUNT }, () => 0.15) + ); + const [error, setError] = useState(null); + const [audioUrl, setAudioUrl] = useState(null); + const [audioFile, setAudioFile] = useState(null); + const [waveform, setWaveform] = useState(null); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const dataArrayRef = useRef(null); + const animationFrameIdRef = useRef(null); + const frameCountRef = useRef(0); + const timerRef = useRef(null); + const startTimeRef = useRef(null); + const pausedTimeRef = useRef(0); + const secondsRef = useRef(0); + const lastUrlRef = useRef(null); + const audioRef = useRef(null); + const previousChunksRef = useRef([]); + const isResumingRef = useRef(false); + const isRestartingRef = useRef(false); + const isTemporaryStopRef = useRef(false); + const temporaryPreviewUrlRef = useRef(null); + // waveform samples collected during recording, used to generate waveform on stop. We collect all samples and downsample at the end to get a more accurate waveform, especially for short recordings. We use a ref to avoid causing re-renders on every sample. + const waveformSamplesRef = useRef([]); + // Flag to indicate whether we should be collecting waveform samples. We need this because there can be a short delay between starting recording and the audio graph being set up, during which we might get some samples that we don't want to include in the waveform. + const isCollectingWaveformRef = useRef(false); + + const cleanupStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + streamRef.current = null; + } + }, []); + + const cleanupAudioContext = useCallback(() => { + if (animationFrameIdRef.current !== null) { + cancelAnimationFrame(animationFrameIdRef.current); + animationFrameIdRef.current = null; + } + frameCountRef.current = 0; + if (audioContextRef.current) { + audioContextRef.current.close().catch(() => {}); + audioContextRef.current = null; + } + analyserRef.current = null; + dataArrayRef.current = null; + }, []); + + const stopTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const startRecordingTimer = useCallback(() => { + startTimeRef.current = Date.now() - pausedTimeRef.current * 1000; + stopTimer(); + timerRef.current = window.setInterval(() => { + if (startTimeRef.current === null) return; + const diffMs = Date.now() - startTimeRef.current; + setSeconds(Math.floor(diffMs / 1000)); + }, 1000); + }, [stopTimer]); + + const startPlaybackTimer = useCallback( + (audio: HTMLAudioElement) => { + setSeconds(0); + stopTimer(); + timerRef.current = window.setInterval(() => { + setSeconds(Math.floor(audio.currentTime)); + }, 250); + }, + [stopTimer] + ); + + useEffect(() => { + // Keep a ref copy of seconds for use in callbacks to avoid stale closures + secondsRef.current = seconds; + }, [seconds]); + + const getAudioLength = useCallback(() => { + if (startTimeRef.current === null) { + return secondsRef.current; + } + const elapsedSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000); + return Math.max(secondsRef.current, elapsedSeconds); + }, []); + + const emitStopPayload = useCallback( + (file: File, url: string, waveformData: number[], audioLength: number) => { + if (!onStop) return; + const payload: VoiceRecorderStopPayload = { + audioFile: file, + audioUrl: url, + waveform: waveformData, + audioLength, + }; + onStop(payload); + }, + [onStop] + ); + + const animateLevels = useCallback(() => { + const analyser = analyserRef.current; + const storedArray = dataArrayRef.current; + if (!analyser || !storedArray) return; + + const dataArray = new Uint8Array(storedArray); + + const draw = () => { + analyser.getByteFrequencyData(dataArray); + const bufferLength = dataArray.length; + let sum = 0; + for (let i = 0; i < bufferLength; i += 1) { + sum += dataArray[i]; + } + const avg = sum / bufferLength; + let normalized = (avg / 255) * 3.5; + const minLevel = 0.05; + if (normalized < minLevel) normalized = minLevel; + if (normalized > 1) normalized = 1; + + frameCountRef.current += 1; + if (frameCountRef.current >= 5) { + setLevels((prev: number[]) => { + const next: number[] = prev.slice(1); + next.push(normalized); + return next; + }); + if (isCollectingWaveformRef.current) { + waveformSamplesRef.current.push(normalized); + } + frameCountRef.current = 0; + } + + animationFrameIdRef.current = requestAnimationFrame(draw); + }; + draw(); + }, []); + + const setupAudioGraph = useCallback( + (stream: MediaStream) => { + const audioContext = new AudioContext(); + audioContextRef.current = audioContext; + const source = audioContext.createMediaStreamSource(stream); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.6; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + analyserRef.current = analyser; + dataArrayRef.current = dataArray; + source.connect(analyser); + audioContext.resume().catch(() => {}); + animateLevels(); + }, + [animateLevels] + ); + + const setupPlaybackGraph = useCallback( + (audio: HTMLAudioElement) => { + const audioContext = new AudioContext(); + audioContextRef.current = audioContext; + const source = audioContext.createMediaElementSource(audio); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.6; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + analyserRef.current = analyser; + dataArrayRef.current = dataArray; + source.connect(analyser); + analyser.connect(audioContext.destination); + audioContext.resume().catch(() => {}); + animateLevels(); + }, + [animateLevels] + ); + + const internalStartRecording = useCallback(async () => { + if (typeof window === 'undefined') return; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setError('Browser does not support audio recording.'); + return; + } + + setError(null); + isResumingRef.current = false; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + chunksRef.current = []; + previousChunksRef.current = []; + waveformSamplesRef.current = []; + isCollectingWaveformRef.current = true; + setupAudioGraph(stream); + startRecordingTimer(); + + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + + mediaRecorder.ondataavailable = (event: BlobEvent) => { + if (event.data && event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsPaused(false); + const audioLength = getAudioLength(); + pausedTimeRef.current = 0; + startTimeRef.current = null; + + isCollectingWaveformRef.current = false; + + if (isResumingRef.current) { + isResumingRef.current = false; + return; + } + + if (isRestartingRef.current) { + isRestartingRef.current = false; + return; + } + + if (chunksRef.current.length === 0) return; + + const blob = new Blob(chunksRef.current, { type: 'audio/ogg' }); + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + } + const url = URL.createObjectURL(blob); + lastUrlRef.current = url; + setAudioUrl(url); + + const file = new File([blob], `voice-${Date.now()}.ogg`, { type: 'audio/ogg' }); + setAudioFile(file); + + const waveformData = downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + setWaveform(waveformData); + + if (isTemporaryStopRef.current) { + setIsTemporaryStopped(true); + setIsStopped(true); + isTemporaryStopRef.current = false; + } else { + setIsStopped(true); + setIsTemporaryStopped(false); + emitStopPayload(file, url, waveformData, audioLength); + } + }; + + mediaRecorder.start(); + setIsRecording(true); + setIsPaused(false); + setIsStopped(false); + pausedTimeRef.current = 0; + } catch { + setError('Microphone access denied or an error occurred.'); + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + } + }, [ + cleanupAudioContext, + cleanupStream, + emitStopPayload, + getAudioLength, + setupAudioGraph, + startRecordingTimer, + stopTimer, + ]); + + const start = useCallback(() => { + internalStartRecording(); + }, [internalStartRecording]); + + const handlePause = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + if (!mediaRecorder || mediaRecorder.state !== 'recording') return; + + try { + mediaRecorder.requestData(); + mediaRecorder.pause(); + stopTimer(); + pausedTimeRef.current = seconds; + setIsPaused(true); + + if (animationFrameIdRef.current !== null) { + cancelAnimationFrame(animationFrameIdRef.current); + animationFrameIdRef.current = null; + } + + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.suspend().catch(() => {}); + } + + setLevels(Array.from({ length: BAR_COUNT }, () => 0.15)); + } catch { + setError('Error pausing recording'); + } + }, [seconds, stopTimer]); + + const handleStopTemporary = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + previousChunksRef.current = [...chunksRef.current]; + isTemporaryStopRef.current = false; + + if (mediaRecorder.state === 'recording') { + mediaRecorder.requestData(); + } + mediaRecorder.stop(); + + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + } else { + if (audioUrl && audioFile) { + const waveformData = + waveform ?? downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + emitStopPayload(audioFile, audioUrl, waveformData, secondsRef.current); + } + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + startTimeRef.current = null; + } + }, [ + audioFile, + audioUrl, + cleanupAudioContext, + cleanupStream, + emitStopPayload, + stopTimer, + waveform, + ]); + + const handleStop = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + previousChunksRef.current = [...chunksRef.current]; + isTemporaryStopRef.current = false; + mediaRecorder.stop(); + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + } else { + if (audioUrl && audioFile) { + const waveformData = + waveform ?? downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + emitStopPayload(audioFile, audioUrl, waveformData, secondsRef.current); + } + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + startTimeRef.current = null; + } + }, [ + audioFile, + audioUrl, + cleanupAudioContext, + cleanupStream, + emitStopPayload, + stopTimer, + waveform, + ]); + + const handlePreviewPlay = useCallback(() => { + let urlToPlay = audioUrl; + + if (!urlToPlay && isPaused) { + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + } + + const allChunks = + chunksRef.current.length > 0 ? chunksRef.current : previousChunksRef.current; + + if (allChunks.length > 0) { + const blob = new Blob(allChunks, { type: 'audio/ogg' }); + urlToPlay = URL.createObjectURL(blob); + temporaryPreviewUrlRef.current = urlToPlay; + } + } + + if (!urlToPlay) return; + + if (temporaryPreviewUrlRef.current && temporaryPreviewUrlRef.current !== urlToPlay) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + if (!audioRef.current || audioRef.current.src !== urlToPlay) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + const audio = new Audio(urlToPlay); + audioRef.current = audio; + + audio.onended = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + audio.currentTime = 0; + setSeconds(pausedTimeRef.current); // Reset to total recorded time + }; + audio.onpause = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + }; + audio.onplay = () => { + setIsPlaying(true); + cleanupAudioContext(); + setupPlaybackGraph(audio); + startPlaybackTimer(audio); + }; + } + + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + if (audio.ended || (audio.duration && audio.currentTime >= audio.duration - 0.01)) { + audio.currentTime = 0; + setSeconds(0); + } + audio.play().catch(() => {}); + } + }, [ + audioUrl, + cleanupAudioContext, + isPlaying, + isPaused, + setupPlaybackGraph, + startPlaybackTimer, + stopTimer, + ]); + + const handlePlay = useCallback(() => { + if (!audioUrl) return; + + if (!audioRef.current) { + const audio = new Audio(audioUrl); + audioRef.current = audio; + + audio.onended = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + audio.currentTime = 0; + setSeconds(0); + }; + audio.onpause = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + }; + audio.onplay = () => { + setIsPlaying(true); + cleanupAudioContext(); + setupPlaybackGraph(audio); + startPlaybackTimer(audio); + }; + } + + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + if (audio.ended || (audio.duration && audio.currentTime >= audio.duration - 0.01)) { + audio.currentTime = 0; + setSeconds(0); + } + audio.play().catch(() => {}); + } + }, [audioUrl, cleanupAudioContext, isPlaying, setupPlaybackGraph, startPlaybackTimer, stopTimer]); + + const handleResume = useCallback(async () => { + if (typeof window === 'undefined') return; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setError('Browser does not support audio recording.'); + return; + } + + setError(null); + isResumingRef.current = true; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + setupAudioGraph(stream); + + // Force update seconds to the correct total time before starting timer + setSeconds(pausedTimeRef.current); + startRecordingTimer(); + + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + + mediaRecorder.ondataavailable = (event: BlobEvent) => { + if (event.data && event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsPaused(false); + const audioLength = getAudioLength(); + pausedTimeRef.current = 0; + startTimeRef.current = null; + + isCollectingWaveformRef.current = false; + + if (chunksRef.current.length === 0) return; + + const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + } + const url = URL.createObjectURL(blob); + lastUrlRef.current = url; + setAudioUrl(url); + + const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' }); + setAudioFile(file); + + const waveformData = downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + setWaveform(waveformData); + + emitStopPayload(file, url, waveformData, audioLength); + }; + + mediaRecorder.start(); + setIsRecording(true); + setIsPaused(false); + setIsStopped(false); + setIsTemporaryStopped(false); + setIsPlaying(false); + + isCollectingWaveformRef.current = true; + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + // We removed: pausedTimeRef.current = seconds + // So it keeps the correct total time from previous Pause + startTimeRef.current = Date.now() - pausedTimeRef.current * 1000; + } catch { + setError('Microphone access denied or an error occurred.'); + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + isResumingRef.current = false; + } + }, [ + cleanupAudioContext, + cleanupStream, + emitStopPayload, + getAudioLength, + setupAudioGraph, + startRecordingTimer, + stopTimer, + ]); + + const handleDelete = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsPlaying(false); + setIsStopped(true); + setIsRecording(false); + setIsPaused(false); + setSeconds(0); + pausedTimeRef.current = 0; + startTimeRef.current = null; + setLevels(Array.from({ length: BAR_COUNT }, () => 0.15)); + previousChunksRef.current = []; + chunksRef.current = []; + waveformSamplesRef.current = []; + isCollectingWaveformRef.current = false; + setWaveform(null); + + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + if (onDelete) { + onDelete(); + } + }, [cleanupAudioContext, cleanupStream, onDelete, stopTimer]); + + const handleRestart = useCallback(() => { + isRestartingRef.current = true; + const mediaRecorder = mediaRecorderRef.current; + + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsStopped(false); + setIsTemporaryStopped(false); + setIsPaused(false); + setIsPlaying(false); + setSeconds(0); + pausedTimeRef.current = 0; + startTimeRef.current = null; + setLevels(Array.from({ length: BAR_COUNT }, () => 0.15)); + previousChunksRef.current = []; + chunksRef.current = []; + isResumingRef.current = false; + waveformSamplesRef.current = []; + isCollectingWaveformRef.current = false; + setWaveform(null); + + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + lastUrlRef.current = null; + } + + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + setAudioUrl(null); + setAudioFile(null); + internalStartRecording(); + }, [cleanupAudioContext, cleanupStream, internalStartRecording, stopTimer]); + + useEffect(() => { + if (autoStart) { + internalStartRecording(); + } + return () => { + const mediaRecorder = mediaRecorderRef.current; + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + lastUrlRef.current = null; + } + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + }; + }, [autoStart, cleanupAudioContext, cleanupStream, internalStartRecording, stopTimer]); + + const getState = (): RecorderState => { + if (isPlaying) return 'playing'; + if (isStopped && !isTemporaryStopped && audioUrl) return 'reviewing'; + if (isRecording && isPaused) return 'paused'; + if (isRecording) return 'recording'; + return 'idle'; + }; + + const handleRecordAgain = useCallback(() => { + handleRestart(); + }, [handleRestart]); + + return { + state: getState(), + isRecording, + isStopped, + isTemporaryStopped, + isPlaying, + isPaused, + seconds, + levels, + error, + audioUrl, + audioFile, + waveform, + start, + handlePause, + handleStopTemporary, + handleStop, + handleResume, + handlePreviewPlay, + handlePlay, + handleRestart, + handleDelete, + handleRecordAgain, + }; +}