Skip to content

Commit cca86c1

Browse files
authored
Corpán Cacophony (#169)
* cacophony - not on android though * 0.9.4
1 parent 91e7ea1 commit cca86c1

File tree

22 files changed

+635
-35
lines changed

22 files changed

+635
-35
lines changed

corpan/corpan-app/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

corpan/corpan-app/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "corpan"
3-
version = "0.9.3"
3+
version = "0.9.4"
44
description = "Language learning app"
55
authors = ["you"]
66
edition = "2021"

corpan/corpan-app/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "corpan",
4-
"version": "0.9.3",
4+
"version": "0.9.4",
55
"identifier": "com.corpora.corpan",
66
"build": {
77
"beforeDevCommand": "npm run dev",

corpan/corpan-app/src/components/MainExperience.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
getPlatformTopPaddingTranslations,
2424
} from "@/util/browser";
2525
import { useScrollNavigation } from "@/hooks/useScrollNavigation";
26-
import { speakWithStackPrefs } from "@/util/speakWithStackPrefs";
26+
import { speakConcurrentWithStackPrefs } from "@/util/speakWithStackPrefs";
2727

2828
/* -------------------------------- Types -------------------------------- */
2929

@@ -337,7 +337,7 @@ export function MainExperience() {
337337

338338
const speak = (uiCode: string, txt: string) => {
339339
if (!txt) return;
340-
speakWithStackPrefs(uiCode, txt, rate);
340+
speakConcurrentWithStackPrefs(uiCode, txt, rate);
341341
};
342342

343343
// --- UI --------------------------------------------------------------------

corpan/corpan-app/src/contentPacks/hostApi.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { invoke } from "@tauri-apps/api/core"
22

3-
import { speakWithStackPrefs } from "@/util/speakWithStackPrefs"
3+
import { speakWithStackPrefs, speakConcurrentWithStackPrefs } from "@/util/speakWithStackPrefs"
44
import { useSettingsStore } from "@/store/settings"
55
import type { HostApi, PackDbQuery } from "./types"
66

@@ -87,6 +87,14 @@ export const createHostApi = (packId?: string): HostApi => {
8787
await speakWithStackPrefs(uiCode, text, rate)
8888
}
8989

90+
const speakConcurrent = async (uiCode: string, text: string): Promise<string> => {
91+
if (disposed) {
92+
return ""
93+
}
94+
const { rate } = useSettingsStore.getState()
95+
return await speakConcurrentWithStackPrefs(uiCode, text, rate)
96+
}
97+
9098
const dispose = () => {
9199
disposed = true
92100
}
@@ -99,6 +107,9 @@ export const createHostApi = (packId?: string): HostApi => {
99107
speak: async (uiCode, text) => {
100108
await speakImmediate(uiCode, text)
101109
},
110+
speakConcurrent: async (uiCode, text) => {
111+
return await speakConcurrent(uiCode, text)
112+
},
102113
stopSpeech,
103114
dispose,
104115
getStackConfig: () => {

corpan/corpan-app/src/contentPacks/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export type PackDbQueryResult = {
3636

3737
export type HostApi = {
3838
speak: (uiCode: string, text: string) => Promise<void>
39+
/** Speak concurrently (allows overlapping audio). Returns utterance ID. */
40+
speakConcurrent?: (uiCode: string, text: string) => Promise<string>
3941
stopSpeech?: () => Promise<void>
4042
dispose?: () => void
4143
getStackConfig: () => StackConfig

corpan/corpan-app/src/util/speak.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ async function speakNative(text: string, langPrefix: string, rate: number, voice
9696
});
9797
}
9898

99+
/**
100+
* Speak concurrently using the synthesizer pool (allows overlapping audio on macOS/iOS).
101+
* On Android, falls back to sequential playback due to platform limitations.
102+
* Returns an utterance ID for tracking completion.
103+
*/
104+
async function speakNativeConcurrent(text: string, langPrefix: string, rate: number, voiceId?: string): Promise<string> {
105+
const result = await invoke<{ utteranceId: string }>("plugin:tts|speak_concurrent", {
106+
args: {
107+
text,
108+
language: langPrefix,
109+
rate,
110+
voice_id: voiceId,
111+
}
112+
});
113+
return result.utteranceId;
114+
}
115+
99116
async function speakBrowser(text: string, langPrefix: string, rate: number, voiceId?: string) {
100117
if (!BROWSER_TTS) throw new Error("Web Speech API not available");
101118

@@ -167,3 +184,34 @@ export function createVoiceTTS(langPrefix: string) {
167184
}
168185
};
169186
}
187+
188+
/**
189+
* Factory returning a concurrent `speak` function that allows overlapping audio.
190+
* On macOS/iOS: true concurrent playback via synthesizer pool.
191+
* On Android: sequential playback (platform limitation) but returns utterance ID.
192+
* Returns an utterance ID for tracking completion.
193+
* Usage: createVoiceTTSConcurrent("en-US")(text, 0.9, "com.apple....") => Promise<string>
194+
*/
195+
export function createVoiceTTSConcurrent(langPrefix: string) {
196+
return async function speakConcurrent(text: string, rate: number = 0.7, voiceId?: string): Promise<string> {
197+
// 1) Prefer native concurrent on macOS/iOS/Android when in Tauri.
198+
try {
199+
if (await preferNativeTTS()) {
200+
return await speakNativeConcurrent(text, langPrefix, rate, voiceId);
201+
}
202+
} catch (err) {
203+
// eslint-disable-next-line no-console
204+
console.warn(`[TTS:${langPrefix}] Native concurrent failed; falling back to browser`, err);
205+
}
206+
207+
// 2) Fallback to browser (sequential - browser doesn't support concurrent easily).
208+
try {
209+
await speakBrowser(text, langPrefix, rate, voiceId);
210+
return `browser_${Date.now()}`;
211+
} catch (err) {
212+
// eslint-disable-next-line no-console
213+
console.warn(`[TTS:${langPrefix}] Browser TTS failed`, err);
214+
return `error_${Date.now()}`;
215+
}
216+
};
217+
}

corpan/corpan-app/src/util/speakWithStackPrefs.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
// honoring random/sequence; fall back to speaking by language if no preferred
33
// IDs are currently available.
44

5-
import { createVoiceTTS } from "@/util/speak";
5+
import { createVoiceTTS, createVoiceTTSConcurrent } from "@/util/speak";
66
import { useSettingsStore } from "@/store/settings";
77
import { getVoicesCached } from "@/util/tts-voices";
88

9-
export async function speakWithStackPrefs(uiCode: string, text: string, rate: number) {
9+
/**
10+
* Helper to get the voice ID to use based on stack preferences.
11+
* Returns undefined if no preferred voice is available.
12+
*/
13+
async function getPreferredVoiceId(uiCode: string): Promise<string | undefined> {
1014
const state = useSettingsStore.getState();
1115
const { voicePrefs, nextVoiceId } = state;
1216

@@ -20,28 +24,35 @@ export async function speakWithStackPrefs(uiCode: string, text: string, rate: nu
2024
const baseIds = basePref?.ids ?? [];
2125
const mergedPrefIds = Array.from(new Set([...exactIds, ...baseIds]));
2226

23-
// If there are no prefs at all, just speak with language
24-
// console.log("mergedPrefIds", mergedPrefIds);
2527
if (mergedPrefIds.length === 0) {
26-
await createVoiceTTS(uiCode)(text, rate);
27-
return;
28+
return undefined;
2829
}
2930

3031
// Validate against currently available voices (native first, browser fallback)
3132
const available = await getVoicesCached({ maxAgeMs: 30_000 });
3233
const availableIds = new Set(available.map((v) => v.id));
3334
const pool = mergedPrefIds.filter((id) => availableIds.has(id));
3435

35-
// console.warn(pool)
3636
if (pool.length === 0) {
37-
// Preferred IDs aren’t installed/available right now; speak by language
38-
await createVoiceTTS(uiCode)(text, rate);
39-
return;
37+
return undefined;
4038
}
4139

42-
// Use the exact entrys mode if present; otherwise base
40+
// Use the exact entry's mode if present; otherwise base
4341
const langKeyForMode = exactPref ? uiCode : base;
44-
const chosenId = nextVoiceId(langKeyForMode, pool);
42+
return nextVoiceId(langKeyForMode, pool);
43+
}
4544

45+
export async function speakWithStackPrefs(uiCode: string, text: string, rate: number) {
46+
const chosenId = await getPreferredVoiceId(uiCode);
4647
await createVoiceTTS(uiCode)(text, rate, chosenId);
4748
}
49+
50+
/**
51+
* Speak concurrently using the synthesizer pool (allows overlapping audio on macOS/iOS).
52+
* On Android, falls back to sequential playback due to platform limitations.
53+
* Returns an utterance ID for tracking completion.
54+
*/
55+
export async function speakConcurrentWithStackPrefs(uiCode: string, text: string, rate: number): Promise<string> {
56+
const chosenId = await getPreferredVoiceId(uiCode);
57+
return await createVoiceTTSConcurrent(uiCode)(text, rate, chosenId);
58+
}

corpan/packs/juice-squeeze/src/game.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,12 @@ export const createJuiceSqueeze = (
304304
}
305305
}
306306

307-
// Speak without interrupting - allows audio to overlap
307+
// Speak without interrupting - allows audio to overlap using concurrent TTS
308308
const speak = (lang: string, text: string) => {
309-
if (typeof hostApi.speak === "function") {
309+
// Prefer speakConcurrent for true overlapping audio
310+
if (typeof hostApi.speakConcurrent === "function") {
311+
hostApi.speakConcurrent(lang, text)
312+
} else if (typeof hostApi.speak === "function") {
310313
hostApi.speak(lang, text)
311314
}
312315
}

corpan/packs/juice-squeeze/src/sdk/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export type EntryOut = {
2323

2424
export type HostApi = {
2525
speak: (lang: string, text: string) => void
26+
/** Speak concurrently (allows overlapping audio). Returns utterance ID. */
27+
speakConcurrent?: (lang: string, text: string) => Promise<string>
2628
stopSpeech?: () => void
2729
getStackConfig: () => StackConfig
2830
onStackConfigChange?: (listener: (next: StackConfig) => void) => () => void

0 commit comments

Comments
 (0)