diff --git a/Readme.md b/Readme.md
index 51512ad..0c777af 100644
--- a/Readme.md
+++ b/Readme.md
@@ -16,6 +16,17 @@
- [Workflow](#workflow)
- [OS images checked](#os-images-checked)
- [OS images to check](#os-images-to-check)
+- [EARS Application](#ears-application)
+ - [11. Project Layout](#11-project-layout)
+ - [12. Single-Window Notebook Architecture](#12-single-window-notebook-architecture)
+ - [13. Glade Rules That Bite](#13-glade-rules-that-bite)
+ - [14. Generating Widgets From Code](#14-generating-widgets-from-code)
+ - [15. Loading cases.csv](#15-loading-casescsv)
+ - [16. File Paths — AppFile()](#16-file-paths--appfile)
+ - [17. Audio Playback](#17-audio-playback)
+ - [18. Build & Run](#18-build--run)
+ - [19. Debugging Autoconnect Failures](#19-debugging-autoconnect-failures)
+ - [20. Windows Deployment Differences](#20-windows-deployment-differences)
---
@@ -379,4 +390,494 @@
### OS images to check
- antiX-26_x64-full
-- xubuntu-26.04-minimal-amd64
\ No newline at end of file
+- xubuntu-26.04-minimal-amd64
+
+
+---
+
+# EARS Application
+
+Notes covering the GTK# port of the auscultation trainer: dynamic UI
+generation from CSV, the notebook page structure, and the failure modes
+that cost the most time.
+
+---
+
+## 11. Project Layout
+
+Source lives in `program/`, build output in `program/bin/linux/`.
+Runtime assets must sit **next to the .exe**, not next to the source.
+
+```
+program/
+├── Program.cs
+├── MainWindow.cs root window + splash page
+├── SettingWindow.cs settings page controller
+├── SoundWindow.cs sound page controller
+├── CaseDefinition.cs CSV row model + loader
+├── WavePlayer.cs cross-platform wav playback
+├── ArduinoConnection.cs
+├── Language.cs
+├── homepageallv3.glade
+├── cases.csv
+├── map/
+├── sound/
+└── bin/linux/ ← everything above (minus .cs) copied here
+ ├── program.exe
+ ├── homepageallv3.glade
+ ├── cases.csv
+ ├── map/
+ └── sound/
+```
+
+---
+
+## 12. Single-Window Notebook Architecture
+
+One `GtkWindow` (`Root`) holds one `GtkNotebook` (`RootNoteBook`) with
+tabs hidden. Each "screen" is a notebook page; navigation is just
+`RootNoteBook.CurrentPage = N`. No second window is ever created.
+
+| Page | Index | Root widget | Controller |
+|---|---|---|---|
+| Splash | 0 | `SplashGrid` | `MainWindow` |
+| Settings | 1 | `SettingGird` | `SettingsWindow` |
+| Sound | 2 | `SoundBox` | `SoundWindow` |
+
+`SettingsWindow` and `SoundWindow` are **not** `Gtk.Window` subclasses
+despite the names — they're plain controller classes that receive the
+shared `Builder` and bind their own widgets:
+
+```csharp
+public class SoundWindow
+{
+ [UI] private Label ConditionNameLabel = null;
+ [UI] private Grid SoundButtonGrid = null;
+ [UI] private Button SoundBackButton = null;
+
+ public event EventHandler BackRequested;
+
+ public SoundWindow(Builder builder)
+ {
+ builder.Autoconnect(this);
+ ...
+ }
+}
+```
+
+Wired up in `MainWindow`'s private constructor:
+
+```csharp
+_soundWindow = new SoundWindow(builder);
+_soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash;
+Option1Button.Clicked += OnNormalSessionClicked;
+```
+
+Controllers never touch the notebook directly — they raise
+`BackRequested` and let `MainWindow` decide. Keeps navigation in one place.
+
+**All three controllers share one `Builder`.** Each `Autoconnect` call
+binds only the ids matching that class's `[UI]` fields, which is exactly
+why ids must be unique across the entire file (§13).
+
+---
+
+## 13. Glade Rules That Bite
+
+### IDs must be unique file-wide and valid C# identifiers
+
+`Autoconnect` maps glade ids to field names by string match across the
+**whole** builder, not per page. Two widgets sharing an id bind
+unpredictably.
+
+| Broken | Why | Fixed |
+|---|---|---|
+| `Condition Name` | Space — never matches a field name | `ConditionNameLabel` |
+| `BackButto` | Typo | `SoundBackButton` |
+| `BackButton` on two pages | Duplicate across pages | `SettingBackButton` + `SoundBackButton` |
+
+A mismatch **fails silently** — the field stays `null` and you get a
+`NullReferenceException` later, often several clicks away from the cause.
+See §19 for the guard that catches this at startup.
+
+Verify before running:
+
+```bash
+grep -o 'id="[^"]*"' homepageallv3.glade | sort | uniq -d
+```
+
+Any output is a duplicate id.
+
+### Placeholders are design-time only
+
+The hatched empty cells Glade shows in a `GtkGrid` save as
+`` and are **ignored at load time**. A button placed at
+`left-attach=2` in a designer grid with two empty columns to its left
+ends up at column 0 in the running app — this is the classic
+"button jumps to the left" bug.
+
+Never use placeholders for spacing or alignment. Use `halign` + `hexpand`.
+
+### Right-aligning a button
+
+Both properties are required:
+
+```xml
+end
+True
+```
+
+`hexpand` makes the cell consume the full row width; `halign=end` parks
+the button at the right edge of that cell. `halign` alone does nothing
+when the cell is only as wide as the button.
+
+In Glade: **Common** tab → *Horizontal Alignment* = `End`,
+*Expand → Horizontal* = checked.
+
+### Scrolling a grid needs a Viewport
+
+`GtkGrid` doesn't implement `GtkScrollable`. Dropping one into a
+`GtkScrolledWindow` requires an intermediate `GtkViewport` — Glade
+inserts it automatically. Don't delete it.
+
+```
+SoundScroller GtkScrolledWindow hexpand + vexpand, packing expand=True
+└─ GtkViewport (auto-added, required)
+ └─ SoundButtonGrid GtkGrid empty, column-homogeneous=True
+```
+
+`GtkFlowBox` *is* scrollable and wraps children automatically, but the
+`gtk-sharp3` binding on Ubuntu is 2.99.x and may not expose it. Check
+before relying on it:
+
+```bash
+monop -r:/usr/lib/cli/gtk-sharp-3.0/gtk-sharp.dll Gtk.FlowBox
+```
+
+### Expand appears in two tabs
+
+For a widget inside a `GtkBox`, **Common → Expand** sets the widget's own
+`hexpand`/`vexpand`, while **Packing → Expand** sets the box child
+property. They are different things and both usually need setting.
+
+---
+
+## 14. Generating Widgets From Code
+
+Leave the container **empty** in Glade and fill it at runtime. Three
+rules, all of which produce silent failures when broken:
+
+```csharp
+private void BuildButtons(IEnumerable labels, Action onPick)
+{
+ // 1. Remove AND destroy — Remove alone leaks the widget
+ foreach (var child in SoundButtonGrid.Children)
+ {
+ SoundButtonGrid.Remove(child);
+ child.Destroy();
+ }
+
+ int i = 0;
+ foreach (string text in labels)
+ {
+ // 2. Capture the loop variable — otherwise every handler
+ // sees the final value
+ string captured = text;
+
+ var btn = new Button(captured);
+ btn.Hexpand = true;
+ btn.Clicked += (s, e) => onPick(captured);
+ SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1);
+ i++;
+ }
+
+ // 3. Widgets created in code start HIDDEN. Without this the grid
+ // stays blank with no error of any kind.
+ SoundButtonGrid.ShowAll();
+}
+```
+
+`ShowAll()` on the container is the single most common cause of
+"my buttons didn't appear" in GTK#.
+
+### Drill-down navigation
+
+All three modes (type → condition → play) use one grid and one render
+method. State is a `List` path; Back pops one level:
+
+```csharp
+private readonly List _path = new List();
+
+private void Render()
+{
+ var matches = _allCases.Where(MatchesPath).ToList();
+
+ var options = matches
+ .Where(c => c.TreePath.Length > _path.Count)
+ .Select(c => c.TreePath[_path.Count])
+ .Distinct()
+ .ToList();
+
+ ConditionNameLabel.Text = _path.Count == 0
+ ? "種別を選択"
+ : string.Join(" : ", _path);
+
+ BuildButtons(options, picked => { _path.Add(picked); /* leaf? play : Render(); */ });
+}
+
+private void OnBackClicked(object sender, EventArgs e)
+{
+ if (_path.Count > 0) { _path.RemoveAt(_path.Count - 1); Render(); }
+ else { WavePlayer.Stop(); BackRequested(this, EventArgs.Empty); }
+}
+```
+
+Tree depth varies per row (`Tree_Level3` is often empty), so
+`CaseDefinition.TreePath` returns only the non-empty levels and the
+drill-down adapts automatically.
+
+---
+
+## 15. Loading cases.csv
+
+`CaseDefinition.LoadCasesFromCsv(path)` returns `List`.
+Column → property mapping:
+
+| CSV column | Property |
+|---|---|
+| `Number` | `Number` |
+| `Type` | `Type` (心音 / 呼吸音) — also drives `IsHeart` |
+| `Category` | `CategoryJp` |
+| `Subcategory` | `SubcategoryJp` |
+| `Location` | `LocationJp` |
+| `Tree_Level1..3` | `TreeLevel1..3` |
+| `Image_File` | `MapFront` |
+| `Sound_File` | `SoundPath` |
+| `Image_Right` / `Image_Left` / `Image_Back` | `MapRight` / `MapLeft` / `MapBack` |
+
+Four things the loader must get right:
+
+**Encoding.** The file contains Japanese. Read with `Encoding.UTF8` and
+BOM detection — the default ANSI codepage produces mojibake that only
+shows up on the rendered buttons.
+
+**Delimiter.** The file is TAB separated. Auto-detect so a re-export
+from Excel as comma-separated doesn't break it:
+
+```csharp
+char sep = lines[0].Contains("\t") ? '\t' : ',';
+```
+
+**Header mapping.** Build `name → index` from row 0 rather than
+hardcoding positions, so adding or reordering columns is safe.
+
+**InvariantCulture on every numeric parse.** Same failure class as the
+CSS scaling bug in §"scaling" — under `ja_JP`/`de_DE` the
+culture-sensitive default silently misparses:
+
+```csharp
+int.TryParse(get(f, "Number"), NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out number)
+```
+
+The loader prints its result on every run:
+
+```
+Loaded 98 cases from /path/to/bin/linux/cases.csv
+```
+
+`Loaded 0 cases` points at the path or the delimiter, not the UI.
+
+---
+
+## 16. File Paths — AppFile()
+
+**Never use bare relative paths.** `Path.GetFullPath("cases.csv")`
+resolves against the *working directory*, so the app works when launched
+from `program/` and silently loads nothing from anywhere else.
+
+```csharp
+public static string AppFile(string relative)
+{
+ string dir = Path.GetDirectoryName(
+ System.Reflection.Assembly.GetExecutingAssembly().Location);
+ return Path.Combine(dir, relative);
+}
+```
+
+Use it for **everything**: the glade file, the CSV, maps, sounds.
+
+```csharp
+builder.AddFromFile(SoundWindow.AppFile("homepageallv3.glade"));
+```
+
+Mixing the two conventions is how you end up editing
+`program/homepageallv3.glade` while the app reads
+`bin/linux/homepageallv3.glade` — every fix appears to do nothing.
+
+**Linux is case-sensitive.** `sound/snd200.wav` ≠ `sound/SND200.wav`,
+which was fine on Windows and isn't now. Audit the CSV against the disk:
+
+```bash
+cut -f10 cases.csv | tail -n +2 | while read f; do
+ [ -n "$f" ] && [ -f "$f" ] || echo "MISSING: $f"
+done
+```
+
+---
+
+## 17. Audio Playback
+
+XAudio2/SharpDX from the WinForms build does **not** load under Mono on
+Linux. `WavePlayer` shells out instead:
+
+| Platform | Mechanism |
+|---|---|
+| Linux / WSL | `paplay`, falling back to `aplay` |
+| Windows | `System.Media.SoundPlayer` |
+
+Install both helpers — WSLg routes through PulseAudio, so `paplay` is
+the one that works:
+
+```bash
+sudo apt install pulseaudio-utils alsa-utils
+```
+
+Verify outside the app before blaming the C#:
+
+```bash
+paplay bin/linux/sound/SND200.wav
+echo $PULSE_SERVER # empty under WSLg → wsl --shutdown and retry
+```
+
+**Format matters on Windows.** `SoundPlayer` handles PCM WAV only:
+
+```bash
+file sound/SND200.wav # want: RIFF ... WAVE audio, Microsoft PCM
+```
+
+**`Stop()` is a no-op on Windows** — `SoundPlayer.Play()` returns no
+handle. If stop-on-back is needed there, use `PlaySync()` on a
+background thread or add NAudio.
+
+---
+
+## 18. Build & Run
+
+Debug build (keeps the console so `Console.WriteLine` diagnostics show):
+
+```bash
+mcs -pkg:gtk-sharp-3.0 *.cs -out:./bin/linux/program.exe \
+ && cp homepageallv3.glade cases.csv ./bin/linux/ \
+ && cp -r map sound ./bin/linux/ \
+ && mono ./bin/linux/program.exe
+```
+
+The `cp` steps are not optional — stale assets in `bin/linux/` are a
+recurring source of phantom bugs (§16).
+
+Re-copying every wav on each build gets slow. Once stable, symlink and
+drop the `cp -r`:
+
+```bash
+ln -s ../../map bin/linux/map
+ln -s ../../sound bin/linux/sound
+```
+
+Release build (`-target:winexe` suppresses the console — don't use it
+while debugging):
+
+```bash
+mcs -target:winexe -pkg:gtk-sharp-3.0 *.cs -out:./bin/windows/program.exe
+```
+
+---
+
+## 19. Debugging Autoconnect Failures
+
+An unbound `[UI]` field is `null` with no warning. Add an explicit guard
+to every controller constructor so the failure names the widget at
+startup:
+
+```csharp
+builder.Autoconnect(this);
+
+if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null)
+ throw new InvalidOperationException(
+ "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) +
+ " SoundButtonGrid=" + (SoundButtonGrid != null) +
+ " SoundBackButton=" + (SoundBackButton != null));
+```
+
+Output looks like:
+
+```
+System.InvalidOperationException: Glade id mismatch —
+ ConditionNameLabel=False SoundButtonGrid=True SoundBackButton=True
+```
+
+For classes with many fields, loop a dictionary instead:
+
+```csharp
+foreach (var pair in new Dictionary {
+ { "ComPortLabel", ComPortLabel }, { "ConnectButton", ConnectButton },
+ { "SettingBackButton", SettingBackButton }, /* ... */ })
+ if (pair.Value == null)
+ throw new InvalidOperationException("Glade id not bound: " + pair.Key);
+```
+
+### Reading a GTK# stack trace
+
+Exceptions inside signal handlers arrive wrapped:
+
+```
+System.Reflection.TargetInvocationException ---> System.NullReferenceException
+ at SoundWindow.Render () [0x0007b]
+```
+
+Ignore the `GLib.SignalClosure` / `MarshalCallback` frames — they're
+plumbing. The first frame naming **your** class is the real site, and
+the `[0x...]` IL offset distinguishes lines within it.
+
+### Common failures
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| NRE in controller ctor | `[UI]` field id mismatch | §19 guard, then fix the glade id |
+| NRE on first click, no CSV log line | Controller never constructed | Assign it in the **private** `MainWindow(Builder)` ctor |
+| Buttons don't appear, no error | Missing `ShowAll()` | Call it on the container after `Attach` |
+| Every button does the same thing | Closure over loop variable | `string captured = text;` |
+| `Loaded 0 cases` | Wrong path or delimiter | Use `AppFile()`; check tab vs comma |
+| Button sits left despite `halign=end` | Relying on grid placeholders | Add `hexpand=True` |
+| Fix appears to do nothing | Editing a different copy of the glade | Use `AppFile()` + `cp` on build |
+| Japanese renders as garbage | Wrong encoding | `File.ReadAllLines(path, Encoding.UTF8)` |
+| GTK warning about scrolling | Grid directly in ScrolledWindow | Keep the `GtkViewport` |
+
+---
+
+## 20. Windows Deployment Differences
+
+The C#, the glade file, and the CSV are **identical**. Four differences:
+
+1. **Audio** — see §17. PCM-only, and `Stop()` doesn't work.
+2. **Build target** — `-target:winexe` for no console window.
+3. **Runtime** — Mono for Windows must be installed on the target
+ machine; it bundles the GTK# runtime.
+4. **Serial ports** — `ArduinoConnection` enumerates `COM*` rather than
+ `/dev/ttyUSB*` / `/dev/ttyACM*`. Mono's Linux `SerialPort.GetPortNames()`
+ has historically missed devices; verify against `ls /dev/tty*`.
+
+Ship this folder:
+
+```
+program.exe
+homepageallv3.glade
+cases.csv
+map/
+sound/
+```
+
+Not problems, for the record: forward slashes in CSV paths work fine on
+Windows, and Windows' case-insensitivity means anything working on Linux
+also works there — never the reverse. Develop on Linux and Windows
+comes free.
\ No newline at end of file
diff --git a/ReadmeQuick.md b/ReadmeQuick.md
index f227bb0..768dd98 100644
--- a/ReadmeQuick.md
+++ b/ReadmeQuick.md
@@ -14,4 +14,9 @@
# Program referencing that DLL
mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe
+
+#run with edit
+GTK_DEBUG=interactive mono ./bin/linux/program.exe
+
```
+
diff --git a/program/MainWindow.cs b/program/MainWindow.cs
index b68f449..16b6d65 100644
--- a/program/MainWindow.cs
+++ b/program/MainWindow.cs
@@ -14,6 +14,7 @@
private const int PageSplash = 0;
private const int PageSettings = 1;
+ private const int PageSound = 2;
// ---- splash page widgets ----
[UI] private Button Option1Button = null;
@@ -45,12 +46,14 @@
internal SettingsWindow _settingsWindow;
internal Language _Language;
+ private SoundWindow _soundWindow;
+
public MainWindow() : this(CreateBuilder()) { }
private static Builder CreateBuilder()
{
var builder = new Builder();
- builder.AddFromFile("homepageall.glade");
+ builder.AddFromFile("homepageallv3.glade");
return builder;
}
@@ -86,7 +89,6 @@
_arduino.ConnectionChanged += OnArduinoConnectionChanged;
-
// // _buttonsById = new Dictionary
// // {
// // { "Option1Button", Option1Button },
@@ -129,6 +131,9 @@
RootNoteBook.ShowTabs = false;
RootNoteBook.ShowBorder = false;
RootNoteBook.CurrentPage = PageSplash;
+ _soundWindow = new SoundWindow(builder);
+ _soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash;
+
OnArduinoConnectionChanged(false);
}
@@ -287,6 +292,8 @@
private void OnNormalSessionClicked(object sender, EventArgs e)
{
Console.WriteLine("Normal Session selected");
+ RootNoteBook.CurrentPage = PageSound;
+ _soundWindow.Reset();
}
private void OnPositionRecordingClicked(object sender, EventArgs e)
diff --git a/program/SettingWindow.cs b/program/SettingWindow.cs
index f39a427..f091c74 100644
--- a/program/SettingWindow.cs
+++ b/program/SettingWindow.cs
@@ -10,7 +10,7 @@
[UI] private Button ConnectButton = null;
[UI] private Button AutoConnectButton = null;
[UI] private Button RefreshButton = null;
- [UI] private Button BackButton = null;
+ [UI] private Button SettingBackButton = null;
[UI] private Label StatusLabel = null;
[UI] private Label LogLabel = null;
[UI] private TextView LogtextBox = null;
@@ -45,7 +45,7 @@
RefreshButton.Clicked += (s, e) => RefreshPorts();
ConnectButton.Clicked += OnConnectClicked;
AutoConnectButton.Clicked += OnAutoConnectClicked;
- BackButton.Clicked += OnBackClicked;
+ SettingBackButton.Clicked += OnBackClicked;
// DeleteEvent += (o, a) => { a.RetVal = true; OnBackClicked(o, EventArgs.Empty); };
@@ -57,7 +57,7 @@
_lang.Register("LogLabel", LogLabel);
_lang.Register("AutoConnectButton", AutoConnectButton);
_lang.Register("RefreshButton", RefreshButton);
- _lang.Register("BackButton", BackButton);
+ _lang.Register("SettingBackButton", SettingBackButton);
_lang.RegisterDynamic("ConnectButton", ConnectButton); // set in OnConnectionChanged
_lang.RegisterDynamic("StatusLabel", StatusLabel);
_lang.Changed += (s, e) => OnConnectionChanged(_connected);
diff --git a/program/homepageall.glade b/program/homepageall.glade
index 07009b2..03f30c2 100644
--- a/program/homepageall.glade
+++ b/program/homepageall.glade
@@ -352,4 +352,136 @@
+
diff --git a/program/info_eng.csv b/program/info_eng.csv
index a7bb3e8..0335a1a 100644
--- a/program/info_eng.csv
+++ b/program/info_eng.csv
@@ -10,7 +10,7 @@
LogLabel,Log
AutoConnectButton,Auto Connect
RefreshButton,Refresh Ports
-BackButton,Back
+SettingBackButton,Back
ConnectButton.Connect,Connect
ConnectButton.Disconnect,Disconnect
StatusLabel.Connected,Connected ({0})
diff --git a/program/info_jpn.csv b/program/info_jpn.csv
index 5cac763..eb576d7 100644
--- a/program/info_jpn.csv
+++ b/program/info_jpn.csv
@@ -10,7 +10,7 @@
LogLabel,ログ
AutoConnectButton,自動接続
RefreshButton,ポート更新
-BackButton,戻る
+SettingBackButton,戻る
ConnectButton.Connect,接続
ConnectButton.Disconnect,切断
StatusLabel.Connected,接続済み ({0})