volume panel; copilot instructions
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useEditorStore } from './store/editorStore';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import TranscriptEditor from './components/TranscriptEditor';
|
||||
@ -8,6 +8,7 @@ import ExportDialog from './components/ExportDialog';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import DevPanel from './components/DevPanel';
|
||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import VolumePanel from './components/VolumePanel';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import {
|
||||
Film,
|
||||
@ -20,6 +21,7 @@ import {
|
||||
Save,
|
||||
Scissors,
|
||||
VolumeX,
|
||||
Volume2,
|
||||
FilePlus2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
@ -27,13 +29,22 @@ import {
|
||||
const IS_ELECTRON = !!window.electronAPI;
|
||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | null;
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
isTranscribing,
|
||||
transcriptionProgress,
|
||||
transcriptionStatus,
|
||||
@ -54,8 +65,72 @@ export default function App() {
|
||||
const [cutMode, setCutMode] = useState(false);
|
||||
const [muteMode, setMuteMode] = useState(false);
|
||||
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
|
||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const projectSignature = useMemo(() => {
|
||||
if (!videoPath) return null;
|
||||
return JSON.stringify({
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
});
|
||||
}, [
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
]);
|
||||
|
||||
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
||||
|
||||
const loadProjectFromData = (data: any) => {
|
||||
useEditorStore.getState().loadProject(data);
|
||||
const loadedSignature = JSON.stringify({
|
||||
videoPath: data.videoPath,
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
words: data.words || [],
|
||||
segments: data.segments || [],
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
});
|
||||
setLastSavedSignature(loadedSignature);
|
||||
};
|
||||
|
||||
const runGuarded = async (action: () => Promise<void>) => {
|
||||
if (!IS_ELECTRON || !hasUnsavedChanges) {
|
||||
await action();
|
||||
return;
|
||||
}
|
||||
setPendingProceedAction(() => action);
|
||||
setShowUnsavedPrompt(true);
|
||||
};
|
||||
|
||||
useKeyboardShortcuts();
|
||||
|
||||
// Handle Escape key to exit cut/mute modes
|
||||
@ -99,58 +174,69 @@ export default function App() {
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
if (!IS_ELECTRON) return;
|
||||
try {
|
||||
const projectPath = await window.electronAPI!.openProject();
|
||||
if (!projectPath) return;
|
||||
const content = await window.electronAPI!.readFile(projectPath);
|
||||
const data = JSON.parse(content);
|
||||
useEditorStore.getState().loadProject(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
alert(`Failed to load project: ${err}`);
|
||||
}
|
||||
await runGuarded(async () => {
|
||||
try {
|
||||
const projectPath = await window.electronAPI!.openProject();
|
||||
if (!projectPath) return;
|
||||
const content = await window.electronAPI!.readFile(projectPath);
|
||||
const data = JSON.parse(content);
|
||||
loadProjectFromData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
alert(`Failed to load project: ${err}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveProject = async () => {
|
||||
if (!IS_ELECTRON) return;
|
||||
const handleSaveProject = async (): Promise<boolean> => {
|
||||
if (!IS_ELECTRON) return false;
|
||||
try {
|
||||
const savePath = await window.electronAPI!.saveProject();
|
||||
if (!savePath) return;
|
||||
if (!savePath) return false;
|
||||
const data = useEditorStore.getState().saveProject();
|
||||
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
||||
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
||||
if (projectSignature) {
|
||||
setLastSavedSignature(projectSignature);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to save project:', err);
|
||||
alert(`Failed to save project: ${err}`);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleOpenFile = async () => {
|
||||
if (IS_ELECTRON) {
|
||||
const path = await window.electronAPI!.openFile();
|
||||
if (path) {
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
await runGuarded(async () => {
|
||||
if (IS_ELECTRON) {
|
||||
const path = await window.electronAPI!.openFile();
|
||||
if (path) {
|
||||
setLastSavedSignature(null);
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
} else {
|
||||
// Browser: use the manual path input
|
||||
const path = manualPath.trim();
|
||||
if (path) {
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Browser: use the manual path input
|
||||
const path = manualPath.trim();
|
||||
if (path) {
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewProject = () => {
|
||||
const shouldReset = window.confirm('Start a new project? Unsaved changes in the current session will be lost.');
|
||||
if (!shouldReset) return;
|
||||
|
||||
useEditorStore.getState().reset();
|
||||
setActivePanel(null);
|
||||
setManualPath('');
|
||||
setCutMode(false);
|
||||
setMuteMode(false);
|
||||
runGuarded(async () => {
|
||||
useEditorStore.getState().reset();
|
||||
setLastSavedSignature(null);
|
||||
setActivePanel(null);
|
||||
setManualPath('');
|
||||
setCutMode(false);
|
||||
setMuteMode(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualSubmit = async (e: React.FormEvent) => {
|
||||
@ -209,7 +295,9 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowReprocessConfirm(true);
|
||||
await runGuarded(async () => {
|
||||
setShowReprocessConfirm(true);
|
||||
});
|
||||
};
|
||||
|
||||
const confirmReprocessProject = async () => {
|
||||
@ -218,6 +306,33 @@ export default function App() {
|
||||
await transcribeVideo(videoPath);
|
||||
};
|
||||
|
||||
const handleUnsavedSaveAndContinue = async () => {
|
||||
const action = pendingProceedAction;
|
||||
if (!action) {
|
||||
setShowUnsavedPrompt(false);
|
||||
return;
|
||||
}
|
||||
const didSave = await handleSaveProject();
|
||||
if (!didSave) return;
|
||||
setShowUnsavedPrompt(false);
|
||||
setPendingProceedAction(null);
|
||||
await action();
|
||||
};
|
||||
|
||||
const handleUnsavedDiscardAndContinue = async () => {
|
||||
const action = pendingProceedAction;
|
||||
setShowUnsavedPrompt(false);
|
||||
setPendingProceedAction(null);
|
||||
if (action) {
|
||||
await action();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsavedCancel = () => {
|
||||
setShowUnsavedPrompt(false);
|
||||
setPendingProceedAction(null);
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Panel) => {
|
||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||
};
|
||||
@ -403,6 +518,13 @@ export default function App() {
|
||||
onClick={handleMute}
|
||||
active={muteMode}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Volume2 className="w-4 h-4" />}
|
||||
label="Volume"
|
||||
active={activePanel === 'volume'}
|
||||
onClick={() => togglePanel('volume')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Pause Trim"
|
||||
@ -520,6 +642,7 @@ export default function App() {
|
||||
{/* Right panel (AI / Export / Settings) */}
|
||||
{activePanel && (
|
||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
||||
{activePanel === 'volume' && <VolumePanel />}
|
||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
@ -559,6 +682,43 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsavedPrompt && (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 px-4"
|
||||
onClick={handleUnsavedCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Save changes first?</h3>
|
||||
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||
There are unsaved changes in this project. Save before continuing?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleUnsavedCancel}
|
||||
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnsavedDiscardAndContinue}
|
||||
className="px-3 py-1.5 rounded-md text-xs text-editor-text hover:bg-editor-surface"
|
||||
>
|
||||
Don't Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnsavedSaveAndContinue}
|
||||
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
|
||||
import type { ExportOptions } from '../types/project';
|
||||
|
||||
export default function ExportDialog() {
|
||||
const { videoPath, words, deletedRanges, cutRanges, muteRanges, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||
const { videoPath, words, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||
useEditorStore();
|
||||
|
||||
const hasCuts = deletedRanges.length > 0;
|
||||
@ -47,6 +47,8 @@ export default function ExportDialog() {
|
||||
output_path: outputPath,
|
||||
keep_segments: keepSegments,
|
||||
mute_ranges: muteRanges,
|
||||
gain_ranges: gainRanges,
|
||||
global_gain_db: globalGainDb,
|
||||
words: options.captions !== 'none' ? words : undefined,
|
||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
||||
...options,
|
||||
|
||||
134
frontend/src/components/VolumePanel.tsx
Normal file
134
frontend/src/components/VolumePanel.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Trash2, Volume2 } from 'lucide-react';
|
||||
|
||||
export default function VolumePanel() {
|
||||
const {
|
||||
words,
|
||||
selectedWordIndices,
|
||||
globalGainDb,
|
||||
gainRanges,
|
||||
setGlobalGainDb,
|
||||
addGainRange,
|
||||
updateGainRange,
|
||||
removeGainRange,
|
||||
} = useEditorStore();
|
||||
|
||||
const [selectionGainDb, setSelectionGainDb] = useState(3);
|
||||
|
||||
const canApplySelection = selectedWordIndices.length > 0;
|
||||
|
||||
const selectedRange = useMemo(() => {
|
||||
if (!canApplySelection) return null;
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
if (!startWord || !endWord) return null;
|
||||
return {
|
||||
start: startWord.start,
|
||||
end: endWord.end,
|
||||
};
|
||||
}, [canApplySelection, selectedWordIndices, words]);
|
||||
|
||||
const applySelectionGain = () => {
|
||||
if (!selectedRange) return;
|
||||
addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Volume2 className="w-4 h-4" />
|
||||
Volume / Gain
|
||||
</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Apply global gain or per-selection gain ranges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Global Gain (dB)</label>
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={globalGainDb}
|
||||
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-editor-text-muted">-24 dB</span>
|
||||
<span className="font-medium text-editor-text">{globalGainDb.toFixed(1)} dB</span>
|
||||
<span className="text-editor-text-muted">+24 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={selectionGainDb}
|
||||
onChange={(e) => setSelectionGainDb(Number(e.target.value) || 0)}
|
||||
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={applySelectionGain}
|
||||
disabled={!canApplySelection || !selectedRange}
|
||||
className="px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40"
|
||||
>
|
||||
Apply To Selection
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-editor-text-muted">
|
||||
{canApplySelection
|
||||
? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
|
||||
: 'Select transcript words to apply a gain range.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{gainRanges.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<div className="text-xs font-medium">Gain Ranges</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
|
||||
{gainRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs flex items-center gap-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{range.start.toFixed(2)}s - {range.end.toFixed(2)}s
|
||||
</div>
|
||||
<div className="text-editor-text-muted">{range.id}</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={range.gainDb}
|
||||
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||
className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
|
||||
title="Gain dB"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeGainRange(range.id)}
|
||||
className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
|
||||
title="Delete gain range"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
DeletedRange,
|
||||
CutRange,
|
||||
MuteRange,
|
||||
GainRange,
|
||||
TranscriptionResult,
|
||||
ProjectFile,
|
||||
SilenceDetectionRange,
|
||||
@ -22,6 +23,8 @@ interface EditorState {
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
gainRanges: GainRange[];
|
||||
globalGainDb: number;
|
||||
silenceTrimGroups: SilenceTrimGroup[];
|
||||
transcriptionModel: string | null;
|
||||
language: string;
|
||||
@ -59,10 +62,14 @@ interface EditorActions {
|
||||
restoreRange: (rangeId: string) => void;
|
||||
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
|
||||
addMuteRange: (start: number, end: number) => void;
|
||||
addGainRange: (start: number, end: number, gainDb: number) => void;
|
||||
updateCutRange: (id: string, start: number, end: number) => void;
|
||||
updateMuteRange: (id: string, start: number, end: number) => void;
|
||||
updateGainRange: (id: string, gainDb: number) => void;
|
||||
removeCutRange: (id: string) => void;
|
||||
removeMuteRange: (id: string) => void;
|
||||
removeGainRange: (id: string) => void;
|
||||
setGlobalGainDb: (gainDb: number) => void;
|
||||
applySilenceTrimGroup: (args: {
|
||||
groupId?: string;
|
||||
sourceRanges: SilenceDetectionRange[];
|
||||
@ -86,6 +93,8 @@ const initialState: EditorState = {
|
||||
deletedRanges: [],
|
||||
cutRanges: [],
|
||||
muteRanges: [],
|
||||
gainRanges: [],
|
||||
globalGainDb: 0,
|
||||
silenceTrimGroups: [],
|
||||
transcriptionModel: null,
|
||||
language: '',
|
||||
@ -142,7 +151,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
|
||||
saveProject: (): ProjectFile => {
|
||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
||||
if (!videoPath) throw new Error('No video loaded');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting
|
||||
@ -157,6 +166,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
language,
|
||||
createdAt: now, // will be overwritten if we track original creation time later
|
||||
@ -259,6 +270,17 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
set({ muteRanges: [...muteRanges, newRange] });
|
||||
},
|
||||
|
||||
addGainRange: (start, end, gainDb) => {
|
||||
const { gainRanges } = get();
|
||||
const newRange: GainRange = {
|
||||
id: `gain_${nextRangeId++}`,
|
||||
start,
|
||||
end,
|
||||
gainDb,
|
||||
};
|
||||
set({ gainRanges: [...gainRanges, newRange] });
|
||||
},
|
||||
|
||||
updateCutRange: (id, start, end) => {
|
||||
const { cutRanges } = get();
|
||||
set({
|
||||
@ -277,6 +299,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
});
|
||||
},
|
||||
|
||||
updateGainRange: (id, gainDb) => {
|
||||
const { gainRanges } = get();
|
||||
set({
|
||||
gainRanges: gainRanges.map((r) =>
|
||||
r.id === id ? { ...r, gainDb } : r
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
removeCutRange: (id) => {
|
||||
const { cutRanges } = get();
|
||||
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
|
||||
@ -287,6 +318,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
set({ muteRanges: muteRanges.filter((r) => r.id !== id) });
|
||||
},
|
||||
|
||||
removeGainRange: (id) => {
|
||||
const { gainRanges } = get();
|
||||
set({ gainRanges: gainRanges.filter((r) => r.id !== id) });
|
||||
},
|
||||
|
||||
setGlobalGainDb: (gainDb) => {
|
||||
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
||||
},
|
||||
|
||||
applySilenceTrimGroup: ({ groupId, sourceRanges, settings }) => {
|
||||
const { duration, cutRanges, silenceTrimGroups } = get();
|
||||
const now = new Date().toISOString();
|
||||
@ -415,6 +455,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
|
||||
@ -35,6 +35,11 @@ export interface MuteRange extends TimeRange {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GainRange extends TimeRange {
|
||||
id: string;
|
||||
gainDb: number;
|
||||
}
|
||||
|
||||
export interface SilenceDetectionRange extends TimeRange {
|
||||
duration: number;
|
||||
}
|
||||
@ -64,6 +69,8 @@ export interface ProjectFile {
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
gainRanges?: GainRange[];
|
||||
globalGainDb?: number;
|
||||
silenceTrimGroups?: SilenceTrimGroup[];
|
||||
language: string;
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user