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