mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
fix audio player stuff
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+4
-4
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return fmt.Errorf("tts payload required")
|
return fmt.Errorf("tts payload required")
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
|||||||
}
|
}
|
||||||
pitch = clampInt(pitch, 0, 99)
|
pitch = clampInt(pitch, 0, 99)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
@@ -55,14 +55,14 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
|||||||
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
||||||
}
|
}
|
||||||
args = append(args, text)
|
args = append(args, text)
|
||||||
cmd = exec.CommandContext(ctx, "espeak", args...)
|
cmd = exec.CommandContext(runCtx, "espeak", args...)
|
||||||
case "flite", "f":
|
case "flite", "f":
|
||||||
args := []string{}
|
args := []string{}
|
||||||
if voice != "" {
|
if voice != "" {
|
||||||
args = append(args, "-voice", voice)
|
args = append(args, "-voice", voice)
|
||||||
}
|
}
|
||||||
args = append(args, "-t", text)
|
args = append(args, "-t", text)
|
||||||
cmd = exec.CommandContext(ctx, "flite", args...)
|
cmd = exec.CommandContext(runCtx, "flite", args...)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported tts engine: %s", engine)
|
return fmt.Errorf("unsupported tts engine: %s", engine)
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-1
@@ -23,9 +23,14 @@ type WSClient struct {
|
|||||||
log *log.Logger
|
log *log.Logger
|
||||||
recoverMu sync.Mutex
|
recoverMu sync.Mutex
|
||||||
recovering bool
|
recovering bool
|
||||||
|
ttsQueue chan *ttsPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||||
|
var ttsQueue chan *ttsPayload
|
||||||
|
if cfg.Audio.TTSEnabled {
|
||||||
|
ttsQueue = make(chan *ttsPayload, 2)
|
||||||
|
}
|
||||||
return &WSClient{
|
return &WSClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
@@ -35,6 +40,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
|||||||
servo: servo,
|
servo: servo,
|
||||||
nightVision: nightVision,
|
nightVision: nightVision,
|
||||||
log: logger,
|
log: logger,
|
||||||
|
ttsQueue: ttsQueue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +59,7 @@ func (c *WSClient) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
c.startTTSWorker(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
errCh <- c.readLoop(ctx, conn)
|
errCh <- c.readLoop(ctx, conn)
|
||||||
}()
|
}()
|
||||||
@@ -155,7 +162,7 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
}
|
}
|
||||||
return c.handleServoCommand(msg.Servo)
|
return c.handleServoCommand(msg.Servo)
|
||||||
case msg.TTS != nil:
|
case msg.TTS != nil:
|
||||||
return c.handleTTSPayload(msg.TTS)
|
return c.enqueueTTS(msg.TTS)
|
||||||
case msg.NightVision != nil:
|
case msg.NightVision != nil:
|
||||||
if c.nightVision == nil {
|
if c.nightVision == nil {
|
||||||
return fmt.Errorf("night vision disabled")
|
return fmt.Errorf("night vision disabled")
|
||||||
@@ -172,6 +179,40 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
||||||
|
if c.ttsQueue == nil {
|
||||||
|
return fmt.Errorf("tts disabled")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.ttsQueue <- payload:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("tts busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) startTTSWorker(ctx context.Context) {
|
||||||
|
if c.ttsQueue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case payload := <-c.ttsQueue:
|
||||||
|
if payload == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.handleTTSPayload(ctx, payload); err != nil {
|
||||||
|
c.log.Printf("tts failed: %v", err)
|
||||||
|
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
||||||
switch {
|
switch {
|
||||||
case payload.Angle != nil:
|
case payload.Angle != nil:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<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-DPEgmdvg.js"></script>
|
<script type="module" crossorigin src="/assets/index-DkDG6KGq.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B88Ix9u6.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B88Ix9u6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
|||||||
|
|
||||||
const RESTART_DELAY_MS = 2000;
|
const RESTART_DELAY_MS = 2000;
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
const UNMUTE_RETRY_MS = 3000;
|
||||||
|
const AUDIO_RETRY_MS = 3000;
|
||||||
|
|
||||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export default function VideoTile({
|
|||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
const restartTimer = useRef(null);
|
const restartTimer = useRef(null);
|
||||||
const audioRestartTimer = useRef(null);
|
const audioRestartTimer = useRef(null);
|
||||||
|
const audioPlayInterval = useRef(null);
|
||||||
const unmuteTimer = useRef(null);
|
const unmuteTimer = useRef(null);
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
@@ -147,6 +149,7 @@ export default function VideoTile({
|
|||||||
clearTimeout(restartTimer.current);
|
clearTimeout(restartTimer.current);
|
||||||
clearTimeout(audioRestartTimer.current);
|
clearTimeout(audioRestartTimer.current);
|
||||||
clearTimeout(unmuteTimer.current);
|
clearTimeout(unmuteTimer.current);
|
||||||
|
clearInterval(audioPlayInterval.current);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -212,8 +215,16 @@ export default function VideoTile({
|
|||||||
let player;
|
let player;
|
||||||
const handleStatus = (nextStatus, info) => {
|
const handleStatus = (nextStatus, info) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setAudioStatus(nextStatus);
|
setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null));
|
||||||
setAudioDetail(info || null);
|
setAudioStatus((prev) => {
|
||||||
|
if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (nextStatus === 'new') {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return nextStatus;
|
||||||
|
});
|
||||||
if (nextStatus === 'playing') {
|
if (nextStatus === 'playing') {
|
||||||
audioRef.current?.play().catch(() => {});
|
audioRef.current?.play().catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -243,6 +254,80 @@ export default function VideoTile({
|
|||||||
};
|
};
|
||||||
}, [audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart]);
|
}, [audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart]);
|
||||||
|
|
||||||
|
// Keep nudging the audio element to play in case autoplay was blocked.
|
||||||
|
useEffect(() => {
|
||||||
|
const audioEl = audioRef.current;
|
||||||
|
if (!audioSessionInfo?.url || !audioEl) {
|
||||||
|
clearInterval(audioPlayInterval.current);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const shouldAttempt = ['connecting', 'connected', 'playing', 'paused'].includes(audioStatus);
|
||||||
|
if (!shouldAttempt) {
|
||||||
|
clearInterval(audioPlayInterval.current);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptPlay = () => {
|
||||||
|
const target = audioRef.current;
|
||||||
|
if (!target) return;
|
||||||
|
if (!target.paused && !target.ended) return;
|
||||||
|
target
|
||||||
|
.play()
|
||||||
|
.then(() => {
|
||||||
|
setAudioStatus((prev) => (prev === 'connected' ? 'playing' : prev));
|
||||||
|
setAudioDetail((prev) => (prev === 'paused' ? null : prev));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setAudioDetail((prev) => prev || err?.message || 'autoplay blocked');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptPlay();
|
||||||
|
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(audioPlayInterval.current);
|
||||||
|
}, [audioSessionInfo?.url, audioStatus]);
|
||||||
|
|
||||||
|
// Reflect audio element events back into status/detail so the HUD stays accurate.
|
||||||
|
useEffect(() => {
|
||||||
|
const audioEl = audioRef.current;
|
||||||
|
if (!audioEl) return undefined;
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setAudioStatus((prev) => (prev === 'error' ? prev : 'playing'));
|
||||||
|
setAudioDetail(null);
|
||||||
|
};
|
||||||
|
const handlePause = () => {
|
||||||
|
setAudioStatus((prev) => {
|
||||||
|
if (['error', 'failed', 'disconnected', 'closed', 'stopped'].includes(prev)) return prev;
|
||||||
|
return 'paused';
|
||||||
|
});
|
||||||
|
setAudioDetail((prev) => prev || 'paused');
|
||||||
|
};
|
||||||
|
const handleEnded = () => {
|
||||||
|
setAudioStatus((prev) => (prev === 'error' ? prev : 'stopped'));
|
||||||
|
setAudioDetail((prev) => prev || 'ended');
|
||||||
|
};
|
||||||
|
const handleError = () => {
|
||||||
|
const { error } = audioEl;
|
||||||
|
const message = error?.message || 'audio error';
|
||||||
|
setAudioStatus('error');
|
||||||
|
setAudioDetail(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
audioEl.addEventListener('play', handlePlay);
|
||||||
|
audioEl.addEventListener('pause', handlePause);
|
||||||
|
audioEl.addEventListener('ended', handleEnded);
|
||||||
|
audioEl.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audioEl.removeEventListener('play', handlePlay);
|
||||||
|
audioEl.removeEventListener('pause', handlePause);
|
||||||
|
audioEl.removeEventListener('ended', handleEnded);
|
||||||
|
audioEl.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
}, [audioSessionInfo?.url]);
|
||||||
|
|
||||||
const renderedStatus = !sessionInfo?.url
|
const renderedStatus = !sessionInfo?.url
|
||||||
? 'waiting'
|
? 'waiting'
|
||||||
: status === 'error'
|
: status === 'error'
|
||||||
|
|||||||
Reference in New Issue
Block a user