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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- "Download in background" now really does it. The banner used to come back with the next progress update, so the
message never went away; the download itself was never affected. The banner goes and the composer's footer shows
`Downloading models 37%` with a progress bar until the models are there (or the download fails, which shows the
failure).
- Closing the composer no longer matters to a download. The models keep downloading one after the other, a composer
summoned meanwhile joins the running download (progress in its footer, no "download" banner) and does what you asked
when it ends, and a composer that was already closed does nothing at the end (before, a closed composer could start
the microphone when the speech models arrived).

### Added
- Windows composer geometry is logged so "the window shows only half" can be diagnosed from the log
(`logs/livelingo-*.log` in the data folder; search for `PROBLEM=`). Every line carries the summon number and the
window's whole geometry (size, the size the system gave it, position, cap, screen working area and scale). The page
reports when its visible area stays smaller than its content (`page-cut-off-by-the-window`), and the window reports a
page taller than its cap, a size the system changed, and a position outside the working area. Model downloads log their
start and end.
## [2.1.1] - 2026-11-03

The settings page shows the real version, updates report their progress and result, and Windows has one installer
Expand Down Expand Up @@ -403,6 +420,7 @@ process-wide task with shared progress and dedup across every UI surface.

See `git log v0.1.4` for the pre-CHANGELOG history.

[2.1.1]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.1.1
[2.1.0]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.1.0
[2.0.2]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.0.2
[2.0.1]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.0.1
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ exported as SRT.
- Settings, models and logs live in `%LocalAppData%\AGIBuild\LiveLingo` (Windows) or
`~/Library/Application Support/LiveLingo` (macOS), outside the installed program, so updating and uninstalling keep
them. Delete that folder to remove everything LiveLingo stored.
- If something looks wrong (for example the composer window is cut off), the log in `logs/` of that folder says what the
window did; lines marked `PROBLEM=` are the ones to look at first. The log holds sizes and names of applications, never
what you wrote or said.
- Recent drafts are kept in memory only and are gone when the app exits.
- The installed app looks for updates on the GitHub releases of this repository (at start and every few hours) and
downloads a newer version in the background. Nothing you wrote or said is part of that request. Set
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Extensions.Logging;

namespace LiveLingo.Core.Models;
Expand Down Expand Up @@ -55,6 +56,7 @@ public Task StartAsync(ModelDescriptor descriptor)
_sessions[descriptor.Id] = session;

Publish(session, new ModelDownloadState(descriptor.Id, ModelDownloadStatus.Downloading, 0, null));
_logger?.LogInformation("Model download started: {ModelId} ({SizeBytes} bytes)", descriptor.Id, descriptor.SizeBytes);

// Progress<T> captures the caller's SynchronizationContext so incremental
// progress lands on the same scheduler that invoked StartAsync. Terminal
Expand Down Expand Up @@ -94,10 +96,12 @@ public void NotifyDeleted(string modelId)

