Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/LageBuch.App.Android/Services/AndroidAlarmService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,5 @@ namespace LageBuch.App.Android.Services;
/// </summary>
public sealed class AndroidAlarmService : IAlarmService
{
public void Start() { }
public void Stop() { }
public void Play(AlarmSound sound) { }
}
Binary file added src/LageBuch.App/Assets/voice-druckabfrage.wav
Binary file not shown.
Binary file added src/LageBuch.App/Assets/voice-rueckzugsalarm.wav
Binary file not shown.
43 changes: 43 additions & 0 deletions src/LageBuch.App/Services/SerialAudioQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Collections.Concurrent;

namespace LageBuch.App.Services;

/// <summary>
/// Runs enqueued playback actions one at a time, in FIFO order, on a single dedicated
/// background thread — so cues that become due close together play sequentially instead of
/// overlapping. <see cref="Enqueue"/> itself never blocks the caller.
///
/// Each action gets a bounded time to finish (see constructor); if it hangs (e.g. a stuck OS
/// audio player process that never exits), the queue moves on to the next item anyway so one
/// stuck cue can't permanently silence later ones.
/// </summary>
public sealed class SerialAudioQueue
{
private readonly BlockingCollection<Action> _queue = new();
private readonly TimeSpan _perItemTimeout;

public SerialAudioQueue(TimeSpan? perItemTimeout = null)
{
_perItemTimeout = perItemTimeout ?? TimeSpan.FromSeconds(10);
var worker = new Thread(Run) { IsBackground = true, Name = nameof(SerialAudioQueue) };
worker.Start();
}

/// <summary>Queues <paramref name="play"/> to run after everything already queued.</summary>
public void Enqueue(Action play) => _queue.Add(play);

private void Run()
{
foreach (var play in _queue.GetConsumingEnumerable())
{
try
{
Task.Run(play).Wait(_perItemTimeout);
}
catch
{
// A misbehaving cue must not stop the queue from serving the next one.
}
}
}
}
54 changes: 19 additions & 35 deletions src/LageBuch.App/Services/SystemAlarmService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,18 @@
namespace LageBuch.App.Services;

/// <summary>
/// Audio for the desktop deployment. The looping life-safety alarm (<see cref="Start"/>/
/// <see cref="Stop"/>) uses winmm's PlaySound on Windows and is a no-op elsewhere, unchanged.
/// The one-shot spoken cues (<see cref="Play"/>) work on Windows, macOS and Linux: winmm in-memory
/// on Windows, and <c>afplay</c>/<c>aplay</c> on macOS/Linux fed a WAV extracted to a temp file.
/// Every path degrades to a silent no-op when the asset or the player is missing, so a build with
/// no voice clip yet — or a host without the CLI player — simply stays quiet rather than crashing.
/// Audio for the desktop deployment. One-shot spoken cues (<see cref="Play"/>) work on Windows,
/// macOS and Linux: winmm in-memory on Windows, and <c>afplay</c>/<c>aplay</c> on macOS/Linux fed
/// a WAV extracted to a temp file. Every path degrades to a silent no-op when the asset or the
/// player is missing, so a build with no voice clip yet — or a host without the CLI player —
/// simply stays quiet rather than crashing.
/// </summary>
internal sealed class SystemAlarmService : IAlarmService
{
private const uint SndAsync = 0x0001; // play asynchronously
private const uint SndNodefault = 0x0002; // no default beep if it fails
private const uint SndMemory = 0x0004; // pszSound points to in-memory WAV
private const uint SndLoop = 0x0008; // loop until the next PlaySound call

private static readonly Uri AlarmAsset = new("avares://LageBuch.App/Assets/alarm.wav");
// No SND_ASYNC: playback must block the queue's worker thread until the clip finishes,
// so cues play one after another instead of overlapping (see SerialAudioQueue).

// One voice clip per AlarmSound. A missing entry or missing file just means that cue is silent.
private static readonly IReadOnlyDictionary<AlarmSound, string> VoiceAssets =
Expand All @@ -31,52 +28,38 @@ internal sealed class SystemAlarmService : IAlarmService
// Generic tone (already bundled) — a task falling due is frequent enough that a spoken
// sentence would be more noise than signal.
[AlarmSound.TaskDue] = "alarm.wav",
[AlarmSound.PressureCheckDue] = "voice-druckabfrage.wav",
[AlarmSound.RetreatAlarm] = "voice-rueckzugsalarm.wav",
};

