diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index f384da6..c4d4244 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -101,6 +101,7 @@
+
@@ -341,10 +342,28 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
+
+
+
+
+
+
+
-
@@ -358,17 +377,17 @@
-
-
+
+
-
-
diff --git a/Models/Scene.cs b/Models/Scene.cs
index 34a1baf..8aea7e5 100644
--- a/Models/Scene.cs
+++ b/Models/Scene.cs
@@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; }
+ private bool? _sceneMutesMic;
+
+ /// TASK 35: what this scene does to the mix when it takes the program output.
+ /// null = leave it alone (the default; scenes are passive until told); true mutes the mic on
+ /// entry. Applied once at entry, never while staging offline — auditioning a scene must not edit
+ /// the stream's state.
+ public bool? SceneMutesMic
+ {
+ get => _sceneMutesMic;
+ set => Set(ref _sceneMutesMic, value);
+ }
+
+ private bool? _sceneMutesDesktop;
+
+ /// TASK 35: as , for desktop/game audio.
+ public bool? SceneMutesDesktop
+ {
+ get => _sceneMutesDesktop;
+ set => Set(ref _sceneMutesDesktop, value);
+ }
+
+
///
/// Asset-table id of this scene's top-strip tile artwork. The tile is a
/// separate image from the scene's background: the preview pane keeps
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
index c0035b3..1ff071a 100644
--- a/Services/LayoutStore.Load.cs
+++ b/Services/LayoutStore.Load.cs
@@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
- cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder";
+ cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SceneMutesMic, SceneMutesDesktop FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
@@ -29,6 +29,8 @@ public partial class LayoutStore : IDisposable
HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null,
+ SceneMutesMic = reader.FieldCount > 7 && !reader.IsDBNull(7) ? reader.GetInt32(7) != 0 : null,
+ SceneMutesDesktop = reader.FieldCount > 8 && !reader.IsDBNull(8) ? reader.GetInt32(8) != 0 : null,
});
}
}
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index 61a9e62..03baaf0 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -16,6 +16,8 @@ public partial class LayoutStore : IDisposable
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackground INTEGER NOT NULL DEFAULT 1,
+ SceneMutesMic INTEGER NULL,
+ SceneMutesDesktop INTEGER NULL,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
@@ -139,6 +141,7 @@ public partial class LayoutStore : IDisposable
MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSceneThumbnailColumn();
+ MigrateSceneAudioColumns();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
@@ -154,6 +157,40 @@ public partial class LayoutStore : IDisposable
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
// get the columns here instead.
+ /// TASK 35: scene-linked audio. NULL means "leave the mix alone", so a scene is passive
+ /// until the creator says otherwise — including every layout written before this column existed.
+ private void MigrateSceneAudioColumns()
+ {
+ var sceneColumns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(Scene);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ sceneColumns.Add(reader.GetString(1));
+ }
+
+ if (!sceneColumns.Contains("SceneMutesMic"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesMic INTEGER NULL;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!sceneColumns.Contains("SceneMutesDesktop"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesDesktop INTEGER NULL;";
+ cmd.ExecuteNonQuery();
+ }
+
+ // BRB ships muted (creator spec), applied only where the creator has never expressed an opinion.
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "UPDATE Scene SET SceneMutesMic = 1 WHERE SceneMutesMic IS NULL AND LOWER(TRIM(Name)) = 'brb';";
+ cmd.ExecuteNonQuery();
+ }
+ }
+
private void MigrateSourceTable()
{
var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index fe5849a..bb839cd 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -54,8 +54,10 @@ public partial class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
- INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder)
- VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort)
+ INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId,
+ SceneMutesMic, SceneMutesDesktop, SortOrder)
+ VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail,
+ $mutesMic, $mutesDesktop, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -65,6 +67,8 @@ public partial class LayoutStore : IDisposable
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text);
+ var mutesMicP = cmd.Parameters.Add("$mutesMic", SqliteType.Integer);
+ var mutesDesktopP = cmd.Parameters.Add("$mutesDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0;
@@ -77,6 +81,12 @@ public partial class LayoutStore : IDisposable
backgroundP.Value = scene.HasBackground ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value;
+ mutesMicP.Value = scene.SceneMutesMic.HasValue
+ ? (object)(scene.SceneMutesMic.Value ? 1 : 0)
+ : DBNull.Value;
+ mutesDesktopP.Value = scene.SceneMutesDesktop.HasValue
+ ? (object)(scene.SceneMutesDesktop.Value ? 1 : 0)
+ : DBNull.Value;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
diff --git a/TASKS.md b/TASKS.md
index 18779ac..5f3f759 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -9,7 +9,7 @@
**Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.**
-`35 → 37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36`
+`37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36`
The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling,
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
@@ -19,7 +19,7 @@ output.
| Task | Status | Scope | Detail |
|---|---|---|---|
-| 35 | ☐ | **Scene-linked audio** — the scene remembers the room: BRB mutes the mic, Chat restores it, applied once at the live transition | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
+| 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
| 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) |
| 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
| 55·2–3 | ☐ | **Disk hygiene** — delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
diff --git a/TASKS/task-35-scene-linked-audio.md b/TASKS/task-35-scene-linked-audio.md
index 9c9767c..2f5ab0b 100644
--- a/TASKS/task-35-scene-linked-audio.md
+++ b/TASKS/task-35-scene-linked-audio.md
@@ -7,13 +7,19 @@
audio at transitions — the scene carries the room state. OBS needs scripts/plugins; native = the
creator-proof pitch.
-1. ☐ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default;
+**Shipped 2026-11-10.** Columns `Scene.SceneMutesMic` / `SceneMutesDesktop` (nullable = passive),
+three-state checkboxes under the layers list ("When this scene goes live"), applied once at the program
+transition in `TransitionToScene`. `SceneAudioTests` pins the whole rule: offline staging edits nothing,
+`null` never overrides a manual mute, a scene with an opinion wins on its next entry, and all three
+states survive a save/reload.
+
+1. ✅ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default;
scenes are passive until told). Schema column bump + roundtrip.
-2. ☐ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on
+2. ✅ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on
offline staging (no surprise stream-state edits while auditioning).
-3. ☐ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry.
-4. ☐ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice.
-5. ☐ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome.
-6. ☐ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override
+3. ✅ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry.
+4. ✅ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice.
+5. ✅ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome.
+6. ✅ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override
survives until the next entry.
diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs
index f0bb824..c38796c 100644
--- a/ViewModels/MainViewModel.Audio.cs
+++ b/ViewModels/MainViewModel.Audio.cs
@@ -294,6 +294,38 @@ public partial class MainViewModel
/// lip-sync is right", so it deserves one click, not a drag.
internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds;
+ /// TASK 35: mute/unmute from a scene entry. Idempotent — a scene that says "mute" while
+ /// the mic is already muted must not overwrite the level it would restore.
+ internal void SetMicMuted(bool muted)
+ {
+ if (muted == MicMuted) return;
+ if (muted)
+ {
+ _volumeBeforeMute = MicVolume;
+ MicVolume = 0;
+ }
+ else
+ {
+ MicVolume = _volumeBeforeMute ?? 0.8;
+ BeginVolumeFlash();
+ }
+ }
+
+ /// TASK 35: as , for desktop/game audio.
+ internal void SetGameMuted(bool muted)
+ {
+ if (muted == GameMuted) return;
+ if (muted)
+ {
+ _gameVolumeBeforeMute = GameAudioVolume;
+ GameAudioVolume = 0;
+ }
+ else
+ {
+ GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
+ }
+ }
+
private void ToggleMicMute()
{
if (MicMuted)
diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs
index ade76aa..06bb45e 100644
--- a/ViewModels/MainViewModel.Scenes.cs
+++ b/ViewModels/MainViewModel.Scenes.cs
@@ -21,6 +21,32 @@ public partial class MainViewModel
// XAML bindings and MainViewModel call sites keep working unchanged.
public ObservableCollection Scenes => _sceneGraph.Scenes;
+ /// TASK 35: the staged scene's audio state, bound by the left panel's three-state
+ /// checkboxes (indeterminate = leave the mix alone).
+ public bool? SceneMutesMic
+ {
+ get => StagedScene?.SceneMutesMic;
+ set
+ {
+ if (StagedScene is not { } scene || scene.SceneMutesMic == value) return;
+ scene.SceneMutesMic = value;
+ ScheduleSave();
+ OnPropertyChanged(nameof(SceneMutesMic));
+ }
+ }
+
+ public bool? SceneMutesDesktop
+ {
+ get => StagedScene?.SceneMutesDesktop;
+ set
+ {
+ if (StagedScene is not { } scene || scene.SceneMutesDesktop == value) return;
+ scene.SceneMutesDesktop = value;
+ ScheduleSave();
+ OnPropertyChanged(nameof(SceneMutesDesktop));
+ }
+ }
+
public Scene? StagedScene
{
get => _stagedScene;
@@ -38,6 +64,8 @@ public partial class MainViewModel
// (build 1082: the option vanished after the first layout load).
OnPropertyChanged(nameof(CanAddBlur));
OnPropertyChanged(nameof(StagedBrandLogo));
+ OnPropertyChanged(nameof(SceneMutesMic));
+ OnPropertyChanged(nameof(SceneMutesDesktop));
SelectedElement = null;
if (value != null)
@@ -137,9 +165,24 @@ public partial class MainViewModel
{
LiveScene = scene;
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
+ // TASK 35: the scene carries the room. Applied ONCE at entry, and only when the program
+ // output actually changes — staging a scene offline must never edit the stream's state.
+ ApplySceneAudio(scene);
}
}
+ /// TASK 35: a scene's audio state, applied once when it takes the program output.
+ /// null means "leave the mix alone" — the creator's manual mute survives until a scene that
+ /// actually has an opinion takes over.
+ private void ApplySceneAudio(Scene scene)
+ {
+ if (scene.SceneMutesMic is { } muteMic)
+ SetMicMuted(muteMic);
+
+ if (scene.SceneMutesDesktop is { } muteDesktop)
+ SetGameMuted(muteDesktop);
+ }
+
/// Raised before a scene switch so the preview can animate the old picture away.
public event Action? PreviewTransition;
@@ -377,6 +420,8 @@ public partial class MainViewModel
Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(name),
HasBackground = SceneCatalog.HasBackground(name),
+ // TASK 35: BRB ships with the mic muted; every other scene is passive until told.
+ SceneMutesMic = SceneCatalog.Is(name, SceneCatalog.Brb) ? true : null,
};
EnsureBackground(scene);
EnsureZoomLayer(scene);
diff --git a/ytLive.Tests/SceneAudioTests.cs b/ytLive.Tests/SceneAudioTests.cs
new file mode 100644
index 0000000..19d21a6
--- /dev/null
+++ b/ytLive.Tests/SceneAudioTests.cs
@@ -0,0 +1,89 @@
+using System;
+using System.IO;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 35 — the scene remembers the room.
+///
+/// The rule the test pins, in the creator's words: a scene's audio state is applied once when
+/// it takes the program output. `null` means "leave the mix alone", so staging a scene offline, or
+/// switching to a scene with no opinion, must never undo a manual mute.
+///
+[Collection("RealApp")]
+public sealed class SceneAudioTests
+{
+ private readonly RealAppHost _app;
+
+ public SceneAudioTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void EnteringALiveScene_AppliesItsAudioState_OnceAndOnlyLive() => _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-sceneaudio-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ var vm = new MainViewModel();
+ var brb = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Brb));
+ var chat = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Chat));
+
+ // Ships muted (the creator's default), while every other scene is passive.
+ Assert.True(brb.SceneMutesMic);
+ Assert.Null(chat.SceneMutesMic);
+ Assert.Null(brb.SceneMutesDesktop);
+
+ vm.MicVolume = 0.8;
+ Assert.False(vm.MicMuted);
+
+ // Staging offline: no program output, so no audio edit. The creator is auditioning.
+ vm.TransitionToScene(brb);
+ Assert.False(vm.MicMuted);
+
+ // Live: entering BRB mutes the mic.
+ vm.StreamStatus = StreamStatus.Streaming;
+ vm.TransitionToScene(vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)));
+ vm.TransitionToScene(brb);
+ Assert.True(vm.MicMuted);
+
+ // The creator is boss: a manual unmute survives a scene with no opinion.
+ vm.SetMicMuted(false);
+ Assert.False(vm.MicMuted);
+ vm.TransitionToScene(chat);
+ Assert.False(vm.MicMuted);
+
+ // ...but a scene that DOES have an opinion wins on its next entry.
+ chat.SceneMutesMic = true;
+ vm.TransitionToScene(brb);
+ vm.TransitionToScene(chat);
+ Assert.True(vm.MicMuted);
+
+ // Desktop audio was never told anything, so it was never touched.
+ Assert.False(vm.GameMuted);
+
+ // Roundtrip: the three states survive a save + reload, null included.
+ vm.SaveLayoutNow();
+ SqliteConnection.ClearAllPools();
+ using var store = new LayoutStore(tempDb);
+ var reloaded = store.Load();
+ var reloadedBrb = reloaded.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Brb));
+ var reloadedChat = reloaded.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Chat));
+ var reloadedLive = reloaded.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
+ Assert.True(reloadedBrb.SceneMutesMic);
+ Assert.True(reloadedChat.SceneMutesMic);
+ Assert.Null(reloadedLive.SceneMutesMic);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best effort */ }
+ }
+ });
+}