private async Task RunAsync(ModelDescriptor descriptor, Session session, IProgress<ModelDownloadProgress> progress)
{
var started = Stopwatch.GetTimestamp();
try
{
await _modelManager.EnsureModelAsync(descriptor, progress, session.Cts.Token).ConfigureAwait(false);
Publish(session, new ModelDownloadState(descriptor.Id, ModelDownloadStatus.Installed, 100, null));
_logger?.LogInformation("Model download finished: {ModelId} in {Seconds:0.0} s", descriptor.Id, Stopwatch.GetElapsedTime(started).TotalSeconds);
}
catch (OperationCanceledException)
{
Expand Down
19 changes: 19 additions & 0 deletions src/LiveLingo.Core/Models/ModelDownloadCoordinatorExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace LiveLingo.Core.Models;

public static class ModelDownloadCoordinatorExtensions
{
/// <summary>
/// Downloads the models one after the other and stops at the first that failed or was cancelled. Nothing in it
/// belongs to a window: whoever asks (the composer of this summon or of a later one) gets the same running
/// downloads, and closing the composer neither stops nor restarts them.
/// </summary>
public static async Task StartAllAsync(this IModelDownloadCoordinator coordinator, IEnumerable<ModelDescriptor> models)
{
foreach (var model in models)
{
await coordinator.StartAsync(model).ConfigureAwait(false);
if (coordinator.GetState(model.Id).Status is ModelDownloadStatus.Failed or ModelDownloadStatus.Cancelled)
return;
}
}
}
108 changes: 108 additions & 0 deletions src/LiveLingo.Desktop/Composer/ComposerGeometryLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Avalonia;
using Microsoft.Extensions.Logging;

namespace LiveLingo.Desktop.Composer;

/// <summary>Where the composer window is and how big, as the window itself sees it. Numbers only: nothing the user wrote.</summary>
/// <param name="Width">Requested width and height in device-independent pixels.</param>
/// <param name="Client">What the system gave the window; differs from the request when the system constrained it.</param>
/// <param name="X">Screen position in physical pixels.</param>
/// <param name="MaxHeight">The cap set for the screen the window is on, in device-independent pixels.</param>
/// <param name="UserWidth">Set while the user's size (edge drag or zoom) overrides the page's; null while the window follows the page.</param>
/// <param name="ContentHeight">The last height the page asked for, in CSS pixels.</param>
/// <param name="Work">Working area of the screen the window is placed on, in physical pixels.</param>
internal readonly record struct ComposerGeometry(
double Width, double Height, Size Client, int X, int Y, double MaxHeight, double? UserWidth, double? UserHeight,
double ContentHeight, bool Moved, bool Wide, double Scaling, PixelRect? Work)
{
public override string ToString() =>
$"window={Width:0.#}x{Height:0.#}dip client={Client.Width:0.#}x{Client.Height:0.#} pos=({X},{Y})px " +
$"max={MaxHeight:0.#} user={Format(UserWidth)}x{Format(UserHeight)} content={ContentHeight:0.#}css " +
$"moved={Moved} wide={Wide} scale={Scaling:0.##} work={(Work is { } w ? $"({w.X},{w.Y},{w.Width}x{w.Height})px" : "unknown")}";

private static string Format(double? value) => value is { } v ? v.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) : "-";
}

/// <summary>
/// The one place that writes what the composer window does with its size and position. A report that "the window shows
/// only half" is answered from the log: every line carries the number of the summon and the whole geometry, so one
/// summon can be read from its start (what was asked) to the last size the window took, and the two symptoms that can
/// hide the page — the window smaller than the page, and a window that went off screen — are logged as warnings.
/// </summary>
internal sealed class ComposerGeometryLog(ILogger logger)
{
private int _summon;
private long _lastThrottled;
private readonly Dictionary<string, long> _lastWarned = [];

public int Summon => _summon;

public void SummonStarted(string target, ComposerGeometry geometry) => Info("summon", geometry, $"target={target}", newSummon: true);

public void Info(string what, ComposerGeometry geometry, string detail = "", bool newSummon = false)
{
if (newSummon)
_summon++;
logger.LogInformation("Composer window summon={Summon} event={Event} {Detail} {Geometry}", _summon, what, detail, geometry);
}

/// <summary>Problems repeat while the cause lasts (every size change of a drag): each kind is logged at most once a second.</summary>
public void Warn(string what, ComposerGeometry geometry, string detail)
{
var now = Environment.TickCount64;
if (_lastWarned.TryGetValue(what, out var last) && now - last < 1000)
return;

_lastWarned[what] = now;
logger.LogWarning("Composer window summon={Summon} PROBLEM={Event} {Detail} {Geometry}", _summon, what, detail, geometry);
}

/// <summary>
/// For events that come in bursts (a drag reports the size on every pointer move): the first of a burst is logged,
/// the rest only when a second has passed. Whatever happens next logs the full geometry again, so the final state is
/// never lost.
/// </summary>
public void InfoThrottled(string what, ComposerGeometry geometry, string detail = "")
{
var now = Environment.TickCount64;
if (now - _lastThrottled < 1000)
return;

_lastThrottled = now;
Info(what, geometry, detail);
}

/// <summary>The page asked for a size; flags a window that cannot show it (capped or constrained by the system).</summary>
public void Sized(int contentWidth, int contentHeight, ComposerGeometry geometry)
{
var detail = $"page={contentWidth}x{contentHeight}css";
if (geometry.UserHeight is null)
Info("size", geometry, detail);
else
InfoThrottled("size-while-user-sized", geometry, detail);

if (geometry.UserHeight is null && contentHeight > geometry.MaxHeight + 1)
Warn("content-taller-than-window-cap", geometry, $"{detail} cap={geometry.MaxHeight:0.#}dip");
}

/// <summary>The system sized the window differently from what was asked.</summary>
public void SystemResized(ComposerGeometry geometry)
{
if (Math.Abs(geometry.Client.Height - geometry.Height) > 1 || Math.Abs(geometry.Client.Width - geometry.Width) > 1)
Warn("system-changed-the-size", geometry, "the window got a different size than it asked for");
else
InfoThrottled("system-resized", geometry);
}

/// <summary>The window is not entirely inside the working area of its screen (nothing should leave it so).</summary>
public void CheckOnScreen(ComposerGeometry geometry)
{
if (geometry.Work is not { } work)
return;

var right = geometry.X + geometry.Width * geometry.Scaling;
var bottom = geometry.Y + geometry.Height * geometry.Scaling;
if (geometry.X < work.X - 1 || geometry.Y < work.Y - 1 || right > work.Right + 1 || bottom > work.Bottom + 1)
Warn("window-outside-the-working-area", geometry, $"right={right:0} bottom={bottom:0}");
}
}
6 changes: 5 additions & 1 deletion src/LiveLingo.Desktop/Composer/ComposerTexts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ public string CommitAria(bool armed, bool hasTarget) =>

// ---- banners -----------------------------------------------------------------

/// <summary>The footer's line for models that are downloading while the banner is out of the way.</summary>
public string FooterDownload(double percent) => loc.T("composer.footer.download", Math.Round(percent));

public string BannerMessage(BannerState banner) => banner.Kind switch
{
BannerKind.None => "",
Expand All @@ -243,7 +246,8 @@ public string CommitAria(bool armed, bool hasTarget) =>

public string BannerActionLabel(BannerState banner, BannerAction action) => action switch
{
BannerAction.Dismiss => loc.T(banner.Kind == BannerKind.Downloading ? "composer.banner.background" : "composer.banner.dismiss"),
BannerAction.Dismiss => loc.T("composer.banner.dismiss"),
BannerAction.Background => loc.T("composer.banner.background"),
BannerAction.Download => loc.T(banner.Kind == BannerKind.DownloadFailed ? "composer.banner.retry" : "composer.banner.download"),
BannerAction.OpenMicrophoneSettings => loc.T("composer.banner.openOs"),
BannerAction.OpenInputSettings => loc.T("composer.banner.checkInput"),
Expand Down
6 changes: 5 additions & 1 deletion src/LiveLingo.Desktop/Composer/ComposerTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ public enum BannerAction
{
Dismiss,
Download,

/// <summary>Keep downloading, but out of the way: the banner goes, the footer shows the progress.</summary>
Background,
OpenMicrophoneSettings,
OpenInputSettings,
UseOriginal,
Expand All @@ -129,7 +132,8 @@ public sealed record BannerState(BannerKind Kind, string Detail = "", double Pro
public IReadOnlyList<BannerAction> Actions => Kind switch
{
BannerKind.ModelMissing or BannerKind.DownloadFailed => [BannerAction.Download],
BannerKind.Downloading or BannerKind.FileFailed or BannerKind.NoSpeechFound => [BannerAction.Dismiss],
BannerKind.Downloading => [BannerAction.Background],
BannerKind.FileFailed or BannerKind.NoSpeechFound => [BannerAction.Dismiss],
BannerKind.TargetLost => [BannerAction.Retarget, BannerAction.Copy],
BannerKind.MicrophoneDenied => [BannerAction.OpenMicrophoneSettings, BannerAction.Dismiss],
BannerKind.NoMicrophone or BannerKind.NoSound => [BannerAction.OpenInputSettings, BannerAction.Dismiss],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void Begin(ComposerStart start)
TargetLanguage = start.Target;
OnPropertyChanged(nameof(Remembered));
OnPropertyChanged(nameof(RememberFor));
AttachToRunningDownloads();
}

/// <summary>The application (or "copy only") whose choices are remembered; null when remembering is off or it is unknown.</summary>
Expand Down
5 changes: 5 additions & 0 deletions src/LiveLingo.Desktop/Composer/ComposerViewModel.Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ private async Task RunBannerActionAsync(BannerAction action)
case BannerAction.Download:
await DownloadModelsCommand.ExecuteAsync(null).ConfigureAwait(true);
break;
case BannerAction.Background:
_downloadInBackground = true;
Banner = BannerState.None;
Refresh();
break;
case BannerAction.OpenMicrophoneSettings:
_platform.MicrophonePermission.OpenSettings();
break;
Expand Down
77 changes: 69 additions & 8 deletions src/LiveLingo.Desktop/Composer/ComposerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public sealed partial class ComposerViewModel : ObservableObject, IAsyncDisposab
private CommitSnapshot? _committed;
private bool _applyingDraftText;
private bool _wantsVoice;
private bool _disposed;

/// <summary>The user sent the download to the background: the banner stays away until the models are there.</summary>
private bool _downloadInBackground;
private bool _stopAfterStart;
private Task? _startingVoice;
private Task? _stoppingVoice;
Expand Down Expand Up @@ -106,6 +110,9 @@ private void OnPreparingChanged(bool preparing) =>
[ObservableProperty] private bool _isEditable = true;
[ObservableProperty] private bool _modelsReady;

/// <summary>Overall download progress (0–100) of the models this composer needs; null when none is downloading.</summary>
[ObservableProperty] private double? _downloadProgress;

[ObservableProperty] private bool _isListening;

/// <summary>True while the speech models are loading after the microphone was pressed (takes a few seconds).</summary>
Expand Down Expand Up @@ -390,17 +397,40 @@ private void Retry(int index)
[RelayCommand]
private async Task DownloadModelsAsync()
{
foreach (var model in WantedModels().Where(model => !_models.HasAllExpectedLocalAssets(model)))
// What the composer needs can grow while the models download (the microphone or an import was asked for), so
// the missing models are worked off until none is left. A round that installed nothing new ends it.
var attempted = "";
List<ModelDescriptor> missing;
while ((missing = WantedModels().Where(model => !_models.HasAllExpectedLocalAssets(model)).ToList()).Count > 0)
{
await _downloads.StartAsync(model).ConfigureAwait(true);
if (!_models.HasAllExpectedLocalAssets(model))
var round = string.Join(',', missing.Select(model => model.Id));
if (round == attempted)
break;

attempted = round;
await _downloads.StartAllAsync(missing).ConfigureAwait(true);

// The composer was closed meanwhile: the downloads went on without it, and nothing here may act for a
// window that is gone (starting the microphone, for one).
if (_disposed)
return;

if (missing.Select(model => _downloads.GetState(model.Id)).FirstOrDefault(
state => state.Status is ModelDownloadStatus.Failed or ModelDownloadStatus.Cancelled) is { } stopped)
{
var error = _downloads.GetState(model.Id).ErrorMessage ?? "";
Banner = new BannerState(BannerKind.DownloadFailed, error);
ShowDownloadFailure(stopped.ErrorMessage);
return;
}
}

DownloadProgress = null;
_downloadInBackground = false;
if (missing.Count > 0)
{
ShowDownloadFailure(_downloads.GetState(missing[0].Id).ErrorMessage);
return;
}

ModelsReady = IsInstalled(_plan.For(Output));
Banner = BannerState.None;
ApplyDraftOutput();
Expand All @@ -411,8 +441,34 @@ private async Task DownloadModelsAsync()
await StartListeningAsync().ConfigureAwait(true);
}

private void ShowDownloadFailure(string? error)
{
DownloadProgress = null;
_downloadInBackground = false;
Banner = new BannerState(BannerKind.DownloadFailed, error ?? "");
}

/// <summary>
/// A summon that finds the models of its output already downloading (the composer of an earlier summon started
/// them) joins them: the footer shows the progress, and what the user asked for happens when they are done.
/// </summary>
private void AttachToRunningDownloads()
{
var wanted = WantedModels();
if (!wanted.Any(model => _downloads.GetState(model.Id).IsDownloading))
return;

_downloadInBackground = true;
DownloadProgress = OverallProgress(wanted);
if (Banner.Kind is BannerKind.ModelMissing)
Banner = BannerState.None;
_ = DownloadModelsCommand.ExecuteAsync(null);
Refresh();
}

public async ValueTask DisposeAsync()
{
_disposed = true;
_session.Changed -= OnDraftChanged;
_downloads.StateChanged -= OnDownloadStateChanged;
_voice.NoSignalChanged -= OnNoSignalChanged;
Expand Down Expand Up @@ -547,8 +603,13 @@ private void OnDownloadStateChanged(ModelDownloadState state)

void Apply()
{
if (state.Status == ModelDownloadStatus.Downloading)
Banner = new BannerState(BannerKind.Downloading, "", OverallProgress(wanted));
if (_disposed || state.Status != ModelDownloadStatus.Downloading)
return;

var progress = OverallProgress(wanted);
DownloadProgress = progress;
if (!_downloadInBackground)
Banner = new BannerState(BannerKind.Downloading, "", progress);
}

if (_notifyOn is null)
Expand Down Expand Up @@ -593,7 +654,7 @@ private void Refresh()
OnPropertyChanged(nameof(CanEditRows));

var needsModels = !ModelsReady && Output != ComposerOutput.Original;
if (needsModels && Banner.Kind is BannerKind.None)
if (needsModels && Banner.Kind is BannerKind.None && DownloadProgress is null)
Banner = new BannerState(BannerKind.ModelMissing, MissingSizeLabel());
}

Expand Down
Loading
Loading