Deploy aea7fa1 preload summary audio

#1
by themva - opened
Files changed (2) hide show
  1. src/App.tsx +212 -38
  2. src/styles.css +12 -2
src/App.tsx CHANGED
@@ -54,6 +54,15 @@ type ResearchTour = {
54
  neighborhoodIds: string[];
55
  };
56
  type ResearchFact = NonNullable<NonNullable<AgentBackendRun["webResearch"]>["facts"]>[number];
 
 
 
 
 
 
 
 
 
57
 
58
  const initialParsed = parsePrompt(defaultPrompt);
59
  const initialRanked = rankNeighborhoods(initialParsed);
@@ -87,6 +96,9 @@ const RESEARCH_TOUR_OVERVIEW_MS = 0;
87
  // Must match TOUR_DWELL_MS in MapCanvas: the camera dwells on each area while it is researched.
88
  const RESEARCH_TOUR_VISIT_MS = 5400;
89
  const RESEARCH_TOUR_SETTLE_MS = 520;
 
 
 
90
 
91
  // Quick-start chips above the composer. They are just shortcuts — the real entry point is
92
  // whatever the user types in the box.
@@ -506,6 +518,7 @@ export default function App() {
506
  />
507
  ) : (
508
  <DetailPanel
 
509
  selected={selected}
510
  parsed={parsed}
511
  scoreT={scoreT}
@@ -701,12 +714,14 @@ function ResultRow({
701
  }
702
 
703
  function DetailPanel({
 
704
  selected,
705
  parsed,
706
  scoreT,
707
  webResearch,
708
  recommendation,
709
  }: {
 
710
  selected: RankedNeighborhood;
711
  parsed: ParsedPrompt;
712
  scoreT: number;
@@ -714,57 +729,137 @@ function DetailPanel({
714
  recommendation: AgentBackendRun["recommendation"] | null;
715
  }) {
716
  const fit = consensus(selected.overall);
717
- const [audioState, setAudioState] = useState<"idle" | "loading" | "playing">("idle");
718
  const audioRef = useRef<HTMLAudioElement | null>(null);
719
  const reqRef = useRef(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720
 
721
- const stopSpeech = useCallback(() => {
722
- reqRef.current += 1; // invalidate any in-flight synthesis
723
  if (audioRef.current) {
724
  audioRef.current.pause();
725
- if (audioRef.current.src) URL.revokeObjectURL(audioRef.current.src);
726
  audioRef.current = null;
727
  }
728
  if (typeof window !== "undefined" && window.speechSynthesis) window.speechSynthesis.cancel();
729
- setAudioState("idle");
730
  }, []);
731
 
732
- // Stop narration when the neighbourhood changes or the panel unmounts.
733
- useEffect(() => stopSpeech, [selected.id, stopSpeech]);
 
 
 
 
 
 
 
734
 
735
- const togglePlay = async () => {
736
- if (audioState !== "idle") {
737
- stopSpeech();
738
  return;
739
  }
740
- const text = buildSpokenSummary(selected, recommendation, webResearch);
 
 
 
 
741
  const req = (reqRef.current += 1);
742
- setAudioState("loading");
743
- try {
744
- const url = await synthesizeKokoro(text);
745
- if (reqRef.current !== req) {
746
- URL.revokeObjectURL(url);
747
- return;
748
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
  const element = new Audio(url);
750
  audioRef.current = element;
751
  element.onended = () => {
752
- URL.revokeObjectURL(url);
753
- if (reqRef.current === req) setAudioState("idle");
 
 
754
  };
755
- await element.play();
756
- if (reqRef.current === req) setAudioState("playing");
757
- } catch {
758
- // Kokoro unavailable (still downloading / unsupported) — fall back to the browser voice.
759
- if (reqRef.current !== req) return;
760
- speakWithBrowser(text, () => {
761
- if (reqRef.current === req) setAudioState("idle");
762
- });
763
- setAudioState("playing");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
764
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
  };
766
 
767
- const playing = audioState !== "idle";
 
768
  const safetyFact = findNeighborhoodFact(webResearch, selected.name, "safety");
769
  const rentFact = findNeighborhoodFact(webResearch, selected.name, "rent");
770
  const commuteFact = findNeighborhoodFact(webResearch, selected.name, "commute");
@@ -883,18 +978,21 @@ function DetailPanel({
883
  </button>
884
  <button
885
  type="button"
886
- className={`secondary-action ${playing ? "is-playing" : ""}`}
887
  onClick={togglePlay}
888
- aria-label={playing ? "Stop summary audio" : "Play summary audio"}
 
 
 
889
  >
890
- {audioState === "loading" ? (
891
  <LoaderCircle size={14} className="spin" />
892
- ) : audioState === "playing" ? (
893
  <Square size={14} fill="currentColor" />
894
  ) : (
895
  <Volume2 size={15} />
896
  )}
897
- {audioState === "loading" ? "Voice…" : audioState === "playing" ? "Stop" : "Play"}
898
  </button>
899
  </div>
900
  </section>
@@ -1202,15 +1300,91 @@ function openListings(name: string) {
1202
  window.open(`https://rentals.ca/toronto/${slug}`, "_blank", "noopener,noreferrer");
1203
  }
1204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205
  // A short, spoken-friendly summary of the findings (~15-20s of narration), capped so it
1206
- // never rambles. Leads with the match, then the model's recommendation, then the commute.
 
1207
  function buildSpokenSummary(
1208
  selected: RankedNeighborhood,
1209
  recommendation: AgentBackendRun["recommendation"] | null,
1210
  webResearch: AgentBackendRun["webResearch"] | null,
1211
  ) {
1212
- const parts = [`${selected.name} is your top match, scoring ${selected.overall} out of 100.`];
1213
- if (recommendation?.summary) parts.push(recommendation.summary);
 
 
 
 
1214
  const commute = findNeighborhoodFact(webResearch, selected.name, "commute");
1215
  if (commute && typeof commute.value === "number") {
1216
  parts.push(`About ${commute.value} minutes to Union Station.`);
 
54
  neighborhoodIds: string[];
55
  };
56
  type ResearchFact = NonNullable<NonNullable<AgentBackendRun["webResearch"]>["facts"]>[number];
57
+ type SummaryAudioState = "idle" | "generating" | "ready" | "playing";
58
+ type SummaryAudioCacheEntry = {
59
+ key: string;
60
+ text: string;
61
+ url: string | null;
62
+ status: "generating" | "ready" | "error";
63
+ promise: Promise<string | null> | null;
64
+ lastUsed: number;
65
+ };
66
 
67
  const initialParsed = parsePrompt(defaultPrompt);
68
  const initialRanked = rankNeighborhoods(initialParsed);
 
96
  // Must match TOUR_DWELL_MS in MapCanvas: the camera dwells on each area while it is researched.
97
  const RESEARCH_TOUR_VISIT_MS = 5400;
98
  const RESEARCH_TOUR_SETTLE_MS = 520;
99
+ const SUMMARY_AUDIO_PRELOAD_LIMIT = 6;
100
+ const SUMMARY_AUDIO_CACHE_LIMIT = 10;
101
+ const summaryAudioCache = new Map<string, SummaryAudioCacheEntry>();
102
 
103
  // Quick-start chips above the composer. They are just shortcuts — the real entry point is
104
  // whatever the user types in the box.
 
518
  />
519
  ) : (
520
  <DetailPanel
521
+ ranked={ranked}
522
  selected={selected}
523
  parsed={parsed}
524
  scoreT={scoreT}
 
714
  }
715
 
716
  function DetailPanel({
717
+ ranked,
718
  selected,
719
  parsed,
720
  scoreT,
721
  webResearch,
722
  recommendation,
723
  }: {
724
+ ranked: RankedNeighborhood[];
725
  selected: RankedNeighborhood;
726
  parsed: ParsedPrompt;
727
  scoreT: number;
 
729
  recommendation: AgentBackendRun["recommendation"] | null;
730
  }) {
731
  const fit = consensus(selected.overall);
732
+ const [audioState, setAudioState] = useState<SummaryAudioState>("idle");
733
  const audioRef = useRef<HTMLAudioElement | null>(null);
734
  const reqRef = useRef(0);
735
+ const spokenText = useMemo(
736
+ () => buildSpokenSummary(selected, recommendation, webResearch),
737
+ [selected, recommendation, webResearch],
738
+ );
739
+ const audioKey = useMemo(() => summaryAudioKey(selected.id, spokenText), [selected.id, spokenText]);
740
+ const preloadAudioItems = useMemo(
741
+ () =>
742
+ ranked.slice(0, SUMMARY_AUDIO_PRELOAD_LIMIT).map((neighborhood) => {
743
+ const text = buildSpokenSummary(neighborhood, recommendation, webResearch);
744
+ return {
745
+ key: summaryAudioKey(neighborhood.id, text),
746
+ text,
747
+ };
748
+ }),
749
+ [ranked, recommendation, webResearch],
750
+ );
751
+ const shouldPrepareAudio = Boolean(recommendation?.summary);
752
 
753
+ const stopPlaybackOnly = useCallback(() => {
 
754
  if (audioRef.current) {
755
  audioRef.current.pause();
 
756
  audioRef.current = null;
757
  }
758
  if (typeof window !== "undefined" && window.speechSynthesis) window.speechSynthesis.cancel();
 
759
  }, []);
760
 
761
+ const stopPlayback = useCallback(() => {
762
+ if (audioRef.current) {
763
+ audioRef.current.pause();
764
+ audioRef.current.currentTime = 0;
765
+ audioRef.current = null;
766
+ }
767
+ if (typeof window !== "undefined" && window.speechSynthesis) window.speechSynthesis.cancel();
768
+ setAudioState(shouldPrepareAudio ? "ready" : "idle");
769
+ }, [shouldPrepareAudio]);
770
 
771
+ useEffect(() => {
772
+ if (!shouldPrepareAudio) {
 
773
  return;
774
  }
775
+ preloadAudioItems.forEach((item) => prepareSummaryAudio(item.key, item.text));
776
+ pruneSummaryAudioCache(new Set(preloadAudioItems.map((item) => item.key)));
777
+ }, [preloadAudioItems, shouldPrepareAudio]);
778
+
779
+ useEffect(() => {
780
  const req = (reqRef.current += 1);
781
+ stopPlaybackOnly();
782
+ if (!shouldPrepareAudio) {
783
+ setAudioState("idle");
784
+ return () => {
785
+ if (reqRef.current === req) reqRef.current += 1;
786
+ stopPlaybackOnly();
787
+ };
788
+ }
789
+
790
+ const entry = prepareSummaryAudio(audioKey, spokenText);
791
+ setAudioState(entry.status === "ready" || entry.status === "error" ? "ready" : "generating");
792
+ if (entry.status === "generating") {
793
+ void entry.promise?.then(() => {
794
+ if (reqRef.current === req) setAudioState("ready");
795
+ });
796
+ }
797
+
798
+ return () => {
799
+ if (reqRef.current === req) reqRef.current += 1;
800
+ stopPlaybackOnly();
801
+ };
802
+ }, [audioKey, shouldPrepareAudio, spokenText, stopPlaybackOnly]);
803
+
804
+ const togglePlay = async () => {
805
+ if (audioState === "generating") return;
806
+ if (audioState === "playing") {
807
+ stopPlayback();
808
+ return;
809
+ }
810
+
811
+ const playUrl = async (url: string, req: number) => {
812
  const element = new Audio(url);
813
  audioRef.current = element;
814
  element.onended = () => {
815
+ if (reqRef.current === req) {
816
+ audioRef.current = null;
817
+ setAudioState("ready");
818
+ }
819
  };
820
+ element.onerror = () => {
821
+ if (reqRef.current === req) {
822
+ audioRef.current = null;
823
+ setAudioState("ready");
824
+ }
825
+ };
826
+ try {
827
+ await element.play();
828
+ if (reqRef.current === req) setAudioState("playing");
829
+ } catch {
830
+ if (reqRef.current !== req) return;
831
+ audioRef.current = null;
832
+ speakWithBrowser(spokenText, () => {
833
+ if (reqRef.current === req) setAudioState("ready");
834
+ });
835
+ setAudioState("playing");
836
+ }
837
+ };
838
+
839
+ const cached = getSummaryAudio(audioKey);
840
+ if (cached?.url && cached.text === spokenText) {
841
+ await playUrl(cached.url, reqRef.current);
842
+ return;
843
  }
844
+
845
+ const req = (reqRef.current += 1);
846
+ setAudioState("generating");
847
+ const entry = prepareSummaryAudio(audioKey, spokenText, { force: cached?.status === "error" });
848
+ const url = await entry.promise;
849
+ if (reqRef.current !== req) return;
850
+ if (url) {
851
+ await playUrl(url, req);
852
+ return;
853
+ }
854
+ // Kokoro unavailable (still downloading / unsupported) — fall back to the browser voice.
855
+ speakWithBrowser(spokenText, () => {
856
+ if (reqRef.current === req) setAudioState("ready");
857
+ });
858
+ setAudioState("playing");
859
  };
860
 
861
+ const generating = audioState === "generating";
862
+ const playing = audioState === "playing";
863
  const safetyFact = findNeighborhoodFact(webResearch, selected.name, "safety");
864
  const rentFact = findNeighborhoodFact(webResearch, selected.name, "rent");
865
  const commuteFact = findNeighborhoodFact(webResearch, selected.name, "commute");
 
978
  </button>
979
  <button
980
  type="button"
981
+ className={`secondary-action ${playing ? "is-playing" : ""} ${generating ? "is-generating" : ""}`}
982
  onClick={togglePlay}
983
+ disabled={generating}
984
+ aria-label={
985
+ generating ? "Summary audio is being generated" : playing ? "Stop summary audio" : "Play summary audio"
986
+ }
987
  >
988
+ {generating ? (
989
  <LoaderCircle size={14} className="spin" />
990
+ ) : playing ? (
991
  <Square size={14} fill="currentColor" />
992
  ) : (
993
  <Volume2 size={15} />
994
  )}
995
+ {generating ? "Generating audio" : playing ? "Stop" : "Play"}
996
  </button>
997
  </div>
998
  </section>
 
1300
  window.open(`https://rentals.ca/toronto/${slug}`, "_blank", "noopener,noreferrer");
1301
  }
1302
 
1303
+ function summaryAudioKey(neighborhoodId: string, text: string) {
1304
+ return `${neighborhoodId}:${hashText(text)}`;
1305
+ }
1306
+
1307
+ function hashText(text: string) {
1308
+ let hash = 5381;
1309
+ for (let index = 0; index < text.length; index += 1) {
1310
+ hash = (hash * 33) ^ text.charCodeAt(index);
1311
+ }
1312
+ return (hash >>> 0).toString(36);
1313
+ }
1314
+
1315
+ function getSummaryAudio(key: string) {
1316
+ const entry = summaryAudioCache.get(key);
1317
+ if (entry) entry.lastUsed = Date.now();
1318
+ return entry;
1319
+ }
1320
+
1321
+ function prepareSummaryAudio(key: string, text: string, options: { force?: boolean } = {}) {
1322
+ const existing = summaryAudioCache.get(key);
1323
+ if (
1324
+ existing &&
1325
+ existing.text === text &&
1326
+ !options.force &&
1327
+ (existing.status === "ready" || existing.status === "generating")
1328
+ ) {
1329
+ existing.lastUsed = Date.now();
1330
+ return existing;
1331
+ }
1332
+
1333
+ if (existing?.url) URL.revokeObjectURL(existing.url);
1334
+
1335
+ const entry: SummaryAudioCacheEntry = {
1336
+ key,
1337
+ text,
1338
+ url: null,
1339
+ status: "generating",
1340
+ promise: null,
1341
+ lastUsed: Date.now(),
1342
+ };
1343
+ entry.promise = synthesizeKokoro(text)
1344
+ .then((url) => {
1345
+ entry.url = url;
1346
+ entry.status = "ready";
1347
+ entry.lastUsed = Date.now();
1348
+ return url;
1349
+ })
1350
+ .catch(() => {
1351
+ entry.status = "error";
1352
+ entry.promise = null;
1353
+ entry.lastUsed = Date.now();
1354
+ return null;
1355
+ });
1356
+ summaryAudioCache.set(key, entry);
1357
+ pruneSummaryAudioCache();
1358
+ return entry;
1359
+ }
1360
+
1361
+ function pruneSummaryAudioCache(keepKeys: Set<string> = new Set()) {
1362
+ if (summaryAudioCache.size <= SUMMARY_AUDIO_CACHE_LIMIT) return;
1363
+ const candidates = [...summaryAudioCache.values()]
1364
+ .filter((entry) => !keepKeys.has(entry.key))
1365
+ .sort((a, b) => a.lastUsed - b.lastUsed);
1366
+ while (summaryAudioCache.size > SUMMARY_AUDIO_CACHE_LIMIT && candidates.length) {
1367
+ const entry = candidates.shift();
1368
+ if (!entry) return;
1369
+ if (entry.url) URL.revokeObjectURL(entry.url);
1370
+ summaryAudioCache.delete(entry.key);
1371
+ }
1372
+ }
1373
+
1374
  // A short, spoken-friendly summary of the findings (~15-20s of narration), capped so it
1375
+ // never rambles. Leads with the selected match, then only uses the model recommendation
1376
+ // when it applies to that neighbourhood.
1377
  function buildSpokenSummary(
1378
  selected: RankedNeighborhood,
1379
  recommendation: AgentBackendRun["recommendation"] | null,
1380
  webResearch: AgentBackendRun["webResearch"] | null,
1381
  ) {
1382
+ const parts = [`${selected.name} scores ${selected.overall} out of 100 for this search.`];
1383
+ if (recommendation?.selectedId === selected.id && recommendation.summary) {
1384
+ parts.push(recommendation.summary);
1385
+ }
1386
+ const rent = findNeighborhoodFact(webResearch, selected.name, "rent");
1387
+ if (rent) parts.push(`${rent.label}: ${formatFactValue(rent)}.`);
1388
  const commute = findNeighborhoodFact(webResearch, selected.name, "commute");
1389
  if (commute && typeof commute.value === "number") {
1390
  parts.push(`About ${commute.value} minutes to Union Station.`);
src/styles.css CHANGED
@@ -255,6 +255,12 @@ textarea:focus-visible {
255
  animation: speak-pulse 1.4s ease-in-out infinite;
256
  }
257
 
 
 
 
 
 
 
258
  @keyframes speak-pulse {
259
  0%, 100% { box-shadow: 0 0 0 0 rgba(48, 75, 120, 0.35); }
260
  50% { box-shadow: 0 0 0 5px rgba(48, 75, 120, 0); }
@@ -1074,7 +1080,8 @@ textarea:focus-visible {
1074
 
1075
  .detail-stack {
1076
  display: flex;
1077
- max-height: calc(100vh - 126px);
 
1078
  flex-direction: column;
1079
  gap: 12px;
1080
  overflow-x: hidden;
@@ -1459,12 +1466,15 @@ textarea:focus-visible {
1459
  height: 48px;
1460
  margin: 0;
1461
  padding: 0 12px;
 
 
1462
  white-space: nowrap;
1463
  }
1464
 
1465
  .agent-rail {
1466
  display: flex;
1467
- max-height: calc(100vh - 128px);
 
1468
  flex-direction: column;
1469
  padding: 0;
1470
  overflow: hidden;
 
255
  animation: speak-pulse 1.4s ease-in-out infinite;
256
  }
257
 
258
+ .secondary-action.is-generating,
259
+ .secondary-action:disabled {
260
+ cursor: default;
261
+ opacity: 0.78;
262
+ }
263
+
264
  @keyframes speak-pulse {
265
  0%, 100% { box-shadow: 0 0 0 0 rgba(48, 75, 120, 0.35); }
266
  50% { box-shadow: 0 0 0 5px rgba(48, 75, 120, 0); }
 
1080
 
1081
  .detail-stack {
1082
  display: flex;
1083
+ /* Leave room at the bottom-right so the panel never covers the map controls. */
1084
+ max-height: calc(100vh - 196px);
1085
  flex-direction: column;
1086
  gap: 12px;
1087
  overflow-x: hidden;
 
1466
  height: 48px;
1467
  margin: 0;
1468
  padding: 0 12px;
1469
+ overflow: hidden;
1470
+ font-size: 12px;
1471
  white-space: nowrap;
1472
  }
1473
 
1474
  .agent-rail {
1475
  display: flex;
1476
+ /* Leave room at the bottom-right so the panel never covers the map controls. */
1477
+ max-height: calc(100vh - 196px);
1478
  flex-direction: column;
1479
  padding: 0;
1480
  overflow: hidden;