private readonly byte[]? _wav;
private readonly Dictionary<AlarmSound, byte[]> _voiceBytes = new();
private readonly Dictionary<AlarmSound, string> _voiceTempFiles = new();
private bool _sounding;
private readonly SerialAudioQueue _queue = new();

public SystemAlarmService()
{
// Load the looping alarm WAV once. Only needed on Windows; skip the work elsewhere.
if (OperatingSystem.IsWindows())
_wav = TryLoad(AlarmAsset);

// Preload the voice clips (all platforms). Absent files are simply skipped.
foreach (var (sound, file) in VoiceAssets)
if (TryLoad(new Uri($"avares://LageBuch.App/Assets/{file}")) is { } bytes)
_voiceBytes[sound] = bytes;
}

public void Start()
{
if (_sounding || _wav is null || !OperatingSystem.IsWindows())
return;
PlaySound(_wav, IntPtr.Zero, SndAsync | SndMemory | SndLoop | SndNodefault);
_sounding = true;
}

public void Stop()
{
if (!_sounding || !OperatingSystem.IsWindows())
return;
PlaySound(null, IntPtr.Zero, 0); // null sound stops any current playback
_sounding = false;
}

[SuppressMessage("Design", "CA1031",
Justification = "A missing player binary must stay silent (see comment); a failed alarm never crashes the app.")]
public void Play(AlarmSound sound)
{
if (!_voiceBytes.TryGetValue(sound, out var bytes))
return; // no clip bundled for this cue yet

_queue.Enqueue(() => PlayBlocking(sound, bytes));
}

