Skip to content

Commit

Permalink
Add support for speech rate multiplier (#136)
Browse files Browse the repository at this point in the history
* Add support for speech rate multiplier

* Add null check and culture-aware formatting

* Extract duplicate tooltip text

* Avoid duplicating speech rate values

* Optimize speech rate validation

* Use words per minute instead
  • Loading branch information
justinshannon authored Feb 18, 2025
1 parent 7bcebfc commit 10336b1
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 2 deletions.
6 changes: 6 additions & 0 deletions vATIS.Desktop/Profiles/Models/AtisVoiceMeta.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public class AtisVoiceMeta
/// </summary>
public string? Voice { get; set; } = "Default";

/// <summary>
/// Gets or sets the speech rate multiplier.
/// </summary>
public int SpeechRate { get; set; } = 180;

/// <summary>
/// Creates a new instance of <see cref="AtisVoiceMeta"/> that is a copy of the current instance.
/// </summary>
Expand All @@ -30,6 +35,7 @@ public AtisVoiceMeta Clone()
{
UseTextToSpeech = UseTextToSpeech,
Voice = Voice,
SpeechRate = SpeechRate
};
}
}
5 changes: 5 additions & 0 deletions vATIS.Desktop/TextToSpeech/TextToSpeechRequestDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public class TextToSpeechRequestDto
/// </summary>
public string? Voice { get; set; }

/// <summary>
/// Gets or sets the speech rate multiplier.
/// </summary>
public double? SpeechRate { get; set; }

/// <summary>
/// Gets or sets the JSON Web Token (JWT) used for authentication.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions vATIS.Desktop/TextToSpeech/TextToSpeechService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public async Task Initialize()
{
Text = text,
Voice = VoiceList.FirstOrDefault(v => v.Name == station.AtisVoice.Voice)?.Id ?? "default",
SpeechRate = station.AtisVoice.SpeechRate,
Jwt = authToken
};

Expand Down
10 changes: 8 additions & 2 deletions vATIS.Desktop/Ui/AtisConfiguration/GeneralConfigView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Vatsim.Vatis.Ui.AtisConfiguration.GeneralConfigView">
<UserControl.Resources>
<converters:EnumToBoolConverter x:Key="EnumToBoolConverter"/>
<converters:SpeechRateMultiplierConverter x:Key="SpeechRateMultiplierConverter"/>
<converters:EnumToBoolConverter x:Key="EnumToBoolConverter"/>
<converters:CharToStringConverter x:Key="CharToStringConverter"/>
<x:String x:Key="SpeechRateTooltip">Select a speech rate to control voice ATIS speed. 180 WPM (words per minute) is the default. Lower values slow down speech, while higher values speed it up.</x:String>
</UserControl.Resources>
<StackPanel Spacing="8">
<StackPanel>
Expand All @@ -23,7 +25,7 @@
<Interaction.Behaviors>
<behaviors:VhfFrequencyFormatBehavior/>
<behaviors:SelectAllTextOnFocusBehavior/>
</Interaction.Behaviors>
</Interaction.Behaviors>
</TextBox>
</StackPanel>
</Border>
Expand Down Expand Up @@ -114,6 +116,10 @@
<ComboBox Width="280" Classes="Dark" HorizontalAlignment="Stretch" ItemsSource="{Binding AvailableVoices, DataType=vm:GeneralConfigViewModel}" SelectedValue="{Binding TextToSpeechVoice, DataType=vm:GeneralConfigViewModel}" DisplayMemberBinding="{Binding Name, DataType=tts:VoiceMetaData}" SelectedValueBinding="{Binding Name, DataType=tts:VoiceMetaData}" IsEnabled="{Binding UseTextToSpeech, DataType=vm:GeneralConfigViewModel, FallbackValue=False}"/>
<RadioButton Theme="{StaticResource RadioButton}" Content="Voice Recorded" IsChecked="{Binding !UseTextToSpeech, DataType=vm:GeneralConfigViewModel}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="13">
<TextBlock VerticalAlignment="Center" ToolTip.Tip="{StaticResource SpeechRateTooltip}">Speech Rate (WPM):</TextBlock>
<ComboBox Width="100" Classes="Dark" ItemsSource="{Binding SpeechRates, DataType=vm:GeneralConfigViewModel}" SelectedValue="{Binding SelectedSpeechRate, DataType=vm:GeneralConfigViewModel}" IsEnabled="{Binding UseTextToSpeech, DataType=vm:GeneralConfigViewModel, FallbackValue=False}" ToolTip.Tip="{StaticResource SpeechRateTooltip}"/>
</StackPanel>
<CheckBox Theme="{StaticResource CheckBox}" Content="Use &quot;decimal&quot; terminology in spoken text" ToolTip.Tip="Spoken decimal numbers will be spoken using &quot;decimal&quot; instead of &quot;point&quot; (e.g. one two one decimal three five)." IsChecked="{Binding UseDecimalTerminology, DataType=vm:GeneralConfigViewModel}" />
</StackPanel>
</Border>
Expand Down
55 changes: 55 additions & 0 deletions vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (cpp)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (cpp)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)

