From 0e888d62af1434fef231e11a5c307a5b48a8deb1 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 10:49:07 +0100
Subject: [PATCH] feat: singleton audio, transcript reflection, voice persistence
---
services/audio.ts | 104 +++++++++++++++++++++++++++++++++++++++------------
1 files changed, 79 insertions(+), 25 deletions(-)
diff --git a/services/audio.ts b/services/audio.ts
index 5fa8bd5..ea43236 100644
--- a/services/audio.ts
+++ b/services/audio.ts
@@ -10,20 +10,34 @@
durationMs: number;
}
+// --- Singleton audio player ---
+// Only ONE audio can play at a time. Any new play request stops the current one.
+
let currentPlayer: ReturnType<typeof createAudioPlayer> | null = null;
-const playingListeners = new Set<(playing: boolean) => void>();
+let currentUri: string | null = null;
+let cancelCurrent: (() => void) | null = null;
-// Audio queue for chaining sequential voice notes
-const audioQueue: Array<{ uri: string; onFinish?: () => void }> = [];
-let processingQueue = false;
+// Listeners get the URI of what's playing (or null when stopped)
+const playingListeners = new Set<(uri: string | null) => void>();
-function notifyListeners(playing: boolean): void {
- for (const cb of playingListeners) cb(playing);
+function notifyListeners(uri: string | null): void {
+ currentUri = uri;
+ for (const cb of playingListeners) cb(uri);
}
-export function onPlayingChange(cb: (playing: boolean) => void): () => void {
+/** Subscribe to playing state changes. Returns unsubscribe function. */
+export function onPlayingChange(cb: (uri: string | null) => void): () => void {
playingListeners.add(cb);
return () => { playingListeners.delete(cb); };
+}
+
+/** Get the URI currently playing, or null. */
+export function playingUri(): string | null {
+ return currentUri;
+}
+
+export function isPlaying(): boolean {
+ return currentPlayer !== null;
}
export async function requestPermissions(): Promise<boolean> {
@@ -44,9 +58,13 @@
return tmpPath;
}
+// --- Audio queue for chaining sequential voice notes (autoplay) ---
+const audioQueue: Array<{ uri: string; onFinish?: () => void }> = [];
+let processingQueue = false;
+
/**
- * Queue audio for playback. Multiple calls chain sequentially —
- * the next voice note plays only after the current one finishes.
+ * Play audio. Stops any current playback first (singleton).
+ * Multiple calls chain sequentially via queue (for chunked voice notes).
*/
export async function playAudio(
uri: string,
@@ -56,6 +74,18 @@
if (!processingQueue) {
processAudioQueue();
}
+}
+
+/**
+ * Play a single audio file, stopping any current playback first.
+ * Does NOT queue — immediately replaces whatever is playing.
+ */
+export async function playSingle(
+ uri: string,
+ onFinish?: () => void
+): Promise<void> {
+ await stopPlayback();
+ await playOneAudio(uri, onFinish);
}
async function processAudioQueue(): Promise<void> {
@@ -72,35 +102,57 @@
function playOneAudio(uri: string, onFinish?: () => void): Promise<void> {
return new Promise<void>(async (resolve) => {
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ cancelCurrent = null;
+ clearTimeout(timer);
+ onFinish?.();
+ try { player?.pause(); } catch { /* ignore */ }
+ try { player?.remove(); } catch { /* ignore */ }
+ if (currentPlayer === player) {
+ currentPlayer = null;
+ notifyListeners(null);
+ }
+ resolve();
+ };
+
+ // Stop any currently playing audio first
+ if (cancelCurrent) {
+ cancelCurrent();
+ }
+
+ // Register cancel callback so stopPlayback can abort us
+ cancelCurrent = finish;
+
+ // Safety timeout
+ const timer = setTimeout(finish, 5 * 60 * 1000);
+ let player: ReturnType<typeof createAudioPlayer> | null = null;
+
try {
await setAudioModeAsync({ playsInSilentMode: true });
- const player = createAudioPlayer(uri);
+ player = createAudioPlayer(uri);
currentPlayer = player;
- notifyListeners(true);
+ notifyListeners(uri);
player.addListener("playbackStatusUpdate", (status) => {
- if (!status.playing && status.currentTime >= status.duration && status.duration > 0) {
- onFinish?.();
- player.remove();
- if (currentPlayer === player) {
- currentPlayer = null;
- if (audioQueue.length === 0) notifyListeners(false);
- }
- resolve();
+ if (!status.playing && status.currentTime > 0 &&
+ (status.duration <= 0 || status.currentTime >= status.duration)) {
+ finish();
}
});
player.play();
} catch (error) {
console.error("Failed to play audio:", error);
+ settled = true;
+ cancelCurrent = null;
+ clearTimeout(timer);
resolve();
}
});
-}
-
-export function isPlaying(): boolean {
- return currentPlayer !== null;
}
/**
@@ -108,7 +160,9 @@
*/
export async function stopPlayback(): Promise<void> {
audioQueue.length = 0;
- if (currentPlayer) {
+ if (cancelCurrent) {
+ cancelCurrent();
+ } else if (currentPlayer) {
try {
currentPlayer.pause();
currentPlayer.remove();
@@ -116,7 +170,7 @@
// Ignore cleanup errors
}
currentPlayer = null;
- notifyListeners(false);
+ notifyListeners(null);
}
}
--
Gitblit v1.3.1