MIDI Recording and details improvement
This commit is contained in:
parent
14a13d5768
commit
b625b8a6d1
@ -55,6 +55,7 @@ func (a *App) OnStartup(ctx context.Context) {
|
||||
}
|
||||
|
||||
a.downloadLoop()
|
||||
a.midiLoop()
|
||||
a.watchFs()
|
||||
a.monitorHardware()
|
||||
}
|
||||
|
142
backend-golang/midi.go
Normal file
142
backend-golang/midi.go
Normal file
@ -0,0 +1,142 @@
|
||||
package backend_golang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mattrtaylor/go-rtmidi"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type Port struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type MIDIMessage struct {
|
||||
MessageType string `json:"messageType"`
|
||||
Channel int `json:"channel"`
|
||||
Note int `json:"note"`
|
||||
Velocity int `json:"velocity"`
|
||||
Control int `json:"control"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
var ports []Port
|
||||
var input rtmidi.MIDIIn
|
||||
var activeIndex int = -1
|
||||
var lastNoteTime time.Time
|
||||
|
||||
func (a *App) midiLoop() {
|
||||
var err error
|
||||
input, err = rtmidi.NewMIDIInDefault()
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "midiError", err.Error())
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
go func() {
|
||||
for {
|
||||
<-ticker.C
|
||||
count, err := input.PortCount()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ports = make([]Port, count)
|
||||
for i := 0; i < count; i++ {
|
||||
name, err := input.PortName(i)
|
||||
if err == nil {
|
||||
ports[i].Name = name
|
||||
}
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "midiPorts", &ports)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) OpenMidiPort(index int) error {
|
||||
if input == nil {
|
||||
return errors.New("failed to initialize MIDI")
|
||||
}
|
||||
if activeIndex == index {
|
||||
return nil
|
||||
}
|
||||
input.Destroy()
|
||||
var err error
|
||||
input, err = rtmidi.NewMIDIInDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = input.SetCallback(func(msg rtmidi.MIDIIn, bytes []byte, t float64) {
|
||||
// https://www.midi.org/specifications-old/item/table-1-summary-of-midi-message
|
||||
// https://www.rfc-editor.org/rfc/rfc6295.html
|
||||
//
|
||||
// msgType channel
|
||||
// 1001 0000
|
||||
//
|
||||
msgType := bytes[0] >> 4
|
||||
channel := bytes[0] & 0x0f
|
||||
switch msgType {
|
||||
case 0x8:
|
||||
note := bytes[1]
|
||||
runtime.EventsEmit(a.ctx, "midiMessage", &MIDIMessage{
|
||||
MessageType: "NoteOff",
|
||||
Channel: int(channel),
|
||||
Note: int(note),
|
||||
})
|
||||
case 0x9:
|
||||
elapsed := time.Since(lastNoteTime)
|
||||
lastNoteTime = time.Now()
|
||||
runtime.EventsEmit(a.ctx, "midiMessage", &MIDIMessage{
|
||||
MessageType: "ElapsedTime",
|
||||
Value: int(elapsed.Milliseconds()),
|
||||
})
|
||||
note := bytes[1]
|
||||
velocity := bytes[2]
|
||||
runtime.EventsEmit(a.ctx, "midiMessage", &MIDIMessage{
|
||||
MessageType: "NoteOn",
|
||||
Channel: int(channel),
|
||||
Note: int(note),
|
||||
Velocity: int(velocity),
|
||||
})
|
||||
case 0xb:
|
||||
// control 12 => K1 knob, control 13 => K2 knob
|
||||
control := bytes[1]
|
||||
value := bytes[2]
|
||||
runtime.EventsEmit(a.ctx, "midiMessage", &MIDIMessage{
|
||||
MessageType: "ControlChange",
|
||||
Channel: int(channel),
|
||||
Control: int(control),
|
||||
Value: int(value),
|
||||
})
|
||||
default:
|
||||
fmt.Printf("Unknown midi message: %v\n", bytes)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = input.OpenPort(index, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activeIndex = index
|
||||
lastNoteTime = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CloseMidiPort() error {
|
||||
if input == nil {
|
||||
return errors.New("failed to initialize MIDI")
|
||||
}
|
||||
if activeIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
activeIndex = -1
|
||||
input.Destroy()
|
||||
var err error
|
||||
input, err = rtmidi.NewMIDIInDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTrigger } from '@fluentui/react-components';
|
||||
import { CustomToastContainer } from '../../components/CustomToastContainer';
|
||||
import { LazyImportComponent } from '../../components/LazyImportComponent';
|
||||
import { flushMidiRecordingContent } from '../../utils';
|
||||
import commonStore from '../../stores/commonStore';
|
||||
|
||||
const AudiotrackEditor = lazy(() => import('./AudiotrackEditor'));
|
||||
|
||||
@ -13,7 +15,12 @@ export const AudiotrackButton: FC<{
|
||||
}> = ({ size, shape, appearance }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Dialog>
|
||||
return <Dialog onOpenChange={(e, data) => {
|
||||
if (!data.open) {
|
||||
flushMidiRecordingContent();
|
||||
commonStore.setRecordingTrackId('');
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger disableButtonEnhancement>
|
||||
<Button size={size} shape={shape} appearance={appearance}>
|
||||
{t('Open MIDI Input Audio Tracks')}
|
||||
|
@ -10,19 +10,39 @@ import {
|
||||
Delete16Regular,
|
||||
MusicNote220Regular,
|
||||
Play16Regular,
|
||||
Record16Regular
|
||||
Record16Regular,
|
||||
Stop16Filled
|
||||
} from '@fluentui/react-icons';
|
||||
import { Button, Card, Slider, Text, Tooltip } from '@fluentui/react-components';
|
||||
import { useWindowSize } from 'usehooks-ts';
|
||||
import commonStore from '../../stores/commonStore';
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
InstrumentTypeNameMap,
|
||||
InstrumentTypeTokenMap,
|
||||
MidiMessage,
|
||||
tracksMinimalTotalTime
|
||||
} from '../../types/composition';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ToastOptions } from 'react-toastify/dist/types';
|
||||
import { flushMidiRecordingContent, refreshTracksTotalTime } from '../../utils';
|
||||
|
||||
const snapValue = 25;
|
||||
const minimalMoveTime = 8; // 1000/125=8ms
|
||||
const scaleMin = 0.2;
|
||||
const minimalMoveTime = 8; // 1000/125=8ms wait_events=125
|
||||
const scaleMin = 0.05;
|
||||
const scaleMax = 3;
|
||||
const baseMoveTime = Math.round(minimalMoveTime / scaleMin);
|
||||
|
||||
const velocityEvents = 128;
|
||||
const velocityBins = 12;
|
||||
const velocityExp = 0.5;
|
||||
|
||||
const minimalTrackWidth = 80;
|
||||
const trackInitOffsetPx = 10;
|
||||
const pixelFix = 0.5;
|
||||
const topToArrowIcon = 19;
|
||||
const arrowIconToTracks = 23;
|
||||
|
||||
type TrackProps = {
|
||||
id: string;
|
||||
right: number;
|
||||
@ -31,6 +51,82 @@ type TrackProps = {
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
const displayCurrentInstrumentType = () => {
|
||||
const displayPanelId = 'instrument_panel_id';
|
||||
const content: React.ReactNode =
|
||||
<div className="flex gap-2 items-center">
|
||||
{InstrumentTypeNameMap.map((name, i) =>
|
||||
<Text key={name} style={{ whiteSpace: 'nowrap' }}
|
||||
className={commonStore.instrumentType === i ? 'text-blue-600' : ''}
|
||||
weight={commonStore.instrumentType === i ? 'bold' : 'regular'}
|
||||
size={commonStore.instrumentType === i ? 300 : 100}
|
||||
>{name}</Text>)}
|
||||
</div>;
|
||||
const options: ToastOptions = {
|
||||
type: 'default',
|
||||
autoClose: 2000,
|
||||
toastId: displayPanelId,
|
||||
position: 'top-left',
|
||||
style: {
|
||||
width: 'fit-content'
|
||||
}
|
||||
};
|
||||
if (toast.isActive(displayPanelId))
|
||||
toast.update(displayPanelId, {
|
||||
render: content,
|
||||
...options
|
||||
});
|
||||
else
|
||||
toast(content, options);
|
||||
};
|
||||
|
||||
const velocityToBin = (velocity: number) => {
|
||||
velocity = Math.max(0, Math.min(velocity, velocityEvents - 1));
|
||||
const binsize = velocityEvents / (velocityBins - 1);
|
||||
return Math.ceil((velocityEvents * ((Math.pow(velocityExp, (velocity / velocityEvents)) - 1.0) / (velocityExp - 1.0))) / binsize);
|
||||
};
|
||||
|
||||
const midiMessageToToken = (msg: MidiMessage) => {
|
||||
if (msg.messageType === 'NoteOn') {
|
||||
const instrument = InstrumentTypeTokenMap[commonStore.instrumentType];
|
||||
const note = msg.note.toString(16);
|
||||
const velocity = velocityToBin(msg.velocity).toString(16);
|
||||
return `${instrument}:${note}:${velocity} `;
|
||||
} else if (msg.messageType === 'ElapsedTime') {
|
||||
let time = Math.round(msg.value / minimalMoveTime);
|
||||
const num = Math.floor(time / 125); // wait_events=125
|
||||
time -= num * 125;
|
||||
let ret = '';
|
||||
for (let i = 0; i < num; i++) {
|
||||
ret += 't125 ';
|
||||
}
|
||||
if (time > 0)
|
||||
ret += `t${time} `;
|
||||
return ret;
|
||||
} else
|
||||
return '';
|
||||
};
|
||||
|
||||
let dropRecordingTime = false;
|
||||
|
||||
export const midiMessageHandler = (data: MidiMessage) => {
|
||||
if (data.messageType === 'NoteOff')
|
||||
return;
|
||||
if (data.messageType === 'ControlChange') {
|
||||
commonStore.setInstrumentType(Math.round(data.value / 127 * (InstrumentTypeNameMap.length - 1)));
|
||||
displayCurrentInstrumentType();
|
||||
return;
|
||||
}
|
||||
if (commonStore.recordingTrackId) {
|
||||
if (dropRecordingTime && data.messageType === 'ElapsedTime') {
|
||||
dropRecordingTime = false;
|
||||
return;
|
||||
}
|
||||
commonStore.setRecordingRawContent([...commonStore.recordingRawContent, data]);
|
||||
commonStore.setRecordingContent(commonStore.recordingContent + midiMessageToToken(data));
|
||||
}
|
||||
};
|
||||
|
||||
const Track: React.FC<TrackProps> = observer(({
|
||||
id,
|
||||
right,
|
||||
@ -66,18 +162,19 @@ const Track: React.FC<TrackProps> = observer(({
|
||||
const tracks = commonStore.tracks.slice();
|
||||
tracks[trackIndex].offsetTime += offsetTime;
|
||||
commonStore.setTracks(tracks);
|
||||
refreshTracksTotalTime();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`p-1 cursor-move rounded whitespace-nowrap overflow-hidden ${trackClass}`}
|
||||
style={{
|
||||
width: `${Math.max(80,
|
||||
width: `${Math.max(minimalTrackWidth,
|
||||
track.contentTime / (baseMoveTime * scale) * snapValue
|
||||
)}px`
|
||||
}}
|
||||
onClick={() => onSelect(id)}
|
||||
>
|
||||
<span className="text-white">{t('Track') + ' ' + id}</span>
|
||||
<span className="text-white">{t('Track') + ' ' + (track.content || id)}</span>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
@ -86,12 +183,15 @@ const Track: React.FC<TrackProps> = observer(({
|
||||
const AudiotrackEditor: FC = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const viewControlsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const currentTimeControlRef = useRef<HTMLDivElement>(null);
|
||||
const playStartTimeControlRef = useRef<HTMLDivElement>(null);
|
||||
const tracksEndLineRef = useRef<HTMLDivElement>(null);
|
||||
const tracksRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarButtonRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarSliderRef = useRef<HTMLInputElement>(null);
|
||||
const contentPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [refreshRef, setRefreshRef] = useState(false);
|
||||
|
||||
@ -102,6 +202,20 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
const playStartTimeControlX = useRef(0);
|
||||
const selectedTrack = selectedTrackId ? commonStore.tracks.find(t => t.id === selectedTrackId) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (toolbarSliderRef.current && toolbarSliderRef.current.parentElement)
|
||||
toolbarSliderRef.current.parentElement.style.removeProperty('--fui-Slider--steps-percent');
|
||||
}, []);
|
||||
|
||||
const scrollContentToBottom = () => {
|
||||
if (contentPreviewRef.current)
|
||||
contentPreviewRef.current.scrollTop = contentPreviewRef.current.scrollHeight;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollContentToBottom();
|
||||
}, [commonStore.recordingContent]);
|
||||
|
||||
useEffect(() => {
|
||||
setRefreshRef(!refreshRef);
|
||||
}, [windowSize, commonStore.tracks]);
|
||||
@ -115,10 +229,12 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
const currentTimeControlWidth = (timeOfTracksWidth < commonStore.trackTotalTime)
|
||||
? timeOfTracksWidth / commonStore.trackTotalTime * viewControlsContainerWidth
|
||||
: 0;
|
||||
const playStartTimeControlPosition = {
|
||||
x: (commonStore.trackPlayStartTime - commonStore.trackCurrentTime) / (baseMoveTime * scale) * snapValue,
|
||||
y: 0
|
||||
};
|
||||
const playStartTimeControlPosition = (commonStore.trackPlayStartTime - commonStore.trackCurrentTime) / (baseMoveTime * scale) * snapValue;
|
||||
const tracksEndPosition = (commonStore.trackTotalTime - commonStore.trackCurrentTime) / (baseMoveTime * scale) * snapValue;
|
||||
const moveableTracksWidth = (tracksEndLineRef.current && viewControlsContainerRef.current &&
|
||||
((tracksEndLineRef.current.getBoundingClientRect().left - (viewControlsContainerRef.current.getBoundingClientRect().left + trackInitOffsetPx)) > 0))
|
||||
? tracksEndLineRef.current.getBoundingClientRect().left - (viewControlsContainerRef.current.getBoundingClientRect().left + trackInitOffsetPx)
|
||||
: Infinity;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 overflow-hidden" style={{ width: '80vw', height: '80vh' }}>
|
||||
@ -130,10 +246,28 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
<ToolTipButton desc={t('Play All')} icon={<Play16Regular />} />
|
||||
<ToolTipButton desc={t('Clear All')} icon={<Delete16Regular />} onClick={() => {
|
||||
commonStore.setTracks([]);
|
||||
commonStore.setTrackScale(1);
|
||||
commonStore.setTrackTotalTime(tracksMinimalTotalTime);
|
||||
commonStore.setTrackCurrentTime(0);
|
||||
commonStore.setTrackPlayStartTime(0);
|
||||
}} />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="flex flex-col ml-2 mr-2">
|
||||
<div className="flex flex-col ml-2 mr-2" ref={viewControlsContainerRef}>
|
||||
<div className="relative">
|
||||
<Tooltip content={`${commonStore.trackTotalTime} ms`} showDelay={0} hideDelay={0}
|
||||
relationship="description">
|
||||
<div className="border-l absolute"
|
||||
ref={tracksEndLineRef}
|
||||
style={{
|
||||
height: (tracksRef.current && commonStore.tracks.length > 0)
|
||||
? tracksRef.current.clientHeight - arrowIconToTracks
|
||||
: 0,
|
||||
top: `${topToArrowIcon + arrowIconToTracks}px`,
|
||||
left: `${tracksEndPosition + trackInitOffsetPx - pixelFix}px`
|
||||
}} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Draggable axis="x" bounds={{
|
||||
left: 0,
|
||||
right: viewControlsContainerWidth - currentTimeControlWidth
|
||||
@ -160,17 +294,17 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
</Draggable>
|
||||
<div className={classnames(
|
||||
'flex',
|
||||
(playStartTimeControlPosition.x < 0 || playStartTimeControlPosition.x > viewControlsContainerWidth)
|
||||
(playStartTimeControlPosition < 0 || playStartTimeControlPosition > viewControlsContainerWidth)
|
||||
&& 'hidden'
|
||||
)}>
|
||||
<Draggable axis="x" bounds={{
|
||||
left: 0,
|
||||
right: (playStartTimeControlRef.current)
|
||||
? viewControlsContainerWidth - playStartTimeControlRef.current.clientWidth
|
||||
? Math.min(viewControlsContainerWidth - playStartTimeControlRef.current.clientWidth, moveableTracksWidth)
|
||||
: 0
|
||||
}}
|
||||
grid={[snapValue, snapValue]}
|
||||
position={playStartTimeControlPosition}
|
||||
position={{ x: playStartTimeControlPosition, y: 0 }}
|
||||
onStart={(e, data) => {
|
||||
playStartTimeControlX.current = data.lastX;
|
||||
}}
|
||||
@ -192,14 +326,15 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
? tracksRef.current.clientHeight
|
||||
: 0,
|
||||
top: '50%',
|
||||
left: 'calc(50% - 0.5px)'
|
||||
left: `calc(50% - ${pixelFix}px)`
|
||||
}} />
|
||||
</div>
|
||||
</Draggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip content={t('Scale View')!} showDelay={0} hideDelay={0} relationship="label">
|
||||
<Tooltip content={t('Scale View')! + ': ' + commonStore.trackScale} showDelay={0} hideDelay={0}
|
||||
relationship="description">
|
||||
<Slider ref={toolbarSliderRef} value={commonStore.trackScale} step={scaleMin} max={scaleMax} min={scaleMin}
|
||||
onChange={(e, data) => {
|
||||
commonStore.setTrackScale(data.value);
|
||||
@ -211,8 +346,23 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
{commonStore.tracks.map(track =>
|
||||
<div key={track.id} className="flex gap-2 pb-1 border-b">
|
||||
<div className="flex gap-1 border-r h-7">
|
||||
<ToolTipButton desc={t('Record')} icon={<Record16Regular />} size="small" shape="circular"
|
||||
appearance="subtle" />
|
||||
<ToolTipButton desc={commonStore.recordingTrackId === track.id ? t('Stop') : t('Record')}
|
||||
icon={commonStore.recordingTrackId === track.id ? <Stop16Filled /> : <Record16Regular />}
|
||||
size="small" shape="circular"
|
||||
appearance="subtle" onClick={() => {
|
||||
flushMidiRecordingContent();
|
||||
|
||||
if (commonStore.recordingTrackId === track.id) {
|
||||
commonStore.setRecordingTrackId('');
|
||||
} else {
|
||||
dropRecordingTime = true;
|
||||
setSelectedTrackId(track.id);
|
||||
|
||||
commonStore.setRecordingTrackId(track.id);
|
||||
commonStore.setRecordingContent(track.content);
|
||||
commonStore.setRecordingRawContent(track.rawContent.slice());
|
||||
}
|
||||
}} />
|
||||
<ToolTipButton desc={t('Play')} icon={<Play16Regular />} size="small" shape="circular"
|
||||
appearance="subtle" />
|
||||
<ToolTipButton desc={t('Delete')} icon={<Delete16Regular />} size="small" shape="circular"
|
||||
@ -226,7 +376,7 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
<Track
|
||||
id={track.id}
|
||||
scale={scale}
|
||||
right={tracksWidth}
|
||||
right={Math.min(tracksWidth, moveableTracksWidth)}
|
||||
isSelected={selectedTrackId === track.id}
|
||||
onSelect={setSelectedTrackId}
|
||||
/>
|
||||
@ -240,6 +390,7 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
commonStore.setTracks([...commonStore.tracks, {
|
||||
id: uuid(),
|
||||
content: '',
|
||||
rawContent: [],
|
||||
offsetTime: 0,
|
||||
contentTime: 0
|
||||
}]);
|
||||
@ -253,12 +404,14 @@ const AudiotrackEditor: FC = observer(() => {
|
||||
</div>
|
||||
<div className="grow"></div>
|
||||
{selectedTrack &&
|
||||
<Card size="small" appearance="outline" style={{ minHeight: '150px' }}>
|
||||
<Card size="small" appearance="outline" style={{ minHeight: '150px', maxHeight: '200px' }}>
|
||||
<div className="flex flex-col gap-1 overflow-hidden">
|
||||
<Text size={100}>{`${t('Start Time')}: ${selectedTrack.offsetTime} ms`}</Text>
|
||||
<Text size={100}>{`${t('Content Time')}: ${selectedTrack.contentTime} ms`}</Text>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
{selectedTrack.content}
|
||||
<div className="overflow-y-auto overflow-x-hidden" ref={contentPreviewRef}>
|
||||
{selectedTrackId === commonStore.recordingTrackId
|
||||
? commonStore.recordingContent
|
||||
: selectedTrack.content}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
@ -16,7 +16,13 @@ import { PlayerElement, VisualizerElement } from 'html-midi-player';
|
||||
import * as mm from '@magenta/music/esm/core.js';
|
||||
import { NoteSequence } from '@magenta/music/esm/protobuf.js';
|
||||
import { defaultCompositionPrompt } from './defaultConfigs';
|
||||
import { FileExists, OpenFileFolder, OpenSaveFileDialogBytes } from '../../wailsjs/go/backend_golang/App';
|
||||
import {
|
||||
CloseMidiPort,
|
||||
FileExists,
|
||||
OpenFileFolder,
|
||||
OpenMidiPort,
|
||||
OpenSaveFileDialogBytes
|
||||
} from '../../wailsjs/go/backend_golang/App';
|
||||
import { getServerRoot, toastWithButton } from '../utils';
|
||||
import { CompositionParams } from '../types/composition';
|
||||
import { useMediaQuery } from 'usehooks-ts';
|
||||
@ -273,8 +279,26 @@ const CompositionPanel: FC = observer(() => {
|
||||
desc={t('Select the MIDI input device to be used.')}
|
||||
content={
|
||||
<div className="flex flex-col gap-1">
|
||||
<Dropdown style={{ minWidth: 0 }}>
|
||||
<Option>{t('None')!}</Option>
|
||||
<Dropdown style={{ minWidth: 0 }}
|
||||
value={commonStore.activeMidiDeviceIndex === -1 ? t('None')! : commonStore.midiPorts[commonStore.activeMidiDeviceIndex].name}
|
||||
selectedOptions={[commonStore.activeMidiDeviceIndex.toString()]}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (data.optionValue) {
|
||||
const index = Number(data.optionValue);
|
||||
let action = (index === -1)
|
||||
? () => CloseMidiPort()
|
||||
: () => OpenMidiPort(index);
|
||||
action().then(() => {
|
||||
commonStore.setActiveMidiDeviceIndex(index);
|
||||
}).catch((e) => {
|
||||
toast(t('Error') + ' - ' + (e.message || e), { type: 'error', autoClose: 2500 });
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Option value={'-1'}>{t('None')!}</Option>
|
||||
{commonStore.midiPorts.map((p, i) =>
|
||||
<Option key={i} value={i.toString()}>{p.name}</Option>)
|
||||
}
|
||||
</Dropdown>
|
||||
<AudiotrackButton />
|
||||
</div>
|
||||
|
@ -7,6 +7,8 @@ import manifest from '../../manifest.json';
|
||||
import { defaultModelConfigs, defaultModelConfigsMac } from './pages/defaultConfigs';
|
||||
import { t } from 'i18next';
|
||||
import { Preset } from './types/presets';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MidiMessage, MidiPort } from './types/composition';
|
||||
|
||||
export async function startup() {
|
||||
initPresets();
|
||||
@ -26,6 +28,7 @@ export async function startup() {
|
||||
initLocalModelsNotify();
|
||||
initLoraModels();
|
||||
initHardwareMonitor();
|
||||
initMidi();
|
||||
}
|
||||
|
||||
await initConfig();
|
||||
@ -134,3 +137,15 @@ async function initHardwareMonitor() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initMidi() {
|
||||
EventsOn('midiError', (data: string) => {
|
||||
toast('MIDI Error: ' + data, { type: 'error' });
|
||||
});
|
||||
EventsOn('midiPorts', (data: MidiPort[]) => {
|
||||
commonStore.setMidiPorts(data);
|
||||
});
|
||||
EventsOn('midiMessage', async (data: MidiMessage) => {
|
||||
(await import('./pages/AudiotrackManager/AudiotrackEditor')).midiMessageHandler(data);
|
||||
});
|
||||
}
|
||||
|
@ -9,7 +9,14 @@ import { Preset } from '../types/presets';
|
||||
import { AboutContent } from '../types/about';
|
||||
import { Attachment, ChatParams, Conversation } from '../types/chat';
|
||||
import { CompletionPreset } from '../types/completion';
|
||||
import { CompositionParams, Track } from '../types/composition';
|
||||
import {
|
||||
CompositionParams,
|
||||
InstrumentType,
|
||||
MidiMessage,
|
||||
MidiPort,
|
||||
Track,
|
||||
tracksMinimalTotalTime
|
||||
} from '../types/composition';
|
||||
import { ModelConfig } from '../types/configs';
|
||||
import { DownloadStatus } from '../types/downloads';
|
||||
import { IntroductionContent } from '../types/home';
|
||||
@ -90,11 +97,19 @@ class CommonStore {
|
||||
};
|
||||
compositionGenerating: boolean = false;
|
||||
compositionSubmittedPrompt: string = defaultCompositionPrompt;
|
||||
// composition midi device
|
||||
midiPorts: MidiPort[] = [];
|
||||
activeMidiDeviceIndex: number = -1;
|
||||
instrumentType: InstrumentType = InstrumentType.Piano;
|
||||
// composition tracks
|
||||
tracks: Track[] = [];
|
||||
trackScale: number = 1;
|
||||
trackTotalTime: number = 5000;
|
||||
trackTotalTime: number = tracksMinimalTotalTime;
|
||||
trackCurrentTime: number = 0;
|
||||
trackPlayStartTime: number = 0;
|
||||
recordingTrackId: string = '';
|
||||
recordingContent: string = ''; // used to improve performance, and I'm too lazy to maintain an ID dictionary for this
|
||||
recordingRawContent: MidiMessage[] = [];
|
||||
// configs
|
||||
currentModelConfigIndex: number = 0;
|
||||
modelConfigs: ModelConfig[] = [];
|
||||
@ -405,6 +420,30 @@ class CommonStore {
|
||||
setTrackPlayStartTime(value: number) {
|
||||
this.trackPlayStartTime = value;
|
||||
}
|
||||
|
||||
setMidiPorts(value: MidiPort[]) {
|
||||
this.midiPorts = value;
|
||||
}
|
||||
|
||||
setInstrumentType(value: InstrumentType) {
|
||||
this.instrumentType = value;
|
||||
}
|
||||
|
||||
setRecordingTrackId(value: string) {
|
||||
this.recordingTrackId = value;
|
||||
}
|
||||
|
||||
setActiveMidiDeviceIndex(value: number) {
|
||||
this.activeMidiDeviceIndex = value;
|
||||
}
|
||||
|
||||
setRecordingContent(value: string) {
|
||||
this.recordingContent = value;
|
||||
}
|
||||
|
||||
setRecordingRawContent(value: MidiMessage[]) {
|
||||
this.recordingRawContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
export default new CommonStore();
|
@ -1,5 +1,7 @@
|
||||
import { NoteSequence } from '@magenta/music/esm/protobuf';
|
||||
|
||||
export const tracksMinimalTotalTime = 5000;
|
||||
|
||||
export type CompositionParams = {
|
||||
prompt: string,
|
||||
maxResponseToken: number,
|
||||
@ -13,6 +15,69 @@ export type CompositionParams = {
|
||||
export type Track = {
|
||||
id: string;
|
||||
content: string;
|
||||
rawContent: MidiMessage[];
|
||||
offsetTime: number;
|
||||
contentTime: number;
|
||||
};
|
||||
export type MidiPort = {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type MessageType = 'NoteOff' | 'NoteOn' | 'ElapsedTime' | 'ControlChange';
|
||||
|
||||
export type MidiMessage = {
|
||||
messageType: MessageType;
|
||||
channel: number;
|
||||
note: number;
|
||||
velocity: number;
|
||||
control: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum InstrumentType {
|
||||
Piano,
|
||||
Percussion,
|
||||
Drum,
|
||||
Tuba,
|
||||
Marimba,
|
||||
Bass,
|
||||
Guitar,
|
||||
Violin,
|
||||
Trumpet,
|
||||
Sax,
|
||||
Flute,
|
||||
Lead,
|
||||
Pad,
|
||||
}
|
||||
|
||||
export const InstrumentTypeNameMap = [
|
||||
'Piano',
|
||||
'Percussion',
|
||||
'Drum',
|
||||
'Tuba',
|
||||
'Marimba',
|
||||
'Bass',
|
||||
'Guitar',
|
||||
'Violin',
|
||||
'Trumpet',
|
||||
'Sax',
|
||||
'Flute',
|
||||
'Lead',
|
||||
'Pad'
|
||||
];
|
||||
|
||||
export const InstrumentTypeTokenMap = [
|
||||
'pi',
|
||||
'p',
|
||||
'd',
|
||||
't',
|
||||
'm',
|
||||
'b',
|
||||
'g',
|
||||
'v',
|
||||
'tr',
|
||||
's',
|
||||
'f',
|
||||
'l',
|
||||
'pa'
|
||||
];
|
||||
|
@ -22,6 +22,7 @@ import { DownloadStatus } from '../types/downloads';
|
||||
import { ModelSourceItem } from '../types/models';
|
||||
import { Language, Languages, SettingsType } from '../types/settings';
|
||||
import { DataProcessParameters, LoraFinetuneParameters } from '../types/train';
|
||||
import { tracksMinimalTotalTime } from '../types/composition';
|
||||
|
||||
export type Cache = {
|
||||
version: string
|
||||
@ -480,6 +481,36 @@ export function getHfDownloadUrl(url: string) {
|
||||
return url;
|
||||
}
|
||||
|
||||
export function refreshTracksTotalTime() {
|
||||
const endTimes = commonStore.tracks.map(t => t.offsetTime + t.contentTime);
|
||||
const totalTime = Math.max(...endTimes) + tracksMinimalTotalTime;
|
||||
if (commonStore.trackPlayStartTime > totalTime)
|
||||
commonStore.setTrackPlayStartTime(totalTime);
|
||||
commonStore.setTrackTotalTime(totalTime);
|
||||
}
|
||||
|
||||
export function flushMidiRecordingContent() {
|
||||
const recordingTrackIndex = commonStore.tracks.findIndex(t => t.id === commonStore.recordingTrackId);
|
||||
if (recordingTrackIndex >= 0) {
|
||||
const recordingTrack = commonStore.tracks[recordingTrackIndex];
|
||||
const tracks = commonStore.tracks.slice();
|
||||
const contentTime = commonStore.recordingRawContent
|
||||
.reduce((sum, current) =>
|
||||
sum + (current.messageType === 'ElapsedTime' ? current.value : 0)
|
||||
, 0);
|
||||
tracks[recordingTrackIndex] = {
|
||||
...recordingTrack,
|
||||
content: commonStore.recordingContent,
|
||||
rawContent: commonStore.recordingRawContent,
|
||||
contentTime: contentTime
|
||||
};
|
||||
commonStore.setTracks(tracks);
|
||||
refreshTracksTotalTime();
|
||||
}
|
||||
commonStore.setRecordingContent('');
|
||||
commonStore.setRecordingRawContent([]);
|
||||
}
|
||||
|
||||
export function getSupportedCustomCudaFile(isBeta: boolean) {
|
||||
if ([' 10', ' 16', ' 20', ' 30', 'MX', 'Tesla P', 'Quadro P', 'NVIDIA P', 'TITAN X', 'TITAN RTX', 'RTX A',
|
||||
'Quadro RTX 4000', 'Quadro RTX 5000', 'Tesla T4', 'NVIDIA A10', 'NVIDIA A40'].some(v => commonStore.status.device_name.includes(v)))
|
||||
|
4
frontend/wailsjs/go/backend_golang/App.d.ts
generated
vendored
4
frontend/wailsjs/go/backend_golang/App.d.ts
generated
vendored
@ -4,6 +4,8 @@ import {backend_golang} from '../models';
|
||||
|
||||
export function AddToDownloadList(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function CloseMidiPort():Promise<void>;
|
||||
|
||||
export function ContinueDownload(arg1:string):Promise<void>;
|
||||
|
||||
export function ConvertData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||
@ -36,6 +38,8 @@ export function MergeLora(arg1:string,arg2:boolean,arg3:number,arg4:string,arg5:
|
||||
|
||||
export function OpenFileFolder(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function OpenMidiPort(arg1:number):Promise<void>;
|
||||
|
||||
export function OpenOpenFileDialog(arg1:string):Promise<string>;
|
||||
|
||||
export function OpenSaveFileDialog(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||
|
8
frontend/wailsjs/go/backend_golang/App.js
generated
8
frontend/wailsjs/go/backend_golang/App.js
generated
@ -6,6 +6,10 @@ export function AddToDownloadList(arg1, arg2) {
|
||||
return window['go']['backend_golang']['App']['AddToDownloadList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CloseMidiPort() {
|
||||
return window['go']['backend_golang']['App']['CloseMidiPort']();
|
||||
}
|
||||
|
||||
export function ContinueDownload(arg1) {
|
||||
return window['go']['backend_golang']['App']['ContinueDownload'](arg1);
|
||||
}
|
||||
@ -70,6 +74,10 @@ export function OpenFileFolder(arg1, arg2) {
|
||||
return window['go']['backend_golang']['App']['OpenFileFolder'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenMidiPort(arg1) {
|
||||
return window['go']['backend_golang']['App']['OpenMidiPort'](arg1);
|
||||
}
|
||||
|
||||
export function OpenOpenFileDialog(arg1) {
|
||||
return window['go']['backend_golang']['App']['OpenOpenFileDialog'](arg1);
|
||||
}
|
||||
|
1
go.mod
1
go.mod
@ -5,6 +5,7 @@ go 1.20
|
||||
require (
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/mattrtaylor/go-rtmidi v0.0.0-20220428034745-af795b1c1a79
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/nyaosorg/go-windows-su v0.2.1
|
||||
github.com/ubuntu/gowsl v0.0.0-20230615094051-94945650cc1e
|
||||
|
2
go.sum
2
go.sum
@ -38,6 +38,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattrtaylor/go-rtmidi v0.0.0-20220428034745-af795b1c1a79 h1:CA1UHN3RuY70DlC0RlvgtB1e8h3kYzmvK7s8CFe+Ohw=
|
||||
github.com/mattrtaylor/go-rtmidi v0.0.0-20220428034745-af795b1c1a79/go.mod h1:oBuZjmjlKSj9CZKrNhcx/adNhHiiE0hZknECjIP8Z0Q=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/nyaosorg/go-windows-su v0.2.1 h1:5V0XavLyjOqPUp7psxxCvBISaneU4XmFPSMlejSl5sc=
|
||||
|
Loading…
Reference in New Issue
Block a user