fix audio player stuff

This commit is contained in:
legop3
2025-12-22 19:47:48 -05:00
parent 020ddfea07
commit 01df853592
7 changed files with 143 additions and 17 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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>
+87 -2
View File
@@ -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'