From bbdbe252bd4e036e3bb2ea42d72704138c1df2c5 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 09:35:01 -0500 Subject: [PATCH 1/3] Voice assistant v1: spoken event announcements + push-to-talk commands Opt-in hands-free layer for mobile/POTA passenger operation and accessibility. Announcements (TTS, per-event toggles) ride the existing DxAlertNotifier decode/QSO hooks: station calling you (with SNR), QSO logged, new-DXCC CQ, new-prefix CQ. Callsigns are spelled letter-by-letter so engines don't read them as words. Two hard audio-safety rules from the TX pipeline docs are enforced: - TTS never plays while the rig is keyed (it would be mixed into the TX audio and transmitted): the announcer refuses to start an utterance during TX, and a mutableIsTransmitting observer hard-stops in-flight speech at key-up. Suppressed announcements don't burn their dedup key. - The push-to-talk mic button is disabled whenever FT8 RX holds an Android audio-capture session (phone mic, or Android-routed USB input) - SpeechRecognizer would fight our capture. Direct-libusb USB and LAN audio leave it available. Commands are a small offline keyword grammar (answer / call CQ / stop / skip / log it) mapped onto the same entry points the UI buttons use (callStation, userResetToCQ + setActivated, forceLogAndMoveOn), with a pure newest-caller selector for "answer" and a spoken echo of each action. New Voice Assistant settings category (5 toggles, config-table persistence with hydration arms); entries for RecognitionService and TTS_SERVICE. 59 new unit tests, all pure JVM. Co-Authored-By: Claude Fable 5 --- ft8af/app/src/main/AndroidManifest.xml | 12 + .../java/com/k1af/ft8af/GeneralVariables.java | 11 + .../java/com/k1af/ft8af/MainViewModel.java | 66 ++++ .../com/k1af/ft8af/alert/DxAlertNotifier.java | 100 +++++- .../com/k1af/ft8af/database/DatabaseOpr.java | 15 + .../voice/VoiceAnnouncementDecisions.java | 91 ++++++ .../com/k1af/ft8af/voice/VoiceAnnouncer.java | 169 ++++++++++ .../k1af/ft8af/voice/VoiceAnswerSelector.java | 74 +++++ .../k1af/ft8af/voice/VoiceCommandParser.java | 80 +++++ .../com/k1af/ft8af/voice/VoicePhrases.java | 98 ++++++ .../java/com/k1af/ft8af/wave/HamRecorder.java | 24 ++ .../java/com/k1af/ft8af/wave/MicRecorder.java | 11 + .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 13 + .../ks3ckc/ft8af/ui/components/FT8AFIcons.kt | 36 +++ .../ft8af/ui/components/VoiceCommandButton.kt | 294 ++++++++++++++++++ .../ft8af/ui/settings/SettingsScreen.kt | 10 + .../ks3ckc/ft8af/ui/settings/VoiceSettings.kt | 132 ++++++++ .../src/main/res/values/strings_compose.xml | 25 ++ .../voice/VoiceAnnouncementDecisionsTest.java | 155 +++++++++ .../ft8af/voice/VoiceAnswerSelectorTest.java | 86 +++++ .../ft8af/voice/VoiceCommandParserTest.java | 120 +++++++ .../k1af/ft8af/voice/VoicePhrasesTest.java | 100 ++++++ .../ft8af/wave/HamRecorderMicGateTest.java | 35 +++ .../components/VoiceCommandButtonLogicTest.kt | 65 ++++ 24 files changed, 1821 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index b42e731d6..4a3879e94 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -33,6 +33,18 @@ GPS breadcrumbs coming while the phone sits in a cradle with the screen off. --> + + + + + + + + + + CONTINENT_CODES = new java.util.HashSet<>(java.util.Arrays.asList("NA", "SA", "EU", "AF", "AS", "OC", "AN")); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index c1f913e08..13d6a07c2 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1015,6 +1015,25 @@ public void doOnAfterQueryCallsignLocation(CallsignInfo callsignInfo) { meterProtectionController.setTransmitSignal(ft8TransmitSignal); ft8TransmitSignal.setMeterProtectionController(meterProtectionController); + // Voice assistant: TTS must never leak into a transmission (it would be + // mixed into the rig audio and go out over the air — see the TX audio + // hazards in CLAUDE.md). Two layers: the announcer refuses to start an + // utterance while isTransmitting(), and this observer hard-stops any + // in-flight speech the instant TX begins. observeForever is safe here: + // the ViewModel constructor runs on the main thread (setValue above), + // and both objects live for the whole process. + { + final com.k1af.ft8af.voice.VoiceAnnouncer announcer = + dxAlertNotifier.getVoiceAnnouncer(); + announcer.setTransmitGate( + () -> ft8TransmitSignal != null && ft8TransmitSignal.isTransmitting()); + ft8TransmitSignal.mutableIsTransmitting.observeForever(transmitting -> { + if (Boolean.TRUE.equals(transmitting)) { + announcer.stopNow(); + } + }); + } + //bring up the WSJT-X UDP interface (status provider + inbound request handlers) setupWsjtxUdp(); @@ -1282,6 +1301,53 @@ public void callStation(Ft8Message message) { ft8TransmitSignal.transmitNow(); } + // --- Voice assistant -------------------------------------------------------- + + /** + * Whether FT8 RX currently holds an Android audio-capture session (system + * mic or Android-routed USB input). The voice-command push-to-talk button + * is disabled while true: a SpeechRecognizer would fight our capture under + * Android's concurrent-capture rules. False for direct-libusb USB audio + * and LAN audio sources, where the capture stack is free. + */ + public boolean isPhoneMicInUse() { + return hamRecorder != null && hamRecorder.isPhoneMicInUse(); + } + + /** The voice announcer (TTS), for the command button's spoken echo. */ + public com.k1af.ft8af.voice.VoiceAnnouncer getVoiceAnnouncer() { + return dxAlertNotifier.getVoiceAnnouncer(); + } + + /** + * "Answer" voice command: call the best current candidate — the newest + * decode addressed to me, else the caller-queue head's newest decode + * (selection logic in {@link com.k1af.ft8af.voice.VoiceAnswerSelector}). + * + * @return the callsign being answered, or null when there was no candidate + * (the spoken/toast feedback branches on this) + */ + public String voiceAnswerBestCaller() { + if (ft8TransmitSignal == null) return null; + ArrayList snapshot; + synchronized (ft8Messages) { + snapshot = new ArrayList<>(ft8Messages); + } + ArrayList queue = + ft8TransmitSignal.mutableCallerQueue.getValue(); + String queueHead = (queue != null && !queue.isEmpty()) ? queue.get(0).callsign : null; + + Ft8Message target = com.k1af.ft8af.voice.VoiceAnswerSelector.pick( + snapshot, + m -> GeneralVariables.checkIsMyCallsign(m.getCallsignTo()), + Ft8Message::getCallsignFrom, + queueHead); + if (target == null) return null; + // callStation guards against TX-in-progress, empty sender, and own call. + callStation(target); + return target.getCallsignFrom(); + } + // --- WSJT-X UDP interface ------------------------------------------------- /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java index 5b765deef..b7b36a326 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java @@ -17,7 +17,11 @@ import com.k1af.ft8af.Ft8Message; import com.k1af.ft8af.GeneralVariables; import com.k1af.ft8af.R; +import com.k1af.ft8af.callsign.WpxPrefix; import com.k1af.ft8af.log.QSLRecord; +import com.k1af.ft8af.voice.VoiceAnnouncementDecisions; +import com.k1af.ft8af.voice.VoiceAnnouncer; +import com.k1af.ft8af.voice.VoicePhrases; import java.util.Collections; import java.util.List; @@ -54,12 +58,22 @@ public class DxAlertNotifier { private final Context appContext; // Already-alerted keys this session (namespaced: "DXCC:"/"STATE:"/"CQREPLY:"/"QSO:"). private final Set alerted = Collections.newSetFromMap(new ConcurrentHashMap<>()); + // Voice assistant: spoken announcements ride the same decode/QSO hooks as the + // notification alerts, so the announcer is co-located here. TTS init is lazy + // inside the announcer — nothing spins up unless a voice toggle is enabled. + private final VoiceAnnouncer voiceAnnouncer; public DxAlertNotifier(Context context) { this.appContext = context != null ? context.getApplicationContext() : null; + this.voiceAnnouncer = new VoiceAnnouncer(this.appContext); createChannels(); } + /** The announcer, for TX-mute wiring (MainViewModel) and command echo (UI). */ + public VoiceAnnouncer getVoiceAnnouncer() { + return voiceAnnouncer; + } + private void createChannels() { if (appContext == null) return; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; @@ -96,8 +110,13 @@ private void createHighChannel(NotificationManager nm, String id, String name, S */ public void processDecodes(List messages) { if (appContext == null || messages == null) return; + boolean anyVoice = VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled( + GeneralVariables.voiceAnnounceCalling, + GeneralVariables.voiceAnnounceNewDxcc, + GeneralVariables.voiceAnnounceNewPrefix); if (!GeneralVariables.alertNewDxcc && !GeneralVariables.alertNewState - && !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns()) { + && !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns() + && !anyVoice) { return; } @@ -130,6 +149,11 @@ public void processDecodes(List messages) { cqReplyBody(msg), msg.getCallsignFrom(), msg.band); } + // Voice assistant: spoken announcement, independent toggles from the + // notification alerts. Runs before the CQ-only gate because the + // calling-me announcement (like the CQ-reply alert) isn't a CQ. + maybeAnnounceVoice(msg, addressedToMe); + // Needed-DX alerts apply to CQ broadcasts only. if (!msg.checkIsCQ()) continue; @@ -146,9 +170,70 @@ public void processDecodes(List 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 29fd5f6e8..65d545ce3 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 @@ -2993,6 +2993,21 @@ 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"); + } 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..04e5b94c0 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java @@ -0,0 +1,91 @@ +package com.k1af.ft8af.voice; + +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) { + return s == null ? "" : s.trim().toUpperCase(); + } +} 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: + *

    + *
  1. The newest decode addressed to my callsign (a station actively + * calling me right now).
  2. + *
  3. Else the newest decode from the head of the caller queue (a station + * that called me earlier and is waiting its turn).
  4. + *
  5. Else null — nothing to answer.
  6. + *
+ * + *

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..796364b94 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt @@ -0,0 +1,294 @@ +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 + } + +/** + * 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) + + val state = voiceButtonState( + commandsEnabled = GeneralVariables.voiceCommandsEnabled, + // 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 = when (errorCode) { + SpeechRecognizer.ERROR_NO_MATCH, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT, + -> R.string.voice_not_understood + else -> R.string.voice_recognizer_error + } + 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..0fc4cb5f4 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt @@ -0,0 +1,132 @@ +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 + 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 c042dbaf7..de61f369b 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -716,6 +716,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 uses the phone mic. + Announcements never play while transmitting, so they can\'t leak into the rig audio. + Voice command + Voice command unavailable: phone mic in use + Voice commands unavailable: the phone mic is capturing FT8 receive audio. Use USB or network rig audio to free 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/voice/VoiceAnnouncementDecisionsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java new file mode 100644 index 000000000..c3cb07670 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java @@ -0,0 +1,155 @@ +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:|"); + } +} 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..7829989c8 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt @@ -0,0 +1,65 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +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() + } +} From 6d8cdf5fb8dfd74926bc2f1809ec3b3bfaaa5053 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 09:47:22 -0500 Subject: [PATCH 2/3] Make the voice-command mic button react to its settings toggle live Device smoke test caught the button only appearing/disappearing after an app restart: the Composable read GeneralVariables.voiceCommandsEnabled as a plain static, which nothing invalidates. Add the house-pattern LiveData mirror (mutableVoiceCommandsEnabled) updated by the settings toggle and config hydration, and observe it from VoiceCommandButton. Verified on hardware: toggling now shows/hides the button immediately both ways. Co-Authored-By: Claude Fable 5 --- .../java/com/k1af/ft8af/GeneralVariables.java | 6 +++ .../com/k1af/ft8af/database/DatabaseOpr.java | 4 ++ .../ft8af/ui/components/VoiceCommandButton.kt | 8 ++- .../ks3ckc/ft8af/ui/settings/VoiceSettings.kt | 4 ++ .../DatabaseOprConfigHydrationTest.java | 53 +++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 6e6120d0a..404cccc75 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -698,6 +698,12 @@ public static String excludedBandsToCsv() { public static boolean voiceAnnounceNewDxcc = false; public static boolean voiceAnnounceNewPrefix = false; public static boolean voiceCommandsEnabled = false; + // Observable mirror of voiceCommandsEnabled: the mic button lives on the main + // screen, which is already composed when the settings toggle flips — a plain + // static read there never recomposes, so the button only appeared after an + // app restart. Settings writes and config hydration must update both. + public static final MutableLiveData mutableVoiceCommandsEnabled = + new MutableLiveData<>(false); // Geographic continent-directed CQ tokens — matched against myContinent. private static final java.util.Set CONTINENT_CODES = 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 65d545ce3..c011e9ac6 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 @@ -3007,6 +3007,10 @@ protected Void doInBackground(Void... voids) { } 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/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt index 796364b94..0b5fafe0e 100644 --- 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 @@ -100,8 +100,14 @@ fun VoiceCommandButton( // 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 = GeneralVariables.voiceCommandsEnabled, + 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(), 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 index 0fc4cb5f4..1d77e9531 100644 --- 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 @@ -121,6 +121,10 @@ fun VoiceSettings( 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/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java index b96c1e67f..3c4290c59 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 @@ -284,6 +284,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); + } + } + @Test public void powerToggles_nonBooleanValueReadsAsOff() { // Hydration compares against "1", so anything else is off rather than a From 7f41d1c5cef20849e56879f76e54d716064db180 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 10:18:07 -0500 Subject: [PATCH 3/3] Address Copilot review on the voice assistant (PR #721) - Suppress the recognizer-error toast for ERROR_CLIENT: it's what cancel() (a deliberate second tap) emits, so toasting it made a user-initiated cancel look like a failure. Mapping extracted to the pure voiceErrorToastRes() and covered by tests. - VoiceAnnouncementDecisions.norm() now uppercases with Locale.ROOT so dedup keys are stable regardless of device locale (Turkish dotted-I regression test added). - Mic-gate strings no longer claim only the "phone mic" is the blocker - the gate also covers Android-routed USB input, and the wording now says so. Co-Authored-By: Claude Fable 5 --- .../voice/VoiceAnnouncementDecisions.java | 5 +++- .../ft8af/ui/components/VoiceCommandButton.kt | 28 +++++++++++++------ .../src/main/res/values/strings_compose.xml | 6 ++-- .../voice/VoiceAnnouncementDecisionsTest.java | 17 +++++++++++ .../components/VoiceCommandButtonLogicTest.kt | 27 ++++++++++++++++++ 5 files changed, 71 insertions(+), 12 deletions(-) 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 index 04e5b94c0..0393eb7f9 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java @@ -1,5 +1,6 @@ package com.k1af.ft8af.voice; +import java.util.Locale; import java.util.Set; /** @@ -86,6 +87,8 @@ public static String qsoCompleteKey(String toCallsign, String endTime) { } private static String norm(String s) { - return s == null ? "" : s.trim().toUpperCase(); + // 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/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt index 0b5fafe0e..ca9bc605d 100644 --- 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 @@ -61,6 +61,21 @@ internal fun voiceButtonState( 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 @@ -155,15 +170,12 @@ fun VoiceCommandButton( }, onError = { errorCode -> listening = false - val msgRes = when (errorCode) { - SpeechRecognizer.ERROR_NO_MATCH, - SpeechRecognizer.ERROR_SPEECH_TIMEOUT, - -> R.string.voice_not_understood - else -> R.string.voice_recognizer_error + val msgRes = voiceErrorToastRes(errorCode) + if (msgRes != null) { + Toast + .makeText(context, context.getString(msgRes), Toast.LENGTH_SHORT) + .show() } - Toast - .makeText(context, context.getString(msgRes), Toast.LENGTH_SHORT) - .show() }, ) } diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index de61f369b..34145ae1f 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -731,11 +731,11 @@ 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 uses the phone mic. + 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: phone mic in use - Voice commands unavailable: the phone mic is capturing FT8 receive audio. Use USB or network rig audio to free it. + 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 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 index c3cb07670..44cf7d0fb 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java @@ -152,4 +152,21 @@ 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/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt index 7829989c8..f516b73f4 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -62,4 +64,29 @@ class VoiceCommandButtonLogicTest { 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) + } }