messages) {
}
}
+ /**
+ * Voice-assistant arm of {@link #processDecodes}: decide + dedup + speak.
+ * Blocked messages never reach here (the caller {@code continue}s on them),
+ * and the dedup claim is gated on canSpeakNow() so a station first decoded
+ * during a transmission isn't silenced for the whole session.
+ */
+ private void maybeAnnounceVoice(Ft8Message msg, boolean addressedToMe) {
+ boolean isCq = msg.checkIsCQ();
+
+ // New-prefix predicate, only computed when it could matter (mirrors the
+ // Kotlin isNewPrefixStation logic: WpxPrefix + checkQSLPrefix).
+ String prefix = null;
+ boolean fromNewPrefix = false;
+ if (GeneralVariables.voiceAnnounceNewPrefix && isCq) {
+ prefix = WpxPrefix.of(msg.getCallsignFrom());
+ fromNewPrefix = prefix != null && !GeneralVariables.checkQSLPrefix(prefix);
+ }
+
+ VoiceAnnouncementDecisions.Kind kind = VoiceAnnouncementDecisions.decide(
+ GeneralVariables.voiceAnnounceCalling,
+ GeneralVariables.voiceAnnounceNewDxcc,
+ GeneralVariables.voiceAnnounceNewPrefix,
+ addressedToMe, isCq, msg.fromDxcc, fromNewPrefix,
+ false /* blocked messages already filtered by the caller */);
+ if (kind == null) return;
+
+ String key;
+ String phrase;
+ switch (kind) {
+ case CALLING_ME:
+ key = VoiceAnnouncementDecisions.callingMeKey(msg.getCallsignFrom());
+ phrase = VoicePhrases.callingYou(msg.getCallsignFrom(), msg.snr);
+ break;
+ case NEW_DXCC:
+ // Same fallback as the notification arm: country name when
+ // resolved, else the (spelled) callsign.
+ boolean noCountry = msg.fromWhere == null || msg.fromWhere.isEmpty();
+ String country = noCountry ? msg.getCallsignFrom() : msg.fromWhere;
+ key = VoiceAnnouncementDecisions.newDxccKey(country);
+ phrase = VoicePhrases.newCountry(
+ noCountry ? VoicePhrases.spellCallsign(country) : country);
+ break;
+ default: // NEW_PREFIX
+ key = VoiceAnnouncementDecisions.newPrefixKey(prefix);
+ phrase = VoicePhrases.newPrefix(prefix);
+ break;
+ }
+
+ if (!VoiceAnnouncementDecisions.claim(
+ voiceAnnouncer.spokenKeys(), key, voiceAnnouncer.canSpeakNow())) {
+ return;
+ }
+ GeneralVariables.fileLog("VOICE announce key=[" + key + "] " + phrase);
+ voiceAnnouncer.speak(phrase, key);
+ }
+
/** Fire a notification when a QSO has just been logged, if the user enabled it. */
public void notifyQsoComplete(QSLRecord qslRecord) {
if (appContext == null || qslRecord == null) return;
+
+ // Voice announcement first — its toggle is independent of the
+ // notification toggle below.
+ announceQsoCompleteVoice(qslRecord);
+
if (!GeneralVariables.alertOnQsoComplete) return;
String call = qslRecord.getToCallsign();
@@ -168,6 +253,19 @@ public void notifyQsoComplete(QSLRecord qslRecord) {
body.toString(), call, qslRecord.getBandFreq());
}
+ /** "QSO with K 1 A B C logged" — once per logged contact, opt-in. */
+ private void announceQsoCompleteVoice(QSLRecord qslRecord) {
+ if (!GeneralVariables.voiceAnnounceQsoComplete) return;
+ String call = qslRecord.getToCallsign();
+ String key = VoiceAnnouncementDecisions.qsoCompleteKey(call, qslRecord.getEndTime());
+ if (!VoiceAnnouncementDecisions.claim(
+ voiceAnnouncer.spokenKeys(), key, voiceAnnouncer.canSpeakNow())) {
+ return;
+ }
+ GeneralVariables.fileLog("VOICE announce key=[" + key + "]");
+ voiceAnnouncer.speak(VoicePhrases.qsoLogged(call), key);
+ }
+
static String defaultBody(Ft8Message msg) {
StringBuilder body = new StringBuilder(msg.getCallsignFrom());
if (msg.maidenGrid != null && !msg.maidenGrid.isEmpty()) {
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java
index 49f0ea6a0..a01b8de38 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java
@@ -2996,6 +2996,25 @@ protected Void doInBackground(Void... voids) {
if (name.equalsIgnoreCase("alertOnQsoComplete")) {//Alert when a QSO completes
GeneralVariables.alertOnQsoComplete = result.equals("1");
}
+ if (name.equalsIgnoreCase("voiceAnnounceCalling")) {//Voice: announce station calling me
+ GeneralVariables.voiceAnnounceCalling = result.equals("1");
+ }
+ if (name.equalsIgnoreCase("voiceAnnounceQsoComplete")) {//Voice: announce QSO logged
+ GeneralVariables.voiceAnnounceQsoComplete = result.equals("1");
+ }
+ if (name.equalsIgnoreCase("voiceAnnounceNewDxcc")) {//Voice: announce new-DXCC CQ
+ GeneralVariables.voiceAnnounceNewDxcc = result.equals("1");
+ }
+ if (name.equalsIgnoreCase("voiceAnnounceNewPrefix")) {//Voice: announce new-prefix CQ
+ GeneralVariables.voiceAnnounceNewPrefix = result.equals("1");
+ }
+ if (name.equalsIgnoreCase("voiceCommandsEnabled")) {//Voice: push-to-talk command button
+ GeneralVariables.voiceCommandsEnabled = result.equals("1");
+ // Hydration runs on a worker thread; postValue keeps the
+ // observable mirror (main-screen mic button) in sync.
+ GeneralVariables.mutableVoiceCommandsEnabled
+ .postValue(GeneralVariables.voiceCommandsEnabled);
+ }
if (name.equalsIgnoreCase("flexMaxRfPower")) {//Flex max RF power
GeneralVariables.flexMaxRfPower = parseConfigInt(result, 10);
}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java
new file mode 100644
index 000000000..0393eb7f9
--- /dev/null
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java
@@ -0,0 +1,94 @@
+package com.k1af.ft8af.voice;
+
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Pure, Android-free decision + dedup logic for spoken announcements (the
+ * voice-assistant counterpart of {@code alert/AlertDecisions}). The announcer
+ * itself touches TextToSpeech and can't be unit-tested; every branch here can.
+ *
+ * Priority when one decode qualifies in several categories: a station
+ * calling ME beats everything (it needs action now), then new DXCC, then new
+ * prefix. New-DXCC / new-prefix announcements apply to CQ broadcasts only —
+ * same rule as the needed-DX notification alerts.
+ *
+ *
Dedup keys are namespaced strings collected in a per-session set so a
+ * station calling every cycle (and every decode pass within a cycle — early,
+ * late, deep passes all funnel through processDecodes) is announced once.
+ */
+public final class VoiceAnnouncementDecisions {
+ private VoiceAnnouncementDecisions() {}
+
+ public enum Kind { CALLING_ME, NEW_DXCC, NEW_PREFIX }
+
+ /** Whether any per-decode announcement toggle is on (cheap early-out). */
+ public static boolean anyDecodeAnnounceEnabled(
+ boolean announceCalling, boolean announceNewDxcc, boolean announceNewPrefix) {
+ return announceCalling || announceNewDxcc || announceNewPrefix;
+ }
+
+ /**
+ * Decide which announcement (if any) a decoded message earns.
+ *
+ * @param announceCalling the voiceAnnounceCalling user toggle
+ * @param announceNewDxcc the voiceAnnounceNewDxcc user toggle
+ * @param announceNewPrefix the voiceAnnounceNewPrefix user toggle
+ * @param addressedToMe message's target callsign is mine
+ * @param isCq message is a CQ broadcast
+ * @param fromNewDxcc sender is a new (unworked) DXCC entity
+ * @param fromNewPrefix sender carries a new (unworked) WPX prefix
+ * @param blocked message is filtered by the user's block list
+ * @return the announcement to speak, or null for silence
+ */
+ public static Kind decide(boolean announceCalling, boolean announceNewDxcc,
+ boolean announceNewPrefix, boolean addressedToMe,
+ boolean isCq, boolean fromNewDxcc, boolean fromNewPrefix,
+ boolean blocked) {
+ if (blocked) return null;
+ if (announceCalling && addressedToMe) return Kind.CALLING_ME;
+ if (!isCq) return null;
+ if (announceNewDxcc && fromNewDxcc) return Kind.NEW_DXCC;
+ if (announceNewPrefix && fromNewPrefix) return Kind.NEW_PREFIX;
+ return null;
+ }
+
+ /**
+ * Claim the right to speak {@code dedupKey}, returning true only when the
+ * caller should proceed. The speakability gate ({@code canSpeak} — false
+ * while transmitting, when TTS would leak into the rig audio) is evaluated
+ * BEFORE the dedup set is touched: burning the key while muted would
+ * silence that station for the whole session, so a station first heard
+ * during a transmission still gets announced on its next decode.
+ */
+ public static boolean claim(Set spoken, String dedupKey, boolean canSpeak) {
+ if (!canSpeak) return false; // gate FIRST — do not burn the key while muted
+ return spoken.add(dedupKey);
+ }
+
+ /** One announcement per calling station per session. */
+ public static String callingMeKey(String fromCallsign) {
+ return "VCALL:" + norm(fromCallsign);
+ }
+
+ /** One announcement per new country per session. */
+ public static String newDxccKey(String country) {
+ return "VDXCC:" + norm(country);
+ }
+
+ /** One announcement per new prefix per session. */
+ public static String newPrefixKey(String prefix) {
+ return "VPREFIX:" + norm(prefix);
+ }
+
+ /** One announcement per logged contact (station + completion time). */
+ public static String qsoCompleteKey(String toCallsign, String endTime) {
+ return "VQSO:" + norm(toCallsign) + "|" + norm(endTime);
+ }
+
+ private static String norm(String s) {
+ // Locale.ROOT: default-locale casing (e.g. Turkish dotted/dotless I)
+ // would make dedup keys differ between devices for the same station.
+ return s == null ? "" : s.trim().toUpperCase(Locale.ROOT);
+ }
+}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java
new file mode 100644
index 000000000..ba1a2786b
--- /dev/null
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java
@@ -0,0 +1,169 @@
+package com.k1af.ft8af.voice;
+
+import android.content.Context;
+import android.speech.tts.TextToSpeech;
+
+import com.k1af.ft8af.GeneralVariables;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Thin Android TextToSpeech wrapper for the voice assistant. Owned by
+ * {@code DxAlertNotifier} (the same object that already sees every decode
+ * batch and QSO completion).
+ *
+ * The one hard rule (see the TX-audio hazards in the project docs): TTS
+ * must NEVER play while the rig is transmitting — the utterance would be
+ * mixed into the TX audio and go out over the air. Two layers enforce it:
+ *
+ * - {@link #speak} refuses to start an utterance while the
+ * {@link TransmitGate} reports TX active, and
+ * - MainViewModel observes {@code mutableIsTransmitting} and calls
+ * {@link #stopNow()} the instant it flips true, cutting off anything
+ * already speaking.
+ *
+ *
+ * TextToSpeech init is lazy (first {@link #speak}) so no TTS engine is
+ * spun up for users who never enable a voice toggle. Utterances queued while
+ * the engine is still initializing are buffered (bounded) and flushed from
+ * onInit — re-checking the transmit gate at flush time.
+ */
+public class VoiceAnnouncer {
+
+ /** Runtime TX check, wired to {@code FT8TransmitSignal.isTransmitting()}. */
+ public interface TransmitGate {
+ boolean isTransmitting();
+ }
+
+ private static final int MAX_PENDING = 5;
+
+ private final Context appContext;
+ private final Object initLock = new Object();
+ private volatile TextToSpeech tts;
+ private volatile boolean ttsReady = false;
+ private volatile boolean ttsFailed = false;
+ // Utterances requested before onInit landed. Guarded by initLock.
+ private final List pending = new ArrayList<>();
+ // Per-session spoken dedup keys (see VoiceAnnouncementDecisions.claim).
+ private final Set spoken = Collections.newSetFromMap(new ConcurrentHashMap<>());
+ private volatile TransmitGate transmitGate = null;
+
+ public VoiceAnnouncer(Context context) {
+ this.appContext = context != null ? context.getApplicationContext() : null;
+ }
+
+ public void setTransmitGate(TransmitGate gate) {
+ this.transmitGate = gate;
+ }
+
+ /** The per-session dedup key set, for {@code VoiceAnnouncementDecisions.claim}. */
+ public Set spokenKeys() {
+ return spoken;
+ }
+
+ /** False while the rig is transmitting — speaking then would go out over the air. */
+ public boolean canSpeakNow() {
+ TransmitGate gate = transmitGate;
+ return gate == null || !gate.isTransmitting();
+ }
+
+ /**
+ * Queue an utterance (QUEUE_ADD — announcements never cut each other off).
+ * Silently dropped while transmitting; the caller's dedup claim should be
+ * gated on {@link #canSpeakNow()} so the key isn't burned in that case.
+ */
+ public void speak(String text, String utteranceId) {
+ if (appContext == null || text == null || text.isEmpty()) return;
+ if (!canSpeakNow()) return;
+
+ synchronized (initLock) {
+ if (ttsFailed) return;
+ if (!ttsReady) {
+ if (tts == null) {
+ initTts();
+ }
+ if (pending.size() < MAX_PENDING) {
+ pending.add(new String[]{text, utteranceId});
+ }
+ return;
+ }
+ }
+ speakNow(text, utteranceId);
+ }
+
+ /** Cut off the current utterance and drop anything queued. TX just started. */
+ public void stopNow() {
+ synchronized (initLock) {
+ pending.clear();
+ }
+ TextToSpeech engine = tts;
+ if (engine != null && ttsReady) {
+ try {
+ engine.stop();
+ } catch (Exception ignored) {
+ // Engine died — nothing is speaking, which is all stop wanted.
+ }
+ }
+ }
+
+ public void shutdown() {
+ TextToSpeech engine;
+ synchronized (initLock) {
+ pending.clear();
+ engine = tts;
+ tts = null;
+ ttsReady = false;
+ }
+ if (engine != null) {
+ try {
+ engine.shutdown();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ // Must be called with initLock held.
+ private void initTts() {
+ tts = new TextToSpeech(appContext, status -> {
+ List toFlush = null;
+ synchronized (initLock) {
+ if (status == TextToSpeech.SUCCESS && tts != null) {
+ try {
+ tts.setLanguage(Locale.US);
+ } catch (Exception ignored) {
+ // Engine keeps its default language; still usable.
+ }
+ ttsReady = true;
+ toFlush = new ArrayList<>(pending);
+ } else {
+ ttsFailed = true;
+ GeneralVariables.fileLog("VoiceAnnouncer: TTS init failed status=" + status);
+ }
+ pending.clear();
+ }
+ if (toFlush != null) {
+ for (String[] entry : toFlush) {
+ // Re-check the gate: TX may have started while init ran.
+ if (!canSpeakNow()) break;
+ speakNow(entry[0], entry[1]);
+ }
+ }
+ });
+ }
+
+ private void speakNow(String text, String utteranceId) {
+ TextToSpeech engine = tts;
+ if (engine == null) return;
+ try {
+ engine.speak(text, TextToSpeech.QUEUE_ADD, null,
+ utteranceId == null ? String.valueOf(text.hashCode()) : utteranceId);
+ } catch (Exception e) {
+ GeneralVariables.fileLog("VoiceAnnouncer: speak failed: " + e.getMessage());
+ }
+ }
+}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java
new file mode 100644
index 000000000..3eb3f7d17
--- /dev/null
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java
@@ -0,0 +1,74 @@
+package com.k1af.ft8af.voice;
+
+import java.util.List;
+
+/**
+ * Pure, Android-free candidate selection for the "answer" voice command:
+ * which decoded message should {@code MainViewModel.callStation} be handed?
+ *
+ * Preference order:
+ *
+ * - The newest decode addressed to my callsign (a station actively
+ * calling me right now).
+ * - Else the newest decode from the head of the caller queue (a station
+ * that called me earlier and is waiting its turn).
+ * - Else null — nothing to answer.
+ *
+ *
+ * Generic over the message type (with tiny accessor interfaces instead of
+ * {@code java.util.function} — minSdk 23, no core-library desugaring) so the
+ * selection logic is testable without Android's {@code Ft8Message}.
+ */
+public final class VoiceAnswerSelector {
+ private VoiceAnswerSelector() {}
+
+ /** Whether a message is addressed to my callsign. */
+ public interface AddressedToMe {
+ boolean test(T message);
+ }
+
+ /** The sender callsign of a message (may be null/empty for junk rows). */
+ public interface SenderOf {
+ String get(T message);
+ }
+
+ /**
+ * @param decodesOldestFirst the decode list in arrival order (the app's
+ * ft8Messages list appends newest last)
+ * @param addressedToMe resolved by the caller via checkIsMyCallsign
+ * @param senderOf sender-callsign accessor
+ * @param queueHeadCallsign callsign at the head of the caller queue, or
+ * null when the queue is empty
+ * @return the message to answer, or null when there is no candidate
+ */
+ public static T pick(List decodesOldestFirst,
+ AddressedToMe addressedToMe,
+ SenderOf senderOf,
+ String queueHeadCallsign) {
+ if (decodesOldestFirst == null || decodesOldestFirst.isEmpty()) return null;
+
+ // Newest-first scan for a station calling me.
+ for (int i = decodesOldestFirst.size() - 1; i >= 0; i--) {
+ T msg = decodesOldestFirst.get(i);
+ if (msg == null) continue;
+ if (!hasSender(senderOf.get(msg))) continue;
+ if (addressedToMe.test(msg)) return msg;
+ }
+
+ // Fall back to the queued caller's newest decode.
+ if (queueHeadCallsign != null && !queueHeadCallsign.trim().isEmpty()) {
+ for (int i = decodesOldestFirst.size() - 1; i >= 0; i--) {
+ T msg = decodesOldestFirst.get(i);
+ if (msg == null) continue;
+ String sender = senderOf.get(msg);
+ if (!hasSender(sender)) continue;
+ if (sender.trim().equalsIgnoreCase(queueHeadCallsign.trim())) return msg;
+ }
+ }
+ return null;
+ }
+
+ private static boolean hasSender(String sender) {
+ return sender != null && !sender.trim().isEmpty();
+ }
+}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java
new file mode 100644
index 000000000..19590d7d2
--- /dev/null
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java
@@ -0,0 +1,80 @@
+package com.k1af.ft8af.voice;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Pure, Android-free keyword parser for one-shot voice commands (no NLU, no
+ * network). The recognizer's top transcription is normalized (lowercased,
+ * punctuation stripped, whitespace collapsed) and matched against a small
+ * fixed keyword set; anything else is {@link Command#UNKNOWN}.
+ *
+ * Match priority when an utterance contains several keywords: STOP beats
+ * everything ("stop calling CQ" must stop, not CQ), then ANSWER, SKIP, LOG,
+ * and CALL_CQ last. Recognizers often mangle "CQ" into "seek you" or "c q",
+ * so those variants are accepted for CALL_CQ.
+ */
+public final class VoiceCommandParser {
+ private VoiceCommandParser() {}
+
+ public enum Command { ANSWER, CALL_CQ, STOP, SKIP, LOG, UNKNOWN }
+
+ private static final Set STOP_WORDS =
+ new HashSet<>(Arrays.asList("stop", "halt", "cancel"));
+ private static final Set ANSWER_WORDS =
+ new HashSet<>(Arrays.asList("answer", "reply"));
+ private static final Set SKIP_WORDS =
+ new HashSet<>(Arrays.asList("skip", "next"));
+ private static final Set LOG_WORDS =
+ new HashSet<>(Arrays.asList("log", "logged"));
+
+ /** Parse a recognizer transcription into a {@link Command}. Null-safe. */
+ public static Command parse(String utterance) {
+ if (utterance == null) return Command.UNKNOWN;
+ String norm = normalize(utterance);
+ if (norm.isEmpty()) return Command.UNKNOWN;
+
+ Set tokens = new HashSet<>(Arrays.asList(norm.split(" ")));
+
+ if (containsAny(tokens, STOP_WORDS)) return Command.STOP;
+ if (containsAny(tokens, ANSWER_WORDS)) return Command.ANSWER;
+ if (containsAny(tokens, SKIP_WORDS)) return Command.SKIP;
+ if (containsAny(tokens, LOG_WORDS)) return Command.LOG;
+ if (isCallCq(norm, tokens)) return Command.CALL_CQ;
+ return Command.UNKNOWN;
+ }
+
+ /** Lowercase, strip everything but letters/digits, collapse whitespace. */
+ static String normalize(String utterance) {
+ String lower = utterance.toLowerCase(Locale.ROOT);
+ StringBuilder sb = new StringBuilder(lower.length());
+ for (int i = 0; i < lower.length(); i++) {
+ char c = lower.charAt(i);
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
+ sb.append(c);
+ } else {
+ sb.append(' ');
+ }
+ }
+ return sb.toString().trim().replaceAll("\\s+", " ");
+ }
+
+ private static boolean containsAny(Set tokens, Set words) {
+ for (String w : words) {
+ if (tokens.contains(w)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * "CQ" arrives from recognizers as the token "cq", the mondegreen
+ * "seek you", or two spelled letters "c q" — accept all three.
+ */
+ private static boolean isCallCq(String norm, Set tokens) {
+ if (tokens.contains("cq")) return true;
+ if (norm.contains("seek you")) return true;
+ return norm.contains("c q");
+ }
+}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java
new file mode 100644
index 000000000..e54b005e6
--- /dev/null
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java
@@ -0,0 +1,98 @@
+package com.k1af.ft8af.voice;
+
+/**
+ * Pure, Android-free builders for the exact spoken strings the voice
+ * assistant utters. English-only for v1 (speech only — UI strings still live
+ * in resources). All phrases are deliberately short (well under ~3 s of
+ * speech) so a stop-on-TX never has much to cut off.
+ *
+ * Callsigns are spelled letter-by-letter ("K1ABC" → "K 1 A B C") so the
+ * TTS engine reads them as call signs instead of trying to pronounce them as
+ * words.
+ */
+public final class VoicePhrases {
+ private VoicePhrases() {}
+
+ /** Mirrors {@code Ft8Message.SNR_UNKNOWN} without dragging that class in. */
+ public static final int SNR_UNKNOWN = Integer.MIN_VALUE;
+
+ /**
+ * Spell a callsign for TTS: one space between characters, '/' spoken as
+ * "stroke" (ham convention for portable/compound calls).
+ */
+ public static String spellCallsign(String callsign) {
+ if (callsign == null) return "";
+ String trimmed = callsign.trim();
+ StringBuilder sb = new StringBuilder(trimmed.length() * 2);
+ for (int i = 0; i < trimmed.length(); i++) {
+ char c = trimmed.charAt(i);
+ if (sb.length() > 0) sb.append(' ');
+ if (c == '/') {
+ sb.append("stroke");
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * SNR as speech: "minus 5", "plus 3", "zero". Empty string for the
+ * {@link #SNR_UNKNOWN} sentinel (the decoder can emit a valid message
+ * with no SNR — mirror DxAlertNotifier's body formatters and drop it).
+ */
+ public static String snrPhrase(int snr) {
+ if (snr == SNR_UNKNOWN) return "";
+ if (snr < 0) return "minus " + (-snr);
+ if (snr == 0) return "zero";
+ return "plus " + snr;
+ }
+
+ /** "K 1 A B C calling you, minus 5" (SNR clause dropped when unknown). */
+ public static String callingYou(String callsign, int snr) {
+ String base = spellCallsign(callsign) + " calling you";
+ String snrPart = snrPhrase(snr);
+ return snrPart.isEmpty() ? base : base + ", " + snrPart;
+ }
+
+ /** "QSO with K 1 A B C logged" */
+ public static String qsoLogged(String callsign) {
+ return "QSO with " + spellCallsign(callsign) + " logged";
+ }
+
+ /**
+ * "New country: Japan". The caller passes the resolved country name, or a
+ * pre-spelled callsign (via {@link #spellCallsign}) when no name resolved.
+ */
+ public static String newCountry(String spokenCountry) {
+ return "New country: " + spokenCountry;
+ }
+
+ /** "New prefix: W 1" — prefixes are call fragments, so always spelled. */
+ public static String newPrefix(String prefix) {
+ return "New prefix: " + spellCallsign(prefix);
+ }
+
+ // ---- Command echo confirmations (spoken after a voice command runs) ----
+
+ public static String echoCallingCq() {
+ return "Calling CQ";
+ }
+
+ public static String echoStopping() {
+ return "Stopping";
+ }
+
+ public static String echoSkipping() {
+ return "Back to CQ";
+ }
+
+ public static String echoLogged() {
+ return "Logged";
+ }
+
+ /** "Answering K 1 A B C" */
+ public static String echoAnswering(String callsign) {
+ return "Answering " + spellCallsign(callsign);
+ }
+}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java
index 82de9a795..e8829dd61 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java
@@ -127,6 +127,30 @@ public boolean isRunning() {
return isRunning;
}
+ /**
+ * Pure decision: is FT8 RX holding an Android audio-capture session (an
+ * AudioRecord) right now? True blocks the voice-command SpeechRecognizer —
+ * two capture clients fight under Android's concurrency rules and the
+ * loser (usually our decode chain) goes silent.
+ *
+ * @param running the recorder is running at all
+ * @param micSource audio comes from MicRecorder (not a LAN connector)
+ * @param usbDirect MicRecorder captures via direct libusb (no AudioRecord)
+ */
+ static boolean phoneMicCaptureInUse(boolean running, boolean micSource, boolean usbDirect) {
+ return running && micSource && !usbDirect;
+ }
+
+ /**
+ * Runtime "phone mic in use by FT8 RX" signal for the voice-command UI:
+ * true whenever this recorder holds an AudioRecord session (system mic or
+ * Android-routed USB input); false for direct-libusb USB capture and for
+ * LAN audio sources (ICOM WiFi / Flex), where the capture stack is free.
+ */
+ public boolean isPhoneMicInUse() {
+ return phoneMicCaptureInUse(isRunning, isMicRecord, micRecorder.isUsingUsbDirect());
+ }
+
/**
* Start recording. This method keeps the device in a continuous recording state.
* Recording data is retrieved through the listener class GetVoiceData.
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java
index 3cf65cf94..39a2fc598 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java
@@ -190,6 +190,17 @@ public MicRecorder(){
}
}
+ /**
+ * True when capture runs over the direct-libusb USB path — no AudioRecord
+ * exists, so Android's audio capture stack (and the phone mic) is free for
+ * other clients (e.g. the voice-command SpeechRecognizer). False whenever
+ * an AudioRecord is in play, including the "preferred device" USB-UAC
+ * route (that still holds an Android capture session).
+ */
+ public boolean isUsingUsbDirect() {
+ return useUsbAudio;
+ }
+
/**
* Open and configure a USB audio device for input.
*/
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt
index d138b946b..89a3466a1 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt
@@ -59,6 +59,7 @@ import radio.ks3ckc.ft8af.ui.components.SlotTimerBar
import radio.ks3ckc.ft8af.ui.components.TabBar
import radio.ks3ckc.ft8af.ui.components.TransmitGlow
import radio.ks3ckc.ft8af.ui.components.TxStrip
+import radio.ks3ckc.ft8af.ui.components.VoiceCommandButton
import radio.ks3ckc.ft8af.ui.components.selectBandIndex
import radio.ks3ckc.ft8af.ui.decode.DecodeScreen
import radio.ks3ckc.ft8af.ui.logbook.LogbookScreen
@@ -400,6 +401,18 @@ fun FT8AFApp(mainViewModel: MainViewModel) {
.padding(bottom = WaterfallBottomStripHeight),
)
}
+
+ // Voice-command push-to-talk mic (v1). Floats over the content
+ // just above the TX strip so it never resizes the waterfall's
+ // AndroidView or shifts list layouts. Renders nothing unless
+ // the voice-commands setting is on; tap is refused (with the
+ // reason) while FT8 RX is capturing through the phone mic.
+ VoiceCommandButton(
+ mainViewModel = mainViewModel,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 12.dp, bottom = 12.dp),
+ )
}
// Active QSO panel (docked) — slides up above TxStrip when a QSO is
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt
index 0a8671837..6c34085ac 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt
@@ -474,6 +474,42 @@ object FT8AFIcons {
drawPath(crown, tint, style = stroke)
}
}
+
+ /** Microphone — voice-command push-to-talk button. */
+ @Composable
+ fun Mic(
+ modifier: Modifier = Modifier,
+ color: Color = Color.Unspecified,
+ size: Dp = 22.dp,
+ strokeWidth: Float = 1.6f,
+ ) {
+ val tint = if (color == Color.Unspecified) androidx.compose.material3.MaterialTheme.colorScheme.onSurface else color
+ Canvas(modifier = modifier.then(Modifier.sizeOf(size))) {
+ val s = this.size.width / 24f
+ val stroke = strokeStyle(strokeWidth * s)
+ // Capsule body
+ drawRoundRect(
+ color = tint,
+ topLeft = Offset(9f * s, 3f * s),
+ size = Size(6f * s, 10f * s),
+ cornerRadius = CornerRadius(3f * s, 3f * s),
+ style = stroke,
+ )
+ // Cradle arc (open at the top)
+ drawArc(
+ color = tint,
+ startAngle = 0f,
+ sweepAngle = 180f,
+ useCenter = false,
+ topLeft = Offset(6f * s, 5f * s),
+ size = Size(12f * s, 12f * s),
+ style = stroke,
+ )
+ // Stand + base
+ drawLine(tint, Offset(12f * s, 17f * s), Offset(12f * s, 20f * s), stroke.width, StrokeCap.Round)
+ drawLine(tint, Offset(9f * s, 20f * s), Offset(15f * s, 20f * s), stroke.width, StrokeCap.Round)
+ }
+ }
}
// Extension to apply a dp size uniformly
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt
new file mode 100644
index 000000000..ca9bc605d
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt
@@ -0,0 +1,312 @@
+package radio.ks3ckc.ft8af.ui.components
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.k1af.ft8af.GeneralVariables
+import com.k1af.ft8af.MainViewModel
+import com.k1af.ft8af.R
+import com.k1af.ft8af.voice.VoiceCommandParser
+import com.k1af.ft8af.voice.VoicePhrases
+import radio.ks3ckc.ft8af.theme.Accent
+import radio.ks3ckc.ft8af.theme.BgSurface2
+import radio.ks3ckc.ft8af.theme.Border
+import radio.ks3ckc.ft8af.theme.TextFaint
+import radio.ks3ckc.ft8af.theme.TextPrimary
+
+/**
+ * Visual/interaction state of the voice-command push-to-talk button.
+ * Extracted as a pure function so the gating is unit-testable (the
+ * Composable below is a thin wrapper).
+ */
+internal enum class VoiceButtonState { HIDDEN, READY, BLOCKED_MIC }
+
+/**
+ * @param commandsEnabled the voiceCommandsEnabled user setting
+ * @param phoneMicInUse FT8 RX holds an Android audio-capture session
+ * (MainViewModel.isPhoneMicInUse) — a SpeechRecognizer
+ * would fight it, so the button is shown disabled with
+ * an explanatory toast instead of silently failing
+ */
+internal fun voiceButtonState(
+ commandsEnabled: Boolean,
+ phoneMicInUse: Boolean,
+): VoiceButtonState =
+ when {
+ !commandsEnabled -> VoiceButtonState.HIDDEN
+ phoneMicInUse -> VoiceButtonState.BLOCKED_MIC
+ else -> VoiceButtonState.READY
+ }
+
+/**
+ * Toast string resource for a recognizer error code, or null for codes that
+ * must stay silent: ERROR_CLIENT is what the recognizer emits after a
+ * user-initiated cancel() (second tap), and toasting it would make a
+ * deliberate cancel look like a failure. Pure so the mapping is testable.
+ */
+internal fun voiceErrorToastRes(errorCode: Int): Int? =
+ when (errorCode) {
+ SpeechRecognizer.ERROR_CLIENT -> null
+ SpeechRecognizer.ERROR_NO_MATCH,
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
+ -> R.string.voice_not_understood
+ else -> R.string.voice_recognizer_error
+ }
+
+/**
+ * The spoken confirmation for an executed voice command, or null when there
+ * is nothing to echo (UNKNOWN, or ANSWER with no candidate — those get toasts
+ * instead). Pure; the phrases themselves live in [VoicePhrases].
+ */
+internal fun echoPhraseFor(
+ command: VoiceCommandParser.Command,
+ answeredCallsign: String?,
+): String? =
+ when (command) {
+ VoiceCommandParser.Command.CALL_CQ -> VoicePhrases.echoCallingCq()
+ VoiceCommandParser.Command.STOP -> VoicePhrases.echoStopping()
+ VoiceCommandParser.Command.SKIP -> VoicePhrases.echoSkipping()
+ VoiceCommandParser.Command.LOG -> VoicePhrases.echoLogged()
+ VoiceCommandParser.Command.ANSWER ->
+ answeredCallsign?.let { VoicePhrases.echoAnswering(it) }
+ VoiceCommandParser.Command.UNKNOWN -> null
+ }
+
+/**
+ * Push-to-talk mic button for one-shot voice commands ("answer", "call CQ",
+ * "stop", "skip", "log it"). NOT always-listening: one tap = one recognition
+ * pass, on-device preferred. Visible only when the voice-commands setting is
+ * on; functionally disabled (tap explains why) while FT8 RX is capturing via
+ * the phone-mic AudioRecord path — v1 never pauses the capture chain.
+ */
+@Composable
+fun VoiceCommandButton(
+ mainViewModel: MainViewModel,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+
+ // Recorder run-state, READ below so this composable subscribes and
+ // re-evaluates the mic gate when recording starts/stops. Source changes
+ // that don't flip the run-state (mic <-> LAN) are picked up on the next
+ // ambient recomposition (slot timer / TX state churn in FT8AFApp).
+ val recorderRunning by mainViewModel.mutableHamRecordIsRunning.observeAsState(false)
+
+ // Observable mirror of the setting — a plain static read here would only be
+ // re-evaluated on unrelated recompositions, so the button wouldn't appear or
+ // disappear until app restart when the toggle flips.
+ val commandsEnabled by GeneralVariables.mutableVoiceCommandsEnabled
+ .observeAsState(GeneralVariables.voiceCommandsEnabled)
+
+ val state = voiceButtonState(
+ commandsEnabled = commandsEnabled == true,
+ // isPhoneMicInUse() already includes the running check; the LiveData
+ // read is ANDed in to make this a tracked snapshot dependency.
+ phoneMicInUse = recorderRunning == true && mainViewModel.isPhoneMicInUse(),
+ )
+ if (state == VoiceButtonState.HIDDEN) return
+
+ var listening by remember { mutableStateOf(false) }
+ // One recognizer per composition, created lazily on first tap.
+ val recognizerHolder = remember { arrayOfNulls(1) }
+ DisposableEffect(Unit) {
+ onDispose {
+ recognizerHolder[0]?.destroy()
+ recognizerHolder[0] = null
+ }
+ }
+
+ val readyDesc = stringResource(R.string.voice_button_desc)
+ val busyDesc = stringResource(R.string.voice_button_mic_busy_desc)
+ val desc = if (state == VoiceButtonState.BLOCKED_MIC) busyDesc else readyDesc
+
+ val tint = when {
+ listening -> Accent
+ state == VoiceButtonState.BLOCKED_MIC -> TextFaint
+ else -> TextPrimary
+ }
+
+ IconButton(
+ onClick = {
+ when {
+ state == VoiceButtonState.BLOCKED_MIC ->
+ Toast
+ .makeText(context, context.getString(R.string.voice_mic_busy), Toast.LENGTH_LONG)
+ .show()
+ listening -> {
+ // Second tap cancels the in-flight listen.
+ recognizerHolder[0]?.cancel()
+ listening = false
+ }
+ else -> listening = startVoiceRecognition(
+ context = context,
+ recognizerHolder = recognizerHolder,
+ onDone = { transcription ->
+ listening = false
+ handleVoiceResult(mainViewModel, context, transcription)
+ },
+ onError = { errorCode ->
+ listening = false
+ val msgRes = voiceErrorToastRes(errorCode)
+ if (msgRes != null) {
+ Toast
+ .makeText(context, context.getString(msgRes), Toast.LENGTH_SHORT)
+ .show()
+ }
+ },
+ )
+ }
+ },
+ modifier = modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(BgSurface2.copy(alpha = 0.92f))
+ .border(1.dp, if (listening) Accent else Border, CircleShape)
+ .semantics { contentDescription = desc },
+ ) {
+ FT8AFIcons.Mic(color = tint, size = 22.dp)
+ }
+}
+
+/**
+ * Kick off a single on-device recognition pass. Returns true when listening
+ * actually started (recognition available and recognizer created).
+ */
+private fun startVoiceRecognition(
+ context: Context,
+ recognizerHolder: Array,
+ onDone: (String?) -> Unit,
+ onError: (Int) -> Unit,
+): Boolean {
+ if (!SpeechRecognizer.isRecognitionAvailable(context)) {
+ Toast
+ .makeText(context, context.getString(R.string.voice_recognizer_unavailable), Toast.LENGTH_LONG)
+ .show()
+ return false
+ }
+ val recognizer = recognizerHolder[0] ?: SpeechRecognizer.createSpeechRecognizer(context)
+ .also { recognizerHolder[0] = it }
+
+ recognizer.setRecognitionListener(object : RecognitionListener {
+ override fun onResults(results: Bundle?) {
+ val top = results
+ ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ ?.firstOrNull()
+ onDone(top)
+ }
+
+ override fun onError(error: Int) = onError(error)
+
+ override fun onReadyForSpeech(params: Bundle?) {}
+
+ override fun onBeginningOfSpeech() {}
+
+ override fun onRmsChanged(rmsdB: Float) {}
+
+ override fun onBufferReceived(buffer: ByteArray?) {}
+
+ override fun onEndOfSpeech() {}
+
+ override fun onPartialResults(partialResults: Bundle?) {}
+
+ override fun onEvent(
+ eventType: Int,
+ params: Bundle?,
+ ) {}
+ })
+
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
+ )
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US")
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
+ // Small fixed grammar — the on-device recognizer is plenty, and no
+ // audio leaves the phone.
+ putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true)
+ }
+ recognizer.startListening(intent)
+ Toast
+ .makeText(context, context.getString(R.string.voice_listening), Toast.LENGTH_SHORT)
+ .show()
+ return true
+}
+
+/** Parse the transcription, run the mapped action, and give feedback. */
+private fun handleVoiceResult(
+ mainViewModel: MainViewModel,
+ context: Context,
+ transcription: String?,
+) {
+ val command = VoiceCommandParser.parse(transcription)
+ GeneralVariables.fileLog("VOICE command [" + (transcription ?: "") + "] -> " + command)
+
+ var answeredCallsign: String? = null
+ when (command) {
+ VoiceCommandParser.Command.ANSWER -> {
+ answeredCallsign = mainViewModel.voiceAnswerBestCaller()
+ if (answeredCallsign == null) {
+ Toast
+ .makeText(context, context.getString(R.string.voice_no_caller), Toast.LENGTH_SHORT)
+ .show()
+ return
+ }
+ }
+ VoiceCommandParser.Command.CALL_CQ -> {
+ if (GeneralVariables.myCallsign.isNullOrEmpty()) {
+ Toast
+ .makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT)
+ .show()
+ return
+ }
+ // The canonical CQ-start sequence (same as the TX strip's CQ button).
+ mainViewModel.ft8TransmitSignal.setTransmitFreeText(false)
+ mainViewModel.ft8TransmitSignal.userResetToCQ()
+ mainViewModel.ft8TransmitSignal.setActivated(true)
+ GeneralVariables.resetLaunchSupervision()
+ }
+ VoiceCommandParser.Command.STOP ->
+ mainViewModel.ft8TransmitSignal.setActivated(false)
+ VoiceCommandParser.Command.SKIP ->
+ mainViewModel.ft8TransmitSignal.userResetToCQ()
+ VoiceCommandParser.Command.LOG ->
+ mainViewModel.ft8TransmitSignal.forceLogAndMoveOn()
+ VoiceCommandParser.Command.UNKNOWN -> {
+ Toast
+ .makeText(context, context.getString(R.string.voice_not_understood), Toast.LENGTH_SHORT)
+ .show()
+ return
+ }
+ }
+
+ // Spoken confirmation. Unique utterance id — echoes are never deduped —
+ // and the announcer's transmit gate silently drops it mid-TX.
+ val echo = echoPhraseFor(command, answeredCallsign)
+ if (echo != null) {
+ mainViewModel.voiceAnnouncer.speak(echo, "VECHO:" + System.nanoTime())
+ }
+}
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt
index 4a78839e1..1ba5f2bea 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt
@@ -69,6 +69,7 @@ private enum class SettingsCategory {
TRANSMISSION,
TIME_SYNC,
DECODE_FILTERS,
+ VOICE,
LOGGING,
ROAD_TRIP,
ADVANCED,
@@ -142,6 +143,8 @@ fun SettingsScreen(
TimeSyncSettings(mainViewModel, onBack = { currentCategory = null })
SettingsCategory.DECODE_FILTERS ->
DecodeFilterSettings(mainViewModel, onBack = { currentCategory = null })
+ SettingsCategory.VOICE ->
+ VoiceSettings(mainViewModel, onBack = { currentCategory = null })
SettingsCategory.LOGGING ->
LoggingSettings(mainViewModel, onBack = { currentCategory = null })
SettingsCategory.ROAD_TRIP ->
@@ -321,6 +324,13 @@ private fun SettingsLanding(
onClick = { onOpenCategory(SettingsCategory.DECODE_FILTERS) },
)
SectionDivider()
+ SettingsRow(
+ label = stringResource(R.string.settings_cat_voice),
+ description = stringResource(R.string.settings_cat_voice_desc),
+ showChevron = true,
+ onClick = { onOpenCategory(SettingsCategory.VOICE) },
+ )
+ SectionDivider()
SettingsRow(
label = stringResource(R.string.settings_cat_logging),
showChevron = true,
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt
new file mode 100644
index 000000000..1d77e9531
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt
@@ -0,0 +1,136 @@
+package radio.ks3ckc.ft8af.ui.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.k1af.ft8af.GeneralVariables
+import com.k1af.ft8af.MainViewModel
+import com.k1af.ft8af.R
+import radio.ks3ckc.ft8af.theme.TextFaint
+import radio.ks3ckc.ft8af.ui.components.GlassCard
+import radio.ks3ckc.ft8af.ui.components.SettingsRow
+
+/**
+ * Voice-assistant settings: per-event spoken announcement toggles (TTS) and
+ * the push-to-talk voice-command button toggle (STT). All persisted through
+ * the SQLite config table (writeConfig) and hydrated in DatabaseOpr — same
+ * pattern as the needed-DX alert toggles.
+ */
+@Composable
+fun VoiceSettings(
+ mainViewModel: MainViewModel,
+ onBack: () -> Unit,
+) {
+ var announceCalling by remember { mutableStateOf(GeneralVariables.voiceAnnounceCalling) }
+ var announceQso by remember { mutableStateOf(GeneralVariables.voiceAnnounceQsoComplete) }
+ var announceNewDxcc by remember { mutableStateOf(GeneralVariables.voiceAnnounceNewDxcc) }
+ var announceNewPrefix by remember { mutableStateOf(GeneralVariables.voiceAnnounceNewPrefix) }
+ var commandsEnabled by remember { mutableStateOf(GeneralVariables.voiceCommandsEnabled) }
+
+ SettingsDetailScaffold(
+ title = stringResource(R.string.settings_cat_voice),
+ onBack = onBack,
+ ) {
+ // =====================================================================
+ // SPOKEN ANNOUNCEMENTS (TTS)
+ // =====================================================================
+ SettingsSection(title = stringResource(R.string.settings_section_voice_announce)) {
+ GlassCard(modifier = Modifier.fillMaxWidth()) {
+ Column {
+ SettingsRow(
+ label = stringResource(R.string.settings_voice_announce_calling),
+ description = stringResource(R.string.settings_voice_announce_calling_desc),
+ toggle = announceCalling,
+ onToggleChange = { checked ->
+ announceCalling = checked
+ GeneralVariables.voiceAnnounceCalling = checked
+ mainViewModel.databaseOpr.writeConfig(
+ "voiceAnnounceCalling", if (checked) "1" else "0", null,
+ )
+ },
+ )
+ SectionDivider()
+ SettingsRow(
+ label = stringResource(R.string.settings_voice_announce_qso),
+ description = stringResource(R.string.settings_voice_announce_qso_desc),
+ toggle = announceQso,
+ onToggleChange = { checked ->
+ announceQso = checked
+ GeneralVariables.voiceAnnounceQsoComplete = checked
+ mainViewModel.databaseOpr.writeConfig(
+ "voiceAnnounceQsoComplete", if (checked) "1" else "0", null,
+ )
+ },
+ )
+ SectionDivider()
+ SettingsRow(
+ label = stringResource(R.string.settings_voice_announce_new_dxcc),
+ description = stringResource(R.string.settings_voice_announce_new_dxcc_desc),
+ toggle = announceNewDxcc,
+ onToggleChange = { checked ->
+ announceNewDxcc = checked
+ GeneralVariables.voiceAnnounceNewDxcc = checked
+ mainViewModel.databaseOpr.writeConfig(
+ "voiceAnnounceNewDxcc", if (checked) "1" else "0", null,
+ )
+ },
+ )
+ SectionDivider()
+ SettingsRow(
+ label = stringResource(R.string.settings_voice_announce_new_prefix),
+ description = stringResource(R.string.settings_voice_announce_new_prefix_desc),
+ toggle = announceNewPrefix,
+ onToggleChange = { checked ->
+ announceNewPrefix = checked
+ GeneralVariables.voiceAnnounceNewPrefix = checked
+ mainViewModel.databaseOpr.writeConfig(
+ "voiceAnnounceNewPrefix", if (checked) "1" else "0", null,
+ )
+ },
+ )
+ }
+ }
+ Text(
+ text = stringResource(R.string.settings_voice_tx_note),
+ color = TextFaint,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ }
+
+ // =====================================================================
+ // VOICE COMMANDS (STT)
+ // =====================================================================
+ SettingsSection(title = stringResource(R.string.settings_section_voice_commands)) {
+ GlassCard(modifier = Modifier.fillMaxWidth()) {
+ SettingsRow(
+ label = stringResource(R.string.settings_voice_commands),
+ description = stringResource(R.string.settings_voice_commands_desc),
+ toggle = commandsEnabled,
+ onToggleChange = { checked ->
+ commandsEnabled = checked
+ GeneralVariables.voiceCommandsEnabled = checked
+ // The mic button on the (already-composed) main screen
+ // observes this mirror; without it the change only shows
+ // after an app restart.
+ GeneralVariables.mutableVoiceCommandsEnabled.value = checked
+ mainViewModel.databaseOpr.writeConfig(
+ "voiceCommandsEnabled", if (checked) "1" else "0", null,
+ )
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml
index 22827d305..4da4fc7cb 100644
--- a/ft8af/app/src/main/res/values/strings_compose.xml
+++ b/ft8af/app/src/main/res/values/strings_compose.xml
@@ -721,6 +721,31 @@
Callsign watchlist
Comma-separated calls or prefixes (e.g. 3Y0, W1AW, TX7). An alert fires the instant a match is decoded — matched by prefix, so a DXpedition prefix catches every variant.
Watchlist: %1$s
+
+
+ Voice Assistant
+ Spoken announcements and hands-free voice commands
+ Spoken announcements
+ Voice commands
+ Station calling me
+ Speak the callsign and signal report when a station calls you
+ QSO completed
+ Speak a confirmation when a QSO is logged
+ New DXCC CQ
+ Speak when an unworked DXCC entity calls CQ
+ New prefix CQ
+ Speak when an unworked WPX prefix calls CQ
+ Voice command button
+ Show a push-to-talk mic button on the main screen. Say \"answer\", \"call CQ\", \"stop\", \"skip\", or \"log it\". Unavailable while FT8 receive is capturing through Android audio (phone mic or Android-routed USB).
+ Announcements never play while transmitting, so they can\'t leak into the rig audio.
+ Voice command
+ Voice command unavailable: audio input in use by FT8 receive
+ Voice commands unavailable: FT8 receive is capturing through Android audio (phone mic or Android-routed USB). Direct-USB or network rig audio frees it.
+ Listening…
+ Didn\'t catch a command. Try \"answer\", \"call CQ\", \"stop\", \"skip\", or \"log it\".
+ No station to answer
+ Speech recognition is not available on this device
+ Speech recognition error
Background receiving
Keeps FT8 decoding running while the app is in the background or the screen is off
FT8AF is receiving
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java
index a2d5db073..b5c25ca81 100644
--- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java
@@ -287,6 +287,59 @@ public void keepScreenOn_absentFromConfigKeepsThePreviousBehaviour() {
assertThat(GeneralVariables.keepScreenOn).isTrue();
}
+ // ---- voice-command toggle + its observable mirror -------------------------
+ // The main-screen mic button observes mutableVoiceCommandsEnabled (a plain
+ // static read there is never recomposed), so hydration must update BOTH the
+ // static and the LiveData mirror or a persisted "on" renders no button until
+ // the setting is touched.
+
+ @Test
+ public void voiceCommandsEnabled_hydrationUpdatesStaticAndLiveDataMirror() {
+ boolean origEnabled = GeneralVariables.voiceCommandsEnabled;
+ Boolean origMirror = GeneralVariables.mutableVoiceCommandsEnabled.getValue();
+ try {
+ GeneralVariables.voiceCommandsEnabled = false;
+ GeneralVariables.mutableVoiceCommandsEnabled.setValue(false);
+
+ Map config = new LinkedHashMap<>();
+ config.put("voiceCommandsEnabled", "1");
+ opr.writeConfigSync(config);
+
+ hydrate();
+ // postValue lands via the main looper; run it so getValue() sees it.
+ org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle();
+
+ assertThat(GeneralVariables.voiceCommandsEnabled).isTrue();
+ assertThat(GeneralVariables.mutableVoiceCommandsEnabled.getValue()).isTrue();
+ } finally {
+ GeneralVariables.voiceCommandsEnabled = origEnabled;
+ GeneralVariables.mutableVoiceCommandsEnabled.setValue(origMirror);
+ }
+ }
+
+ @Test
+ public void voiceCommandsEnabled_hydrationTurnsMirrorBackOff() {
+ boolean origEnabled = GeneralVariables.voiceCommandsEnabled;
+ Boolean origMirror = GeneralVariables.mutableVoiceCommandsEnabled.getValue();
+ try {
+ GeneralVariables.voiceCommandsEnabled = true;
+ GeneralVariables.mutableVoiceCommandsEnabled.setValue(true);
+
+ Map config = new LinkedHashMap<>();
+ config.put("voiceCommandsEnabled", "0");
+ opr.writeConfigSync(config);
+
+ hydrate();
+ org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle();
+
+ assertThat(GeneralVariables.voiceCommandsEnabled).isFalse();
+ assertThat(GeneralVariables.mutableVoiceCommandsEnabled.getValue()).isFalse();
+ } finally {
+ GeneralVariables.voiceCommandsEnabled = origEnabled;
+ GeneralVariables.mutableVoiceCommandsEnabled.setValue(origMirror);
+ }
+ }
+
// ---- self-syncing clock (ClockSelfSync) -----------------------------------
// New "autoSyncClockFromDecodes" key: the classic bug this guards against is
// adding the field + writeConfig on toggle but forgetting the hydration arm,
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java
new file mode 100644
index 000000000..44cf7d0fb
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java
@@ -0,0 +1,172 @@
+package com.k1af.ft8af.voice;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.k1af.ft8af.voice.VoiceAnnouncementDecisions.Kind;
+
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Pure-JVM tests for {@link VoiceAnnouncementDecisions} (no Android runtime needed). */
+public class VoiceAnnouncementDecisionsTest {
+
+ private static Set newSpokenSet() {
+ return Collections.newSetFromMap(new ConcurrentHashMap<>());
+ }
+
+ // ---- decide: toggle gating ------------------------------------------
+
+ @Test
+ public void decide_callingMe_firesOnlyWhenToggleOn() {
+ assertThat(VoiceAnnouncementDecisions.decide(
+ true, false, false, true, false, false, false, false))
+ .isEqualTo(Kind.CALLING_ME);
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, false, false, true, false, false, false, false))
+ .isNull();
+ }
+
+ @Test
+ public void decide_newDxcc_firesOnlyWhenToggleOnAndCq() {
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, true, false, false, true, true, false, false))
+ .isEqualTo(Kind.NEW_DXCC);
+ // Toggle off
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, false, false, false, true, true, false, false))
+ .isNull();
+ // Not a CQ — needed-DX announcements are CQ-gated like the alerts.
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, true, false, false, false, true, false, false))
+ .isNull();
+ }
+
+ @Test
+ public void decide_newPrefix_firesOnlyWhenToggleOnAndCq() {
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, false, true, false, true, false, true, false))
+ .isEqualTo(Kind.NEW_PREFIX);
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, false, false, false, true, false, true, false))
+ .isNull();
+ assertThat(VoiceAnnouncementDecisions.decide(
+ false, false, true, false, false, false, true, false))
+ .isNull();
+ }
+
+ // ---- decide: priority ------------------------------------------------
+
+ @Test
+ public void decide_addressedToMeBeatsEverything() {
+ // All toggles on, message qualifies in every category: calling-me wins.
+ assertThat(VoiceAnnouncementDecisions.decide(
+ true, true, true, true, true, true, true, false))
+ .isEqualTo(Kind.CALLING_ME);
+ }
+
+ @Test
+ public void decide_newDxccBeatsNewPrefix() {
+ assertThat(VoiceAnnouncementDecisions.decide(
+ true, true, true, false, true, true, true, false))
+ .isEqualTo(Kind.NEW_DXCC);
+ }
+
+ @Test
+ public void decide_blockedIsAlwaysSilent() {
+ assertThat(VoiceAnnouncementDecisions.decide(
+ true, true, true, true, true, true, true, true))
+ .isNull();
+ }
+
+ @Test
+ public void decide_nothingQualifies() {
+ assertThat(VoiceAnnouncementDecisions.decide(
+ true, true, true, false, true, false, false, false))
+ .isNull();
+ }
+
+ // ---- anyDecodeAnnounceEnabled ----------------------------------------
+
+ @Test
+ public void anyDecodeAnnounceEnabled_coversEachToggle() {
+ assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(false, false, false))
+ .isFalse();
+ assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(true, false, false))
+ .isTrue();
+ assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(false, true, false))
+ .isTrue();
+ assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(false, false, true))
+ .isTrue();
+ }
+
+ // ---- claim: dedup + mute gate ----------------------------------------
+
+ @Test
+ public void claim_sameStationAnnouncedOncePerSession() {
+ Set spoken = newSpokenSet();
+ String key = VoiceAnnouncementDecisions.callingMeKey("K1ABC");
+ assertThat(VoiceAnnouncementDecisions.claim(spoken, key, true)).isTrue();
+ // Same station on the next decode pass / cycle: silent.
+ assertThat(VoiceAnnouncementDecisions.claim(spoken, key, true)).isFalse();
+ }
+
+ @Test
+ public void claim_doesNotBurnKeyWhileMuted() {
+ Set spoken = newSpokenSet();
+ String key = VoiceAnnouncementDecisions.callingMeKey("K1ABC");
+ // TX active — no speech, and the key must survive for the next cycle.
+ assertThat(VoiceAnnouncementDecisions.claim(spoken, key, false)).isFalse();
+ assertThat(spoken).isEmpty();
+ // TX over, station still calling: announce now.
+ assertThat(VoiceAnnouncementDecisions.claim(spoken, key, true)).isTrue();
+ }
+
+ @Test
+ public void claim_differentStationsAreIndependent() {
+ Set spoken = newSpokenSet();
+ assertThat(VoiceAnnouncementDecisions.claim(
+ spoken, VoiceAnnouncementDecisions.callingMeKey("K1ABC"), true)).isTrue();
+ assertThat(VoiceAnnouncementDecisions.claim(
+ spoken, VoiceAnnouncementDecisions.callingMeKey("W9XYZ"), true)).isTrue();
+ }
+
+ // ---- key formats -------------------------------------------------------
+
+ @Test
+ public void keys_areNamespacedAndNormalized() {
+ assertThat(VoiceAnnouncementDecisions.callingMeKey(" k1abc "))
+ .isEqualTo("VCALL:K1ABC");
+ assertThat(VoiceAnnouncementDecisions.newDxccKey("Japan"))
+ .isEqualTo("VDXCC:JAPAN");
+ assertThat(VoiceAnnouncementDecisions.newPrefixKey("w1"))
+ .isEqualTo("VPREFIX:W1");
+ assertThat(VoiceAnnouncementDecisions.qsoCompleteKey("K1ABC", "2026-08-02 12:00"))
+ .isEqualTo("VQSO:K1ABC|2026-08-02 12:00");
+ }
+
+ @Test
+ public void keys_nullSafe() {
+ assertThat(VoiceAnnouncementDecisions.callingMeKey(null)).isEqualTo("VCALL:");
+ assertThat(VoiceAnnouncementDecisions.qsoCompleteKey(null, null)).isEqualTo("VQSO:|");
+ }
+
+ @Test
+ public void keys_localeStableUnderTurkishDefaultLocale() {
+ // Default-locale toUpperCase() in tr-TR maps 'i' -> dotted 'İ', so the
+ // same station would produce different dedup keys on a Turkish device.
+ // norm() must use Locale.ROOT.
+ java.util.Locale original = java.util.Locale.getDefault();
+ try {
+ java.util.Locale.setDefault(new java.util.Locale("tr", "TR"));
+ assertThat(VoiceAnnouncementDecisions.callingMeKey("ti5abc"))
+ .isEqualTo("VCALL:TI5ABC");
+ assertThat(VoiceAnnouncementDecisions.newDxccKey("Liberia"))
+ .isEqualTo("VDXCC:LIBERIA");
+ } finally {
+ java.util.Locale.setDefault(original);
+ }
+ }
+}
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java
new file mode 100644
index 000000000..140fb3c97
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java
@@ -0,0 +1,86 @@
+package com.k1af.ft8af.voice;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Pure-JVM tests for {@link VoiceAnswerSelector}. The selector is generic, so
+ * these use a tiny stand-in message type instead of the Android-tied
+ * {@code Ft8Message}.
+ */
+public class VoiceAnswerSelectorTest {
+
+ /** Minimal decode stand-in: sender + whether it's addressed to me. */
+ private static final class Msg {
+ final String from;
+ final boolean toMe;
+
+ Msg(String from, boolean toMe) {
+ this.from = from;
+ this.toMe = toMe;
+ }
+ }
+
+ private static Msg pick(List decodes, String queueHead) {
+ return VoiceAnswerSelector.pick(decodes, m -> m.toMe, m -> m.from, queueHead);
+ }
+
+ @Test
+ public void callingMeBeatsQueueHead() {
+ Msg queued = new Msg("W9XYZ", false);
+ Msg callingMe = new Msg("K1ABC", true);
+ assertThat(pick(Arrays.asList(queued, callingMe), "W9XYZ")).isSameInstanceAs(callingMe);
+ }
+
+ @Test
+ public void newestCallingMeWins() {
+ Msg older = new Msg("K1ABC", true);
+ Msg newer = new Msg("W9XYZ", true);
+ // List is oldest-first; the selector must take the newest (last).
+ assertThat(pick(Arrays.asList(older, newer), null)).isSameInstanceAs(newer);
+ }
+
+ @Test
+ public void fallsBackToQueueHeadNewestDecode() {
+ Msg other = new Msg("N0CAL", false);
+ Msg queuedOld = new Msg("W9XYZ", false);
+ Msg queuedNew = new Msg("W9XYZ", false);
+ assertThat(pick(Arrays.asList(queuedOld, other, queuedNew), "W9XYZ"))
+ .isSameInstanceAs(queuedNew);
+ }
+
+ @Test
+ public void queueHeadMatchIsCaseInsensitiveAndTrimmed() {
+ Msg queued = new Msg("w9xyz", false);
+ assertThat(pick(Collections.singletonList(queued), " W9XYZ ")).isSameInstanceAs(queued);
+ }
+
+ @Test
+ public void emptyListYieldsNull() {
+ assertThat(pick(new ArrayList<>(), "W9XYZ")).isNull();
+ assertThat(pick(null, "W9XYZ")).isNull();
+ }
+
+ @Test
+ public void noCandidateYieldsNull() {
+ Msg other = new Msg("N0CAL", false);
+ assertThat(pick(Collections.singletonList(other), null)).isNull();
+ // Queue head not present in the decode list either.
+ assertThat(pick(Collections.singletonList(other), "W9XYZ")).isNull();
+ }
+
+ @Test
+ public void nullAndSenderlessRowsAreSkipped() {
+ Msg callingMe = new Msg("K1ABC", true);
+ Msg blankSender = new Msg(" ", true);
+ Msg nullSender = new Msg(null, true);
+ List decodes = Arrays.asList(callingMe, null, blankSender, nullSender);
+ assertThat(pick(decodes, null)).isSameInstanceAs(callingMe);
+ }
+}
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java
new file mode 100644
index 000000000..29d637c02
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java
@@ -0,0 +1,120 @@
+package com.k1af.ft8af.voice;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.k1af.ft8af.voice.VoiceCommandParser.Command;
+
+import org.junit.Test;
+
+/** Pure-JVM tests for {@link VoiceCommandParser} (no Android runtime needed). */
+public class VoiceCommandParserTest {
+
+ // ---- ANSWER --------------------------------------------------------
+
+ @Test
+ public void answer_plain() {
+ assertThat(VoiceCommandParser.parse("answer")).isEqualTo(Command.ANSWER);
+ }
+
+ @Test
+ public void answer_withNoiseWords() {
+ assertThat(VoiceCommandParser.parse("answer him")).isEqualTo(Command.ANSWER);
+ assertThat(VoiceCommandParser.parse("answer them")).isEqualTo(Command.ANSWER);
+ assertThat(VoiceCommandParser.parse("please answer the station")).isEqualTo(Command.ANSWER);
+ }
+
+ @Test
+ public void answer_replyVariant() {
+ assertThat(VoiceCommandParser.parse("reply")).isEqualTo(Command.ANSWER);
+ assertThat(VoiceCommandParser.parse("reply to him")).isEqualTo(Command.ANSWER);
+ }
+
+ @Test
+ public void answer_capitalizationAndPunctuation() {
+ assertThat(VoiceCommandParser.parse(" Answer! ")).isEqualTo(Command.ANSWER);
+ }
+
+ // ---- CALL_CQ -------------------------------------------------------
+
+ @Test
+ public void callCq_variants() {
+ assertThat(VoiceCommandParser.parse("call cq")).isEqualTo(Command.CALL_CQ);
+ assertThat(VoiceCommandParser.parse("cq")).isEqualTo(Command.CALL_CQ);
+ assertThat(VoiceCommandParser.parse("CQ")).isEqualTo(Command.CALL_CQ);
+ assertThat(VoiceCommandParser.parse("start calling CQ")).isEqualTo(Command.CALL_CQ);
+ }
+
+ @Test
+ public void callCq_recognizerMondegreens() {
+ // Recognizers routinely transcribe "CQ" as "seek you" or spelled letters.
+ assertThat(VoiceCommandParser.parse("call seek you")).isEqualTo(Command.CALL_CQ);
+ assertThat(VoiceCommandParser.parse("call c q")).isEqualTo(Command.CALL_CQ);
+ }
+
+ // ---- STOP ----------------------------------------------------------
+
+ @Test
+ public void stop_variants() {
+ assertThat(VoiceCommandParser.parse("stop")).isEqualTo(Command.STOP);
+ assertThat(VoiceCommandParser.parse("stop transmitting")).isEqualTo(Command.STOP);
+ assertThat(VoiceCommandParser.parse("halt")).isEqualTo(Command.STOP);
+ assertThat(VoiceCommandParser.parse("cancel")).isEqualTo(Command.STOP);
+ }
+
+ @Test
+ public void stop_beatsCqWhenBothPresent() {
+ // "stop calling cq" must stop, not start a CQ run.
+ assertThat(VoiceCommandParser.parse("stop calling cq")).isEqualTo(Command.STOP);
+ }
+
+ // ---- SKIP ----------------------------------------------------------
+
+ @Test
+ public void skip_variants() {
+ assertThat(VoiceCommandParser.parse("skip")).isEqualTo(Command.SKIP);
+ assertThat(VoiceCommandParser.parse("skip him")).isEqualTo(Command.SKIP);
+ assertThat(VoiceCommandParser.parse("next")).isEqualTo(Command.SKIP);
+ }
+
+ // ---- LOG -----------------------------------------------------------
+
+ @Test
+ public void log_variants() {
+ assertThat(VoiceCommandParser.parse("log it")).isEqualTo(Command.LOG);
+ assertThat(VoiceCommandParser.parse("log")).isEqualTo(Command.LOG);
+ assertThat(VoiceCommandParser.parse("logged")).isEqualTo(Command.LOG);
+ }
+
+ // ---- UNKNOWN -------------------------------------------------------
+
+ @Test
+ public void unknown_forUnrelatedSpeech() {
+ assertThat(VoiceCommandParser.parse("what's the weather")).isEqualTo(Command.UNKNOWN);
+ assertThat(VoiceCommandParser.parse("hello world")).isEqualTo(Command.UNKNOWN);
+ // "call" alone is not a command — only "call cq" is.
+ assertThat(VoiceCommandParser.parse("call")).isEqualTo(Command.UNKNOWN);
+ }
+
+ @Test
+ public void unknown_forNullEmptyAndPunctuationOnly() {
+ assertThat(VoiceCommandParser.parse(null)).isEqualTo(Command.UNKNOWN);
+ assertThat(VoiceCommandParser.parse("")).isEqualTo(Command.UNKNOWN);
+ assertThat(VoiceCommandParser.parse(" ")).isEqualTo(Command.UNKNOWN);
+ assertThat(VoiceCommandParser.parse("!?.,")).isEqualTo(Command.UNKNOWN);
+ }
+
+ @Test
+ public void unknown_keywordsMustBeWholeTokens() {
+ // Keyword embedded in a longer word must not match ("nextel" != "next").
+ assertThat(VoiceCommandParser.parse("nextel")).isEqualTo(Command.UNKNOWN);
+ assertThat(VoiceCommandParser.parse("catalog")).isEqualTo(Command.UNKNOWN);
+ assertThat(VoiceCommandParser.parse("unstoppable")).isEqualTo(Command.UNKNOWN);
+ }
+
+ // ---- normalize -----------------------------------------------------
+
+ @Test
+ public void normalize_stripsPunctuationAndCollapsesWhitespace() {
+ assertThat(VoiceCommandParser.normalize(" Call, CQ! now ")).isEqualTo("call cq now");
+ }
+}
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java
new file mode 100644
index 000000000..dd73d4e10
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java
@@ -0,0 +1,100 @@
+package com.k1af.ft8af.voice;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/** Pure-JVM tests for {@link VoicePhrases} (no Android runtime needed). */
+public class VoicePhrasesTest {
+
+ // ---- spellCallsign ---------------------------------------------------
+
+ @Test
+ public void spellCallsign_spacesEveryCharacter() {
+ assertThat(VoicePhrases.spellCallsign("K1ABC")).isEqualTo("K 1 A B C");
+ }
+
+ @Test
+ public void spellCallsign_slashSpokenAsStroke() {
+ assertThat(VoicePhrases.spellCallsign("EA8/K1ABC"))
+ .isEqualTo("E A 8 stroke K 1 A B C");
+ }
+
+ @Test
+ public void spellCallsign_nullAndBlankAreEmpty() {
+ assertThat(VoicePhrases.spellCallsign(null)).isEmpty();
+ assertThat(VoicePhrases.spellCallsign(" ")).isEmpty();
+ }
+
+ // ---- snrPhrase ---------------------------------------------------------
+
+ @Test
+ public void snrPhrase_negative() {
+ assertThat(VoicePhrases.snrPhrase(-5)).isEqualTo("minus 5");
+ }
+
+ @Test
+ public void snrPhrase_positive() {
+ assertThat(VoicePhrases.snrPhrase(3)).isEqualTo("plus 3");
+ }
+
+ @Test
+ public void snrPhrase_zero() {
+ assertThat(VoicePhrases.snrPhrase(0)).isEqualTo("zero");
+ }
+
+ @Test
+ public void snrPhrase_unknownSentinelIsEmpty() {
+ // Decoder can emit a message with no SNR (Integer.MIN_VALUE sentinel);
+ // the phrase must not say "minus 2147483648".
+ assertThat(VoicePhrases.snrPhrase(VoicePhrases.SNR_UNKNOWN)).isEmpty();
+ }
+
+ @Test
+ public void snrUnknownSentinelMatchesFt8Message() {
+ // VoicePhrases mirrors Ft8Message.SNR_UNKNOWN without importing it
+ // (keeps this class Android-free); pin the value so they can't drift.
+ assertThat(VoicePhrases.SNR_UNKNOWN).isEqualTo(Integer.MIN_VALUE);
+ }
+
+ // ---- announcement phrases ------------------------------------------------
+
+ @Test
+ public void callingYou_withSnr() {
+ assertThat(VoicePhrases.callingYou("K1ABC", -5))
+ .isEqualTo("K 1 A B C calling you, minus 5");
+ }
+
+ @Test
+ public void callingYou_unknownSnrDropsClause() {
+ assertThat(VoicePhrases.callingYou("K1ABC", VoicePhrases.SNR_UNKNOWN))
+ .isEqualTo("K 1 A B C calling you");
+ }
+
+ @Test
+ public void qsoLogged() {
+ assertThat(VoicePhrases.qsoLogged("K1ABC"))
+ .isEqualTo("QSO with K 1 A B C logged");
+ }
+
+ @Test
+ public void newCountry_speaksResolvedNameVerbatim() {
+ assertThat(VoicePhrases.newCountry("Japan")).isEqualTo("New country: Japan");
+ }
+
+ @Test
+ public void newPrefix_isSpelled() {
+ assertThat(VoicePhrases.newPrefix("W1")).isEqualTo("New prefix: W 1");
+ }
+
+ // ---- echo confirmations ---------------------------------------------------
+
+ @Test
+ public void echoes() {
+ assertThat(VoicePhrases.echoCallingCq()).isEqualTo("Calling CQ");
+ assertThat(VoicePhrases.echoStopping()).isEqualTo("Stopping");
+ assertThat(VoicePhrases.echoSkipping()).isEqualTo("Back to CQ");
+ assertThat(VoicePhrases.echoLogged()).isEqualTo("Logged");
+ assertThat(VoicePhrases.echoAnswering("K1ABC")).isEqualTo("Answering K 1 A B C");
+ }
+}
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java
new file mode 100644
index 000000000..6854f8e0c
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java
@@ -0,0 +1,35 @@
+package com.k1af.ft8af.wave;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/**
+ * Pure-JVM tests for {@link HamRecorder#phoneMicCaptureInUse} — the decision
+ * behind the voice-command button's "phone mic in use by FT8 RX" gate.
+ */
+public class HamRecorderMicGateTest {
+
+ @Test
+ public void systemMicCaptureBlocksVoiceCommands() {
+ // Running, mic source, AudioRecord path (incl. Android-routed USB input).
+ assertThat(HamRecorder.phoneMicCaptureInUse(true, true, false)).isTrue();
+ }
+
+ @Test
+ public void directUsbCaptureLeavesMicFree() {
+ // Direct-libusb USB audio: no AudioRecord exists, recognizer is safe.
+ assertThat(HamRecorder.phoneMicCaptureInUse(true, true, true)).isFalse();
+ }
+
+ @Test
+ public void lanAudioSourceLeavesMicFree() {
+ // ICOM WiFi / Flex network audio: MicRecorder is stopped.
+ assertThat(HamRecorder.phoneMicCaptureInUse(true, false, false)).isFalse();
+ }
+
+ @Test
+ public void stoppedRecorderLeavesMicFree() {
+ assertThat(HamRecorder.phoneMicCaptureInUse(false, true, false)).isFalse();
+ }
+}
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt
new file mode 100644
index 000000000..f516b73f4
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt
@@ -0,0 +1,92 @@
+package radio.ks3ckc.ft8af.ui.components
+
+import android.speech.SpeechRecognizer
+import com.google.common.truth.Truth.assertThat
+import com.k1af.ft8af.R
+import com.k1af.ft8af.voice.VoiceCommandParser
+import org.junit.Test
+
+/**
+ * Pure-JVM tests for the voice-command button's extracted decision logic
+ * (the Composable itself is a thin wrapper): visibility/enabled gating and
+ * the spoken echo mapping.
+ */
+class VoiceCommandButtonLogicTest {
+ // ---- voiceButtonState -------------------------------------------------
+
+ @Test
+ fun hiddenWheneverSettingIsOff() {
+ assertThat(voiceButtonState(commandsEnabled = false, phoneMicInUse = false))
+ .isEqualTo(VoiceButtonState.HIDDEN)
+ // Setting off wins even when the mic would also be busy.
+ assertThat(voiceButtonState(commandsEnabled = false, phoneMicInUse = true))
+ .isEqualTo(VoiceButtonState.HIDDEN)
+ }
+
+ @Test
+ fun blockedWhilePhoneMicCapturesFt8Audio() {
+ assertThat(voiceButtonState(commandsEnabled = true, phoneMicInUse = true))
+ .isEqualTo(VoiceButtonState.BLOCKED_MIC)
+ }
+
+ @Test
+ fun readyWhenEnabledAndMicFree() {
+ assertThat(voiceButtonState(commandsEnabled = true, phoneMicInUse = false))
+ .isEqualTo(VoiceButtonState.READY)
+ }
+
+ // ---- echoPhraseFor ------------------------------------------------------
+
+ @Test
+ fun echoesForEachCommand() {
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.CALL_CQ, null))
+ .isEqualTo("Calling CQ")
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.STOP, null))
+ .isEqualTo("Stopping")
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.SKIP, null))
+ .isEqualTo("Back to CQ")
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.LOG, null))
+ .isEqualTo("Logged")
+ }
+
+ @Test
+ fun answerEchoSpellsTheCallsign() {
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.ANSWER, "K1ABC"))
+ .isEqualTo("Answering K 1 A B C")
+ }
+
+ @Test
+ fun answerWithoutCandidateHasNoEcho() {
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.ANSWER, null)).isNull()
+ }
+
+ @Test
+ fun unknownHasNoEcho() {
+ assertThat(echoPhraseFor(VoiceCommandParser.Command.UNKNOWN, null)).isNull()
+ }
+
+ // ---- recognizer error -> toast mapping ---------------------------------
+
+ @Test
+ fun errorClientIsSilent() {
+ // ERROR_CLIENT is what cancel() (second tap) produces — a deliberate
+ // user action must not be toasted as a failure.
+ assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_CLIENT)).isNull()
+ }
+
+ @Test
+ fun noMatchAndTimeoutToastNotUnderstood() {
+ assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_NO_MATCH))
+ .isEqualTo(R.string.voice_not_understood)
+ assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_SPEECH_TIMEOUT))
+ .isEqualTo(R.string.voice_not_understood)
+ }
+
+ @Test
+ fun otherErrorsToastGenericMessage() {
+ assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_NETWORK))
+ .isEqualTo(R.string.voice_recognizer_error)
+ assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_AUDIO))
+ .isEqualTo(R.string.voice_recognizer_error)
+ }
+}