Skip to content
This repository was archived by the owner on May 3, 2025. It is now read-only.

Commit 286c434

Browse files
Export as wav file (#96)
* Implement OpusMediaRecorder in useToneAudio hook * Add file name + type fields to ExportDialog, remove Alert regarding .webm conversions * Move Tone.Transport.start after recorder starts to avoid cutting off start audio, set default file name as download prop if empty * Remove unused code
1 parent 8491652 commit 286c434

File tree

10 files changed

+229
-39
lines changed

10 files changed

+229
-39
lines changed

package-lock.json

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"jotai": "1.5.2",
2121
"lodash": "4.17.21",
2222
"luxon": "2.0.2",
23+
"opus-media-recorder": "0.8.0",
2324
"react": "17.0.2",
2425
"react-beautiful-dnd": "13.1.0",
2526
"react-dom": "17.0.2",
220 KB
Binary file not shown.
243 KB
Binary file not shown.

public/opus-media-recorder/encoderWorker.umd.js

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/workstation/export-dialog.tsx

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,46 @@
11
import { Dialog, DialogProps } from "components/dialog";
22
import {
3-
Alert,
43
Button,
54
ExportIcon,
6-
Link,
75
Text,
86
majorScale,
97
Paragraph,
108
RecordIcon,
119
Spinner,
10+
Pane,
11+
TextInput,
12+
Label,
13+
minorScale,
1214
} from "evergreen-ui";
1315
import { List } from "immutable";
14-
import React, { useCallback, useState } from "react";
16+
import React, { useCallback, useMemo, useState } from "react";
1517
import { useListFiles } from "utils/hooks/domain/files/use-list-files";
1618
import { useListInstruments } from "utils/hooks/domain/instruments/use-list-instruments";
1719
import { useBoolean } from "utils/hooks/use-boolean";
1820
import { useToneAudio } from "utils/hooks/use-tone-audio";
1921
import { useWorkstationState } from "utils/hooks/use-workstation-state";
2022
import * as Tone from "tone";
21-
import { formatNow } from "utils/date-utils";
2223
import { ProjectRecord } from "models/project-record";
2324
import { useTheme } from "utils/hooks/use-theme";
25+
import { MimeType } from "enums/mime-type";
26+
import { enumToSelectMenuItems } from "utils/select-menu-utils";
27+
import { SelectMenu, SelectMenuItem } from "components/select-menu";
28+
import { getExtension } from "utils/mime-type-utils";
29+
import slugify from "slugify";
30+
import { unixTime } from "utils/core-utils";
31+
import { useInput } from "utils/hooks/use-input";
32+
import { isEmpty } from "lodash";
2433

2534
interface ExportDialogProps
2635
extends Pick<DialogProps, "isShown" | "onCloseComplete"> {}
2736

37+
const options: Array<SelectMenuItem<MimeType>> = enumToSelectMenuItems(
38+
MimeType
39+
).map((item) => {
40+
const mimeType = item.value as MimeType;
41+
return { ...item, label: getExtension(mimeType)! };
42+
}) as Array<SelectMenuItem<MimeType>>;
43+
2844
const title = "Export as Audio";
2945

3046
const ExportDialog: React.FC<ExportDialogProps> = (
@@ -44,6 +60,12 @@ const ExportDialog: React.FC<ExportDialogProps> = (
4460
setTrue: startRecording,
4561
setFalse: stopRecording,
4662
} = useBoolean();
63+
64+
const { value: fileName, ...inputProps } = useInput({
65+
initialValue: getDefaultFileName(state.project),
66+
isRequired: true,
67+
});
68+
const [mimeType, setMimeType] = useState<MimeType>(MimeType.WAV);
4769
const [blob, setBlob] = useState<Blob>();
4870
const handleRecordingComplete = useCallback(
4971
(file: Blob) => {
@@ -56,6 +78,7 @@ const ExportDialog: React.FC<ExportDialogProps> = (
5678
const { isLoading: isLoadingSamples } = useToneAudio({
5779
lengthInMs: state.getLengthInMs(),
5880
onRecordingComplete: handleRecordingComplete,
81+
mimeType,
5982
isRecording,
6083
files,
6184
instruments,
@@ -77,6 +100,22 @@ const ExportDialog: React.FC<ExportDialogProps> = (
77100
stopRecording();
78101
onCloseComplete?.();
79102
}, [onCloseComplete, stopRecording]);
103+
104+
const handleSelect = useCallback(
105+
(item: SelectMenuItem<MimeType>) => setMimeType(item.value),
106+
[]
107+
);
108+
109+
const fullFileName = useMemo(() => {
110+
const extension = getExtension(mimeType)!;
111+
if (isEmpty(fileName)) {
112+
return `${getDefaultFileName(state.project)}${extension}`;
113+
}
114+
115+
return `${fileName}${extension}`;
116+
}, [fileName, mimeType, state.project]);
117+
const hasFile = blob != null;
118+
80119
return (
81120
<Dialog
82121
confirmLabel="Close"
@@ -93,54 +132,72 @@ const ExportDialog: React.FC<ExportDialogProps> = (
93132
below. Recording happens in real-time, and you'll be
94133
able to download the file once complete.
95134
</Paragraph>
96-
<Alert
97-
marginBottom={majorScale(2)}
98-
title="The file will be exported as a .webm file">
99-
<Text>
100-
Use a site like{" "}
101-
<Link
102-
href="https://cloudconvert.com/webm-converter"
103-
target="_blank">
104-
CloudConvert
105-
</Link>{" "}
106-
to convert it to your desired format.
107-
</Text>
108-
</Alert>
135+
<Pane
136+
display="flex"
137+
flexDirection="row"
138+
marginBottom={majorScale(2)}>
139+
<Pane
140+
display="flex"
141+
flexDirection="column"
142+
marginRight={majorScale(1)}
143+
width="88%">
144+
<Label marginBottom={minorScale(1)}>Name</Label>
145+
<TextInput
146+
{...inputProps}
147+
value={fileName}
148+
width="100%"
149+
/>
150+
</Pane>
151+
<Pane display="flex" flexDirection="column" width="12%">
152+
<Label marginBottom={minorScale(1)}>Type</Label>
153+
<SelectMenu
154+
calculateHeight={true}
155+
closeOnSelect={true}
156+
hasFilter={false}
157+
isMultiSelect={false}
158+
onSelect={handleSelect}
159+
options={options}
160+
title="Type"
161+
width={majorScale(16)}>
162+
<Button disabled={hasFile || isRecording}>
163+
{getExtension(mimeType)}
164+
</Button>
165+
</SelectMenu>
166+
</Pane>
167+
</Pane>
109168
<Button
110169
allowUnsafeHref={true}
111-
appearance={blob == null ? "default" : "primary"}
112-
download={getFileName(state.project)}
113-
href={
114-
blob != null ? URL.createObjectURL(blob) : undefined
115-
}
170+
appearance={hasFile ? "primary" : "default"}
171+
download={fullFileName}
172+
href={hasFile ? URL.createObjectURL(blob) : undefined}
116173
iconBefore={
117-
blob == null ? (
174+
hasFile ? (
175+
ExportIcon
176+
) : (
118177
<RecordIcon
119178
color={
120179
isRecording ? colors.red600 : undefined
121180
}
122181
/>
123-
) : (
124-
ExportIcon
125182
)
126183
}
127184
is="a"
128185
isLoading={isRecording}
129-
onClick={blob == null ? handleExportClick : undefined}
186+
onClick={hasFile ? undefined : handleExportClick}
130187
width="100%">
131-
{blob == null && !isRecording && "Export"}
188+
{!hasFile && !isRecording && "Export"}
132189
{isRecording && (
133190
<Text color={colors.red600}>Recording...</Text>
134191
)}
135-
{blob != null && !isRecording && "Download file"}
192+
{hasFile && !isRecording && "Download file"}
136193
</Button>
137194
</React.Fragment>
138195
)}
139196
</Dialog>
140197
);
141198
};
142199

143-
const getFileName = (project: ProjectRecord): string =>
144-
`${project.name} ${formatNow()}.webm`;
200+
const getDefaultFileName = (project: ProjectRecord): string =>
201+
slugify(`${project.name} ${project.bpm} BPM ${unixTime()}`);
145202

146203
export { ExportDialog };

src/enums/mime-type.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
enum MimeType {
2+
OGG = "audio/ogg",
3+
WAV = "audio/wav",
4+
WEBM = "audio/webm",
5+
}
6+
7+
export { MimeType };

src/globals.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
declare module "opus-media-recorder" {
2+
declare class OpusMediaRecorder extends MediaRecorder {
3+
constructor(
4+
stream: MediaStream,
5+
options?: MediaRecorderOptions,
6+
workerOptions?: OpusMediaRecorderWorkerOptions
7+
);
8+
}
9+
10+
export interface OpusMediaRecorderWorkerOptions {
11+
encoderWorkerFactory: () => Worker;
12+
OggOpusEncoderWasmPath: string;
13+
WebMOpusEncoderWasmPath: string;
14+
}
15+
16+
export = OpusMediaRecorder;
17+
}

0 commit comments

Comments
 (0)