mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
set default tab to help. add beep keys.
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -28,6 +28,7 @@ type inboundMessage struct {
|
|||||||
Servo *servoPayload `json:"servo,omitempty"`
|
Servo *servoPayload `json:"servo,omitempty"`
|
||||||
TTS *ttsPayload `json:"tts,omitempty"`
|
TTS *ttsPayload `json:"tts,omitempty"`
|
||||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
|
Song *songPayload `json:"song,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -67,6 +68,17 @@ type nightVisionPayload struct {
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type songPayload struct {
|
||||||
|
Slot *int `json:"slot,omitempty"`
|
||||||
|
Notes []songNote `json:"notes"`
|
||||||
|
Loop bool `json:"loop,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type songNote struct {
|
||||||
|
Note int `json:"note"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
type ackMessage struct {
|
type ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -95,3 +95,26 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|||||||
func (s *SerialAdapter) SeekDock() error {
|
func (s *SerialAdapter) SeekDock() error {
|
||||||
return s.write([]byte{143})
|
return s.write([]byte{143})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||||
|
if len(notes) == 0 {
|
||||||
|
return fmt.Errorf("song requires at least one note")
|
||||||
|
}
|
||||||
|
if len(notes) > 16 {
|
||||||
|
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
||||||
|
}
|
||||||
|
if slot < 0 || slot > 4 {
|
||||||
|
return fmt.Errorf("song slot must be 0-4")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte{140, byte(slot), byte(len(notes))}
|
||||||
|
for _, n := range notes {
|
||||||
|
note := clampInt(n.Note, 31, 127)
|
||||||
|
duration := clampInt(n.Duration, 1, 255)
|
||||||
|
payload = append(payload, byte(note), byte(duration))
|
||||||
|
}
|
||||||
|
if err := s.write(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.write([]byte{141, byte(slot)})
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,3 +56,8 @@ func (s *SerialAdapter) SeekDock() error {
|
|||||||
s.log.Printf("[dummy] seek dock")
|
s.log.Printf("[dummy] seek dock")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||||
|
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return fmt.Errorf("night vision disabled")
|
return fmt.Errorf("night vision disabled")
|
||||||
}
|
}
|
||||||
return c.nightVision.HandleAction(msg.NightVision.Action)
|
return c.nightVision.HandleAction(msg.NightVision.Action)
|
||||||
|
case msg.Song != nil:
|
||||||
|
slot := 0
|
||||||
|
if msg.Song.Slot != nil {
|
||||||
|
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||||
|
}
|
||||||
|
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-6QuSGuDC.js"></script>
|
<script type="module" crossorigin src="/assets/index-CjsBr3wX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DusXB4R9.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BrXzdi-k.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||||
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import VideoTile from './VideoTile.jsx';
|
import VideoTile from './VideoTile.jsx';
|
||||||
|
|
||||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
|
const {
|
||||||
|
state: { song },
|
||||||
|
} = useControlSystem();
|
||||||
const roverId = session?.assignment?.roverId;
|
const roverId = session?.assignment?.roverId;
|
||||||
const rosterEntry =
|
const rosterEntry =
|
||||||
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||||
@@ -37,6 +41,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={batteryConfig}
|
batteryConfig={batteryConfig}
|
||||||
layoutFormat={layoutFormat}
|
layoutFormat={layoutFormat}
|
||||||
|
songNote={song?.note}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const KEY_ACTIONS = [
|
|||||||
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
||||||
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||||
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
||||||
|
{ id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' },
|
||||||
|
{ id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function groupActions(actions) {
|
function groupActions(actions) {
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
|
|||||||
const RESTART_DELAY_MS = 2000;
|
const RESTART_DELAY_MS = 2000;
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
const UNMUTE_RETRY_MS = 3000;
|
||||||
|
|
||||||
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
|
|
||||||
|
function formatNoteLabel(note) {
|
||||||
|
if (typeof note !== 'number' || !Number.isFinite(note)) return '--';
|
||||||
|
const name = NOTE_NAMES[note % 12] || '?';
|
||||||
|
const octave = Math.floor(note / 12) - 1;
|
||||||
|
return `${name}${octave}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildBatteryVisual(charge, config) {
|
function buildBatteryVisual(charge, config) {
|
||||||
const full = config?.Full;
|
const full = config?.Full;
|
||||||
const warn = config?.Warn;
|
const warn = config?.Warn;
|
||||||
@@ -40,6 +49,7 @@ export default function VideoTile({
|
|||||||
layoutFormat = 'desktop',
|
layoutFormat = 'desktop',
|
||||||
hudVariant = 'default',
|
hudVariant = 'default',
|
||||||
driverLabel = null,
|
driverLabel = null,
|
||||||
|
songNote = null,
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
@@ -265,6 +275,7 @@ export default function VideoTile({
|
|||||||
variant={hudVariant}
|
variant={hudVariant}
|
||||||
driverLabel={driverLabel}
|
driverLabel={driverLabel}
|
||||||
battery={batteryVisual}
|
battery={batteryVisual}
|
||||||
|
songNote={songNote}
|
||||||
/>
|
/>
|
||||||
<OvercurrentOverlay motors={overcurrentMotors} />
|
<OvercurrentOverlay motors={overcurrentMotors} />
|
||||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
|
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
|
||||||
@@ -325,6 +336,7 @@ function HudOverlay({
|
|||||||
variant = 'default',
|
variant = 'default',
|
||||||
driverLabel = null,
|
driverLabel = null,
|
||||||
battery,
|
battery,
|
||||||
|
songNote = null,
|
||||||
}) {
|
}) {
|
||||||
const sensors = frame?.sensors;
|
const sensors = frame?.sensors;
|
||||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||||
@@ -350,6 +362,11 @@ function HudOverlay({
|
|||||||
<span>Status: {status}</span>
|
<span>Status: {status}</span>
|
||||||
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{songNote != null ? (
|
||||||
|
<div className="absolute right-1 top-1 rounded bg-black/70 px-1 py-0.25 text-[0.65rem] font-semibold text-emerald-200">
|
||||||
|
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="absolute left-1 top-1/2 flex -translate-y-1/2 flex-col gap-0.25 bg-black/70 px-1 py-0.75 text-[0.65rem] text-slate-100">
|
<div className="absolute left-1 top-1/2 flex -translate-y-1/2 flex-col gap-0.25 bg-black/70 px-1 py-0.75 text-[0.65rem] text-slate-100">
|
||||||
<span className="text-[0.6rem] uppercase tracking-wide text-slate-400">Telemetry</span>
|
<span className="text-[0.6rem] uppercase tracking-wide text-slate-400">Telemetry</span>
|
||||||
@@ -382,6 +399,11 @@ function HudOverlay({
|
|||||||
<span>Status: {status}</span>
|
<span>Status: {status}</span>
|
||||||
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{songNote != null ? (
|
||||||
|
<div className="absolute right-1 top-1 rounded bg-black/70 px-1 py-0.25 text-[0.65rem] font-semibold text-emerald-200">
|
||||||
|
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
|
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
|
||||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
||||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
|
|||||||
import { controlReducer, initialControlState } from './controlReducer.js';
|
import { controlReducer, initialControlState } from './controlReducer.js';
|
||||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||||
import { useCommandPipeline } from './commandPipeline.js';
|
import { useCommandPipeline } from './commandPipeline.js';
|
||||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
@@ -258,6 +258,22 @@ export function ControlSystemProvider({ children }) {
|
|||||||
pipeline.sendNightVision('toggle');
|
pipeline.sendNightVision('toggle');
|
||||||
}, [pipeline]);
|
}, [pipeline]);
|
||||||
|
|
||||||
|
const setSongNote = useCallback(
|
||||||
|
(note) => {
|
||||||
|
const next = typeof note === 'number' ? note : SONG_DEFAULT_NOTE;
|
||||||
|
dispatch({ type: 'control/set-song-note', payload: next });
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendSong = useCallback(
|
||||||
|
(notes, options) => {
|
||||||
|
return pipeline.sendSong(notes, options);
|
||||||
|
},
|
||||||
|
[pipeline],
|
||||||
|
);
|
||||||
|
|
||||||
const registerInputState = useCallback((source, data) => {
|
const registerInputState = useCallback((source, data) => {
|
||||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -282,6 +298,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
setSongNote,
|
||||||
|
sendSong,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -301,6 +319,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
setSongNote,
|
||||||
|
sendSong,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useSocket } from '../context/SocketContext.jsx';
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { AUX_LIMITS, COMMAND_DELAY_MS, OI_COMMANDS } from './constants.js';
|
import {
|
||||||
|
AUX_LIMITS,
|
||||||
|
COMMAND_DELAY_MS,
|
||||||
|
OI_COMMANDS,
|
||||||
|
SONG_DEFAULT_DURATION,
|
||||||
|
SONG_DEFAULT_NOTE,
|
||||||
|
SONG_NOTE_RANGE,
|
||||||
|
} from './constants.js';
|
||||||
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
||||||
|
|
||||||
export function useCommandPipeline() {
|
export function useCommandPipeline() {
|
||||||
@@ -151,6 +158,32 @@ export function useCommandPipeline() {
|
|||||||
[emitCommand, nightVision, roverId],
|
[emitCommand, nightVision, roverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendSong = useCallback(
|
||||||
|
(notes = [], options = {}) => {
|
||||||
|
if (!roverId) return null;
|
||||||
|
const prepared =
|
||||||
|
Array.isArray(notes) && notes.length > 0
|
||||||
|
? notes
|
||||||
|
: [{ note: SONG_DEFAULT_NOTE, duration: SONG_DEFAULT_DURATION }];
|
||||||
|
const payloadNotes = prepared.slice(0, 16).map((entry) => ({
|
||||||
|
note: clampRange(Math.round(entry?.note ?? SONG_DEFAULT_NOTE), SONG_NOTE_RANGE),
|
||||||
|
duration: clampRange(Math.round(entry?.duration ?? SONG_DEFAULT_DURATION), [1, 255]),
|
||||||
|
}));
|
||||||
|
emitCommand({
|
||||||
|
type: 'song',
|
||||||
|
data: {
|
||||||
|
song: {
|
||||||
|
notes: payloadNotes,
|
||||||
|
slot: options.slot,
|
||||||
|
loop: options.loop,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return payloadNotes;
|
||||||
|
},
|
||||||
|
[emitCommand, roverId],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
roverId,
|
roverId,
|
||||||
@@ -164,6 +197,7 @@ export function useCommandPipeline() {
|
|||||||
sendServoAngle,
|
sendServoAngle,
|
||||||
sendOiCommand,
|
sendOiCommand,
|
||||||
sendNightVision,
|
sendNightVision,
|
||||||
|
sendSong,
|
||||||
runMacroSteps,
|
runMacroSteps,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ export const DRIVE_LIMITS = {
|
|||||||
boostSpeed: 400,
|
boostSpeed: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COMMAND_DELAY_MS = 200;
|
export const COMMAND_DELAY_MS = 100;
|
||||||
|
|
||||||
|
export const SONG_NOTE_RANGE = [31, 127];
|
||||||
|
export const SONG_DEFAULT_NOTE = 60;
|
||||||
|
export const SONG_DEFAULT_DURATION = 32;
|
||||||
|
export const SONG_REPEAT_MS = 150;
|
||||||
|
|
||||||
export const OI_COMMANDS = {
|
export const OI_COMMANDS = {
|
||||||
start: [128],
|
start: [128],
|
||||||
@@ -40,6 +45,8 @@ export const DEFAULT_KEYMAP = {
|
|||||||
driveMacro: ['f'],
|
driveMacro: ['f'],
|
||||||
dockMacro: ['g'],
|
dockMacro: ['g'],
|
||||||
chatFocus: ['enter'],
|
chatFocus: ['enter'],
|
||||||
|
songNoteUp: ['ArrowUp'],
|
||||||
|
songNoteDown: ['ArrowDown'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MACROS = [
|
export const DEFAULT_MACROS = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||||
|
|
||||||
function createDriveState() {
|
function createDriveState() {
|
||||||
return {
|
return {
|
||||||
@@ -21,12 +21,19 @@ function createCameraState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSongState() {
|
||||||
|
return {
|
||||||
|
note: SONG_DEFAULT_NOTE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const initialControlState = {
|
export const initialControlState = {
|
||||||
roverId: null,
|
roverId: null,
|
||||||
mode: 'drive',
|
mode: 'drive',
|
||||||
drive: createDriveState(),
|
drive: createDriveState(),
|
||||||
aux: createAuxState(),
|
aux: createAuxState(),
|
||||||
camera: createCameraState(),
|
camera: createCameraState(),
|
||||||
|
song: createSongState(),
|
||||||
macros: DEFAULT_MACROS,
|
macros: DEFAULT_MACROS,
|
||||||
keymap: DEFAULT_KEYMAP,
|
keymap: DEFAULT_KEYMAP,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
@@ -40,6 +47,7 @@ export function controlReducer(state, action) {
|
|||||||
roverId: action.payload ?? null,
|
roverId: action.payload ?? null,
|
||||||
drive: action.payload ? state.drive : createDriveState(),
|
drive: action.payload ? state.drive : createDriveState(),
|
||||||
aux: action.payload ? state.aux : createAuxState(),
|
aux: action.payload ? state.aux : createAuxState(),
|
||||||
|
song: action.payload ? state.song : createSongState(),
|
||||||
};
|
};
|
||||||
case 'control/set-mode':
|
case 'control/set-mode':
|
||||||
return state.mode === action.payload
|
return state.mode === action.payload
|
||||||
@@ -114,6 +122,15 @@ export function controlReducer(state, action) {
|
|||||||
...state,
|
...state,
|
||||||
drive: createDriveState(),
|
drive: createDriveState(),
|
||||||
aux: createAuxState(),
|
aux: createAuxState(),
|
||||||
|
song: createSongState(),
|
||||||
|
};
|
||||||
|
case 'control/set-song-note':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
song: {
|
||||||
|
...(state.song || createSongState()),
|
||||||
|
note: action.payload ?? SONG_DEFAULT_NOTE,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ import { useChat } from '../../context/ChatContext.jsx';
|
|||||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
|
import {
|
||||||
|
SONG_DEFAULT_DURATION,
|
||||||
|
SONG_DEFAULT_NOTE,
|
||||||
|
SONG_NOTE_RANGE,
|
||||||
|
SONG_REPEAT_MS,
|
||||||
|
} from '../constants.js';
|
||||||
|
|
||||||
const SOURCE = 'keyboard';
|
const SOURCE = 'keyboard';
|
||||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||||
const SERVO_REPEAT_MS = 110;
|
const SERVO_REPEAT_MS = 110;
|
||||||
|
const NOTE_MIN = SONG_NOTE_RANGE[0];
|
||||||
|
const NOTE_MAX = SONG_NOTE_RANGE[1];
|
||||||
|
|
||||||
function clampSpeed(value, fallback) {
|
function clampSpeed(value, fallback) {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
@@ -95,6 +103,8 @@ export default function KeyboardInputManager() {
|
|||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
toggleNightVision,
|
toggleNightVision,
|
||||||
|
setSongNote,
|
||||||
|
sendSong,
|
||||||
},
|
},
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const { focusChat, blurChat, isChatFocused } = useChat();
|
const { focusChat, blurChat, isChatFocused } = useChat();
|
||||||
@@ -126,6 +136,7 @@ export default function KeyboardInputManager() {
|
|||||||
const lastVectorRef = useRef(ZERO_VECTOR);
|
const lastVectorRef = useRef(ZERO_VECTOR);
|
||||||
const lastAuxRef = useRef(ZERO_AUX);
|
const lastAuxRef = useRef(ZERO_AUX);
|
||||||
const servoIntervalRef = useRef(null);
|
const servoIntervalRef = useRef(null);
|
||||||
|
const songIntervalRef = useRef(null);
|
||||||
|
|
||||||
const driveFromKeys = useCallback(() => {
|
const driveFromKeys = useCallback(() => {
|
||||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||||
@@ -194,14 +205,66 @@ export default function KeyboardInputManager() {
|
|||||||
servoIntervalRef.current = setTimeout(tick, 0);
|
servoIntervalRef.current = setTimeout(tick, 0);
|
||||||
}, [computeServoDirection, nudgeServo, servoStep, stopServoLoop]);
|
}, [computeServoDirection, nudgeServo, servoStep, stopServoLoop]);
|
||||||
|
|
||||||
|
const stopSongLoop = useCallback(() => {
|
||||||
|
if (songIntervalRef.current) {
|
||||||
|
clearTimeout(songIntervalRef.current);
|
||||||
|
songIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const computeSongDirection = useCallback(() => {
|
||||||
|
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||||
|
const up = bindingActive(keymap.songNoteUp, tokensSnapshot);
|
||||||
|
const down = bindingActive(keymap.songNoteDown, tokensSnapshot);
|
||||||
|
return (up ? 1 : 0) - (down ? 1 : 0);
|
||||||
|
}, [keymap]);
|
||||||
|
|
||||||
|
const triggerSongChange = useCallback(
|
||||||
|
(direction) => {
|
||||||
|
if (direction === 0) return;
|
||||||
|
const current = typeof state.song?.note === 'number' ? state.song.note : SONG_DEFAULT_NOTE;
|
||||||
|
let next = current + direction;
|
||||||
|
if (next > NOTE_MAX) {
|
||||||
|
next = NOTE_MIN;
|
||||||
|
} else if (next < NOTE_MIN) {
|
||||||
|
next = NOTE_MAX;
|
||||||
|
}
|
||||||
|
const finalNote = setSongNote(next);
|
||||||
|
sendSong([{ note: finalNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
|
||||||
|
},
|
||||||
|
[sendSong, setSongNote, state.song?.note],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ensureSongLoop = useCallback(() => {
|
||||||
|
const direction = computeSongDirection();
|
||||||
|
if (direction === 0) {
|
||||||
|
stopSongLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (songIntervalRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
const nextDirection = computeSongDirection();
|
||||||
|
if (nextDirection === 0) {
|
||||||
|
stopSongLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerSongChange(nextDirection);
|
||||||
|
songIntervalRef.current = setTimeout(tick, SONG_REPEAT_MS);
|
||||||
|
};
|
||||||
|
songIntervalRef.current = setTimeout(tick, 0);
|
||||||
|
}, [computeSongDirection, stopSongLoop, triggerSongChange]);
|
||||||
|
|
||||||
const resetAll = useCallback(() => {
|
const resetAll = useCallback(() => {
|
||||||
activeTokensRef.current.clear();
|
activeTokensRef.current.clear();
|
||||||
lastVectorRef.current = ZERO_VECTOR;
|
lastVectorRef.current = ZERO_VECTOR;
|
||||||
lastAuxRef.current = ZERO_AUX;
|
lastAuxRef.current = ZERO_AUX;
|
||||||
stopServoLoop();
|
stopServoLoop();
|
||||||
|
stopSongLoop();
|
||||||
stopAllMotion();
|
stopAllMotion();
|
||||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||||
}, [registerInputState, stopAllMotion, stopServoLoop]);
|
}, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event) {
|
||||||
@@ -238,6 +301,7 @@ export default function KeyboardInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureServoLoop();
|
ensureServoLoop();
|
||||||
|
ensureSongLoop();
|
||||||
driveFromKeys();
|
driveFromKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +309,7 @@ export default function KeyboardInputManager() {
|
|||||||
const tokens = tokensForEvent(event);
|
const tokens = tokensForEvent(event);
|
||||||
tokens.forEach((token) => activeTokensRef.current.delete(token));
|
tokens.forEach((token) => activeTokensRef.current.delete(token));
|
||||||
ensureServoLoop();
|
ensureServoLoop();
|
||||||
|
ensureSongLoop();
|
||||||
driveFromKeys();
|
driveFromKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +330,7 @@ export default function KeyboardInputManager() {
|
|||||||
blurChat,
|
blurChat,
|
||||||
driveFromKeys,
|
driveFromKeys,
|
||||||
ensureServoLoop,
|
ensureServoLoop,
|
||||||
|
ensureSongLoop,
|
||||||
focusChat,
|
focusChat,
|
||||||
isChatFocused,
|
isChatFocused,
|
||||||
keymap.chatFocus,
|
keymap.chatFocus,
|
||||||
@@ -274,6 +340,7 @@ export default function KeyboardInputManager() {
|
|||||||
runMacro,
|
runMacro,
|
||||||
setMode,
|
setMode,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
|
stopSongLoop,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const latestResetAllRef = useRef(resetAll);
|
const latestResetAllRef = useRef(resetAll);
|
||||||
@@ -288,8 +355,9 @@ export default function KeyboardInputManager() {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
stopServoLoop();
|
stopServoLoop();
|
||||||
|
stopSongLoop();
|
||||||
},
|
},
|
||||||
[stopServoLoop],
|
[stopServoLoop, stopSongLoop],
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -92,5 +92,9 @@ export function formatKeyLabel(value) {
|
|||||||
if (canonical === '/') return '/ or ?';
|
if (canonical === '/') return '/ or ?';
|
||||||
if (canonical === '-') return '- or _';
|
if (canonical === '-') return '- or _';
|
||||||
if (canonical === '=') return '= or +';
|
if (canonical === '=') return '= or +';
|
||||||
|
if (canonical === 'arrowup') return 'Arrow Up';
|
||||||
|
if (canonical === 'arrowdown') return 'Arrow Down';
|
||||||
|
if (canonical === 'arrowleft') return 'Arrow Left';
|
||||||
|
if (canonical === 'arrowright') return 'Arrow Right';
|
||||||
return canonical.length === 1 ? canonical.toUpperCase() : canonical;
|
return canonical.length === 1 ? canonical.toUpperCase() : canonical;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export const HELP_CONTENT = {
|
|||||||
{ action: 'auxAllForward', label: 'All motors forward' },
|
{ action: 'auxAllForward', label: 'All motors forward' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'audio',
|
||||||
|
title: 'Audio / Song',
|
||||||
|
items: [
|
||||||
|
{ action: 'songNoteUp', label: 'Song note up' },
|
||||||
|
{ action: 'songNoteDown', label: 'Song note down' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user