Check warning on line 1 in vATIS.Desktop/Ui/Converters/SpeechRateMultiplierConverter.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

A source file is missing a required header. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0073)
using System.Globalization;
using Avalonia.Data.Converters;

namespace Vatsim.Vatis.Ui.Converters;

/// <summary>
/// Converts a numeric speech rate (e.g., 0.5) to a formatted string (e.g., "0.5x").
/// </summary>
public class SpeechRateMultiplierConverter : IValueConverter
{
/// <summary>
/// Converts a double value representing the speech rate to a formatted string (e.g., "0.5x").
/// </summary>
/// <param name="value">The value to convert (expected to be a double representing the speech rate).</param>
/// <param name="targetType">The target type (not used in this implementation).</param>
/// <param name="parameter">Any optional parameters (not used in this implementation).</param>
/// <param name="culture">The culture info (not used in this implementation).</param>
/// <returns>A string formatted as the speech rate with an 'x' suffix (e.g., "1.0x").</returns>
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is null)
return null;

if (value is double rate)
{
return $"{rate.ToString(CultureInfo.InvariantCulture)}x"; // Format as 0.5x, 1.0x, etc.
}

return value;
}

/// <summary>
/// Converts a formatted string (e.g., "0.5x") back to a double value representing the speech rate.
/// </summary>
/// <param name="value">The value to convert (expected to be a string formatted as "0.5x").</param>
/// <param name="targetType">The target type (expected to be a double).</param>
/// <param name="parameter">Any optional parameters (not used in this implementation).</param>
/// <param name="culture">The culture info (not used in this implementation).</param>
/// <returns>A double representing the speech rate (e.g., 0.5).</returns>
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is null)
return null;

// If converting back (e.g., from "0.5x" to 0.5), remove the "x" and parse the number.
if (value is string rateString &&
double.TryParse(rateString.Replace("x", ""), CultureInfo.InvariantCulture, out var result))
{
return result;
}

return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ namespace Vatsim.Vatis.Ui.ViewModels.AtisConfiguration;
/// </summary>
public class GeneralConfigViewModel : ReactiveViewModelBase, IDisposable
{
private static readonly int[] s_allowedSpeechRates = [120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240];
private readonly CompositeDisposable _disposables = [];
private readonly HashSet<string> _initializedProperties = [];
private readonly IProfileRepository _profileRepository;
Expand All @@ -44,6 +45,7 @@ public class GeneralConfigViewModel : ReactiveViewModelBase, IDisposable
private string? _idsEndpoint;
private ObservableCollection<VoiceMetaData>? _availableVoices;
private bool _showDuplicateAtisTypeError;
private int _selectedSpeechRate;

/// <summary>
/// Initializes a new instance of the <see cref="GeneralConfigViewModel"/> class.
Expand Down Expand Up @@ -247,6 +249,27 @@ public ObservableCollection<VoiceMetaData>? AvailableVoices
set => this.RaiseAndSetIfChanged(ref _availableVoices, value);
}

/// <summary>
/// Gets the available speech rate multipliers.
/// </summary>
public ObservableCollection<int> SpeechRates { get; } = new(s_allowedSpeechRates);

/// <summary>
/// Gets or sets the selected speech rate multiplier.
/// </summary>
public int SelectedSpeechRate
{
get => _selectedSpeechRate;
set
{
this.RaiseAndSetIfChanged(ref _selectedSpeechRate, value);
if (!_initializedProperties.Add(nameof(SelectedSpeechRate)))
{
HasUnsavedChanges = true;
}
}
}

/// <summary>
/// Gets or sets a value indicating whether to show duplicate ATIS type error.
/// </summary>
Expand Down Expand Up @@ -374,6 +397,12 @@ public bool ApplyConfig()
SelectedStation.AtisVoice.Voice = TextToSpeechVoice;
}

if (SelectedStation.AtisVoice.SpeechRate != SelectedSpeechRate)
{
SelectedStation.AtisVoice.SpeechRate =
s_allowedSpeechRates.Contains(SelectedSpeechRate) ? SelectedSpeechRate : 180;
}

if (HasErrors || ShowDuplicateAtisTypeError)
{
return false;
Expand Down Expand Up @@ -410,6 +439,11 @@ private void HandleUpdateProperties(AtisStation? station)
UseTextToSpeech = station.AtisVoice.UseTextToSpeech;
TextToSpeechVoice = station.AtisVoice.Voice;

// Ensure speech rate is a valid value.
SelectedSpeechRate = s_allowedSpeechRates.Contains(station.AtisVoice.SpeechRate)
? station.AtisVoice.SpeechRate
: 180; // Fallback to default speech rate value.

HasUnsavedChanges = false;
}
}

0 comments on commit 10336b1

Please sign in to comment.