// Runs on the SerialAudioQueue's worker thread; blocks until the clip finishes playing.
private void PlayBlocking(AlarmSound sound, byte[] bytes)
{
if (OperatingSystem.IsWindows())
{
// One-shot (no SndLoop). Deliberately distinct from Start's looping playback.
PlaySound(bytes, IntPtr.Zero, SndAsync | SndMemory | SndNodefault);
PlaySound(bytes, IntPtr.Zero, SndMemory | SndNodefault);
return;
}

Expand All @@ -87,11 +70,12 @@ public void Play(AlarmSound sound)
var player = OperatingSystem.IsMacOS() ? "afplay" : "aplay";
try
{
Process.Start(new ProcessStartInfo(player, $"\"{path}\"")
using var process = Process.Start(new ProcessStartInfo(player, $"\"{path}\"")
{
UseShellExecute = false,
CreateNoWindow = true,
});
process?.WaitForExit();
}
catch
{
Expand Down
19 changes: 10 additions & 9 deletions src/LageBuch.AppLogic/Services/IAlarmService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,22 @@ public enum AlarmSound

/// <summary>"Aufgabe fällig" — a task's timer expired while still open (#88).</summary>
TaskDue,

/// <summary>"Druckabfrage fällig" — a Trupp's pressure-control interval has elapsed (#78, #81).</summary>
PressureCheckDue,

/// <summary>"Rückzugsalarm" — a Trupp has hit its time limit or return pressure (#81),
/// repeated until acknowledged.</summary>
RetreatAlarm,
}

/// <summary>
/// Sounds audible cues. <see cref="Start"/>/<see cref="Stop"/> drive the looping life-safety tone
/// (the Atemschutz Rückzugsalarm) and are idempotent so callers can drive them straight from state
/// on every tick. <see cref="Play"/> is a fire-and-forget one-shot spoken announcement.
/// Sounds audible cues. <see cref="Play"/> is a fire-and-forget one-shot spoken announcement;
/// a caller that needs an insistent, repeating cue (e.g. the Atemschutz Rückzugsalarm) calls it
/// again on its own cadence rather than this service looping anything on its own (#81).
/// </summary>
public interface IAlarmService
{
/// <summary>Begins (or continues) the looping alarm. Safe to call when already sounding.</summary>
void Start();

/// <summary>Silences the looping alarm. Safe to call when already silent.</summary>
void Stop();

/// <summary>Plays a spoken cue once. Fire-and-forget; safe to call from the UI thread.</summary>
void Play(AlarmSound sound);
}
56 changes: 48 additions & 8 deletions src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ public sealed partial class ScbaViewModel : ObservableObject, IDisposable
private readonly Action _onChanged;
private readonly IDisposable? _subscription;
private readonly HashSet<Guid> _alarmLogged = new();

// Druckabfrage due-crossings that have already sounded. Unlike _alarmLogged (a one-way state
// that only ever grows until the Trupp returns), control-due toggles on and off every Abfrage-
// Intervall, so an id is removed once no longer due, letting the next crossing sound again.
private readonly HashSet<Guid> _controlDueAnnounced = new();

// Rückzugsalarm speaks instead of sounding a looping siren (#81), repeating on this cadence
// while unacknowledged so it stays insistent -- mirrors ReminderViewModel's ILS cue exactly,
// just at 15s instead of 60s given the life-safety stakes. Reset to null by AcknowledgeAlarm
// (so the next cycle announces immediately) and by a newly-tripped alarm (so a second Trupp
// alarming after an ack is heard right away rather than waiting out the window).
private static readonly TimeSpan RetreatRepeatInterval = TimeSpan.FromSeconds(15);
private DateTimeOffset? _lastAlarmAnnouncedAt;

private readonly IncidentSettings _settings;

// True once the user has hand-edited the Einsatzzeit; after that a Trupp-type switch must not
Expand Down Expand Up @@ -383,27 +397,32 @@ public string AlarmDisplay
[RelayCommand(CanExecute = nameof(CanAcknowledgeAlarm))]
private void AcknowledgeAlarm()
{
// Silence the sound; the visual banner stays until the trupp is back.
_alarm.Stop();
// Silences the repeat cadence; the visual banner stays until the trupp is back.
_lastAlarmAnnouncedAt = null;
IsAlarmAcknowledged = true;
}

private string AlarmReason(AtemschutzTrupp trupp) => trupp.IsTimeAlarm(_clock.Now)
? "Einsatzzeit erreicht"
: $"Rückzugsdruck erreicht ({trupp.LatestPressure} bar)";

/// <summary>Sounds or silences the audible alarm from current state, and keeps the banner
/// bindings fresh. A newly-alarming trupp re-arms the sound even after an earlier ack.</summary>
/// <summary>Speaks the Rückzugsalarm cue on its repeat cadence while unacknowledged (#81), and
/// keeps the banner bindings fresh. A newly-alarming trupp re-arms the cue even after an
/// earlier ack.</summary>
private void UpdateAlarm(bool newAlarmTripped)
{
if (newAlarmTripped)
IsAlarmAcknowledged = false;

if (IsAnyAlarm && !IsAlarmAcknowledged)
_alarm.Start();
if (IsAnyAlarm && !IsAlarmAcknowledged &&
(_lastAlarmAnnouncedAt is null || _clock.Now - _lastAlarmAnnouncedAt >= RetreatRepeatInterval))
{
_alarm.Play(AlarmSound.RetreatAlarm);
_lastAlarmAnnouncedAt = _clock.Now;
}
else if (!IsAnyAlarm)
{
_alarm.Stop();
_lastAlarmAnnouncedAt = null;
IsAlarmAcknowledged = false;
}

Expand Down Expand Up @@ -559,10 +578,32 @@ private void OnTick()
RefreshHeader();
var tripped = LogNewAlarms();
UpdateAlarm(tripped);
AnnounceControlDue();
if (tripped)
_onChanged();
}

/// <summary>Plays a cue once per Druckabfrage due-crossing per Trupp. Unlike <see cref="LogNewAlarms"/>
/// this is local feedback, not a journal write, so it runs on joined clients too (not gated on
/// IsRemote) — only a closed/read-only workspace stays silent.</summary>
private void AnnounceControlDue()
{
if (IsReadOnly)
return;
foreach (var trupp in _session.Incident.ScbaTrupps)
{
if ((trupp.IsActive || trupp.IsWithdrawing) && trupp.IsControlDue(_clock.Now))
{
if (_controlDueAnnounced.Add(trupp.Id))
_alarm.Play(AlarmSound.PressureCheckDue);
}
else
{
_controlDueAnnounced.Remove(trupp.Id);
}
}
}

/// <summary>Appends one ETB entry per Trupp that has newly entered the alarm state.
/// Returns whether anything was logged (so callers can persist). No-op when read-only.</summary>
private bool LogNewAlarms()
Expand All @@ -587,7 +628,6 @@ private bool LogNewAlarms()
public void Dispose()
{
_session.Changed -= RefreshTrupps;
_alarm.Stop();
_subscription?.Dispose();
}
}
93 changes: 93 additions & 0 deletions tests/LageBuch.App.Tests/SerialAudioQueueTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Diagnostics;
using LageBuch.App.Services;

namespace LageBuch.App.Tests;

public class SerialAudioQueueTests
{
[Fact]
public void Enqueued_actions_do_not_overlap()
{
var sw = Stopwatch.StartNew();
var events = new List<(string Id, long StartMs, long EndMs)>();
var done = new CountdownEvent(2);
var queue = new SerialAudioQueue();

queue.Enqueue(() => RecordTimedRun("a", sw, events, done, TimeSpan.FromMilliseconds(150)));
queue.Enqueue(() => RecordTimedRun("b", sw, events, done, TimeSpan.FromMilliseconds(50)));

Assert.True(done.Wait(TimeSpan.FromSeconds(5)), "queued actions never completed");
Assert.Equal(2, events.Count);
Assert.True(events[1].StartMs >= events[0].EndMs,
"second action started before the first one finished");
}

[Fact]
public void Enqueued_actions_run_in_fifo_order()
{
var order = new List<int>();
var done = new CountdownEvent(5);
var queue = new SerialAudioQueue();

for (var i = 0; i < 5; i++)
{
var id = i;
queue.Enqueue(() =>
{
lock (order) order.Add(id);
done.Signal();
});
}

Assert.True(done.Wait(TimeSpan.FromSeconds(5)), "queued actions never completed");
Assert.Equal(new[] { 0, 1, 2, 3, 4 }, order);
}

[Fact]
public void Enqueue_returns_without_waiting_for_playback_to_finish()
{
var queue = new SerialAudioQueue();
var started = new ManualResetEventSlim();

queue.Enqueue(() =>
{
started.Set();
Thread.Sleep(TimeSpan.FromSeconds(2));
});

Assert.True(started.Wait(TimeSpan.FromSeconds(1)), "action never started");

var sw = Stopwatch.StartNew();
queue.Enqueue(() => { });
sw.Stop();

Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(500), "Enqueue blocked the caller");
}

[Fact]
public void A_stuck_action_does_not_permanently_block_later_ones()
{
var queue = new SerialAudioQueue(perItemTimeout: TimeSpan.FromMilliseconds(200));
var laterRan = new ManualResetEventSlim();

queue.Enqueue(() => Thread.Sleep(TimeSpan.FromSeconds(30))); // simulates a hung player
queue.Enqueue(() => laterRan.Set());

Assert.True(laterRan.Wait(TimeSpan.FromSeconds(2)),
"later action never ran; the stuck item wedged the queue");
}

private static void RecordTimedRun(
string id,
Stopwatch sw,
List<(string Id, long StartMs, long EndMs)> events,
CountdownEvent done,
TimeSpan sleep)
{
var start = sw.ElapsedMilliseconds;
Thread.Sleep(sleep);
var end = sw.ElapsedMilliseconds;
lock (events) events.Add((id, start, end));
done.Signal();
}
}
5 changes: 0 additions & 5 deletions tests/LageBuch.AppLogic.Tests/ReminderViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@ namespace LageBuch.AppLogic.Tests;
// audible output fired without real audio.
internal sealed class FakeAlarmService : IAlarmService
{
public int StartCount { get; private set; }
public int StopCount { get; private set; }
public bool IsSounding { get; private set; }
public List<AlarmSound> Played { get; } = new();
public void Start() { StartCount++; IsSounding = true; }
public void Stop() { StopCount++; IsSounding = false; }
public void Play(AlarmSound sound) => Played.Add(sound);
}

Expand Down
Loading
Loading