diff --git a/RELEASES-COMMITS-MAP.md b/RELEASES-COMMITS-MAP.md index b4cccfd..b76f82a 100644 --- a/RELEASES-COMMITS-MAP.md +++ b/RELEASES-COMMITS-MAP.md @@ -40,3 +40,4 @@ Ship recipe: **`HOWTO-DEVEL-DEPLOY.md`**. | 0.6.81 | `02aff5715a386a68333dc65aa44af05836e6adb3` | dirty | 2026-09-15T21:06:49-03:00 | `0a0266909c800a58620b3e0c8a47919a8315000a19accc1c1405f76fc6e0b113` | build | | 0.7.0 | `60b19e108776101a1e3c038c9a6819da01c8e6cf` | dirty | 2026-09-15T22:36:42-03:00 | `9098dfbf31e60e9fada3141d0b0ae91f9d5b5b4e5c0ba2dfaf46a72794c704a2` | build | | 0.7.1 | `a27057daf60a2399706ccef1d0ef99299304b6b5` | dirty | 2026-09-16T07:22:19-03:00 | `1dcf52bc466ce7b537621b8bedcba1ffb7edd352a68625692f99083be32792f1` | build | +| 0.7.2 | `8ca0618536894f2c23034aa8bbc6ce94276e7a28` | dirty | 2026-09-16T11:07:11-03:00 | `a6f072549e721c59e4d61a2eb479fe6e15ad893db742b55707e68e30bff195c0` | build | diff --git a/announce.go b/announce.go index f8dedc3..bbd3efb 100644 --- a/announce.go +++ b/announce.go @@ -97,6 +97,23 @@ var errClipFailed = errors.New("indisponível no momento — falha no download") const overlayActionTimeout = 60 * time.Second +// duckFor picks the radio level under an announcement: the caller's level +// while music plays, the spot level (default silence) while a playlist spot +// is on air, so the voice is never mixed with another voice. +func duckFor(cfg Config, activeCommercial bool, musicDuck float64) float64 { + if !activeCommercial { + return musicDuck + } + if cfg.SpotDuckLevel == nil { + return 0 + } + return float64(*cfg.SpotDuckLevel) / 100 +} + +func (a *Announcer) duckLevel(cfg Config, musicDuck float64) float64 { + return duckFor(cfg, a.player.activeIsCommercial(), musicDuck) +} + func waitOverlaySlot(ctx context.Context, p *Player, d time.Duration) error { if p == nil || !p.overlayBlocked() { return nil @@ -181,7 +198,7 @@ func (a *Announcer) PlayDeskApp(parent context.Context, desk *DeskApp, id string cfg := a.store.Config() btn, _ := desk.Button(id) - duck := buttonDuck(cfg, btn) + duck := a.duckLevel(cfg, buttonDuck(cfg, btn)) chimeOn := buttonChime(cfg, btn) a.set("Abaixando a música", id) a.player.RampDuck(duck, cfg.FadeDownMs) @@ -306,7 +323,7 @@ func (a *Announcer) PlayRemoteURL(parent context.Context, desk *DeskApp, label, return err } cfg := a.store.Config() - duck := float64(cfg.DuckLevel) / 100 + duck := a.duckLevel(cfg, float64(cfg.DuckLevel)/100) a.set("Abaixando a música", label) a.player.RampDuck(duck, cfg.FadeDownMs) if ctx.Err() != nil { @@ -384,7 +401,7 @@ func (a *Announcer) speakLocked(parent context.Context, desk *DeskApp, kind, tex } a.player.rdb.LogEvent("info", "call", "call.synth_ok", text, 0, text, details) } - duck := float64(cfg.DuckLevel) / 100 + duck := a.duckLevel(cfg, float64(cfg.DuckLevel)/100) a.set("Abaixando a música", text) a.player.RampDuck(duck, cfg.FadeDownMs) if ctx.Err() != nil { @@ -465,7 +482,7 @@ func (a *Announcer) Announce(parent context.Context, category, text, speech stri } a.set("Abaixando a música", text) - duck := float64(cfg.DuckLevel) / 100 + duck := a.duckLevel(cfg, float64(cfg.DuckLevel)/100) a.player.RampDuck(duck, cfg.FadeDownMs) if ctx.Err() != nil { return ctx.Err() diff --git a/announce_test.go b/announce_test.go index 60d9230..c131aea 100644 --- a/announce_test.go +++ b/announce_test.go @@ -21,10 +21,37 @@ func TestOverlayBlockedIgnoresBusy(t *testing.T) { } } -func TestOverlayBlockedOnCommercial(t *testing.T) { - p := &Player{active: &core.Decision{Media: core.MediaSchedule{Kind: "spot"}}} - if !p.overlayBlocked() { - t.Fatal("spot must block overlay") +func TestOverlayBlockedOnlyForExactAndEvents(t *testing.T) { + spot := &Player{active: &core.Decision{Media: core.MediaSchedule{Kind: "spot"}}} + if spot.overlayBlocked() { + t.Fatal("a playlist spot must not block the overlay; it is muted under the voice") + } + event := &Player{active: &core.Decision{Media: core.MediaSchedule{Kind: "event"}}} + if !event.overlayBlocked() { + t.Fatal("event must block overlay") + } + exact := &Player{active: &core.Decision{Media: core.MediaSchedule{Kind: "spot"}, Reservation: core.Reservation{Exact: &core.ExactTimeOccurrence{}}}} + if !exact.overlayBlocked() { + t.Fatal("exact-time spot must block overlay") + } +} + +func TestDuckForSpotSilencesRadio(t *testing.T) { + cfg := Config{DuckLevel: 10} + if got := duckFor(cfg, false, 0.1); got != 0.1 { + t.Fatalf("music keeps the caller level, got %v", got) + } + if got := duckFor(cfg, true, 0.1); got != 0 { + t.Fatalf("spot defaults to silence, got %v", got) + } + five := 5 + cfg.SpotDuckLevel = &five + if got := duckFor(cfg, true, 0.1); got != 0.05 { + t.Fatalf("spotDuckLevel override, got %v", got) + } + merged := mergeConfig(Config{DuckLevel: 10}, Config{SpotDuckLevel: &five}) + if merged.SpotDuckLevel == nil || *merged.SpotDuckLevel != 5 { + t.Fatalf("mergeConfig must carry spotDuckLevel, got %v", merged.SpotDuckLevel) } } @@ -42,7 +69,7 @@ func TestWaitOverlaySlotIdleIsFast(t *testing.T) { } func TestWaitOverlaySlotTimeout(t *testing.T) { - p := &Player{active: &core.Decision{Media: core.MediaSchedule{Kind: "spot"}}} + p := &Player{active: &core.Decision{Media: core.MediaSchedule{Kind: "event"}}} err := waitOverlaySlot(context.Background(), p, 30*time.Millisecond) if !errors.Is(err, errOverlayTimeout) { t.Fatalf("got %v", err) diff --git a/embed/templates.json b/embed/templates.json index 97391c4..51ae6c7 100644 --- a/embed/templates.json +++ b/embed/templates.json @@ -157,6 +157,7 @@ "defaults": { "modelId": "eleven_multilingual_v2", "duckLevel": 10, + "spotDuckLevel": 0, "fadeDownMs": 400, "fadeUpMs": 800, "voices": { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2a1133f..2dedac3 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -71,6 +71,7 @@ export namespace main { export class Config { modelId: string; duckLevel: number; + spotDuckLevel?: number; fadeDownMs: number; fadeUpMs: number; voices: Record; @@ -87,6 +88,7 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.modelId = source["modelId"]; this.duckLevel = source["duckLevel"]; + this.spotDuckLevel = source["spotDuckLevel"]; this.fadeDownMs = source["fadeDownMs"]; this.fadeUpMs = source["fadeUpMs"]; this.voices = source["voices"]; diff --git a/models.go b/models.go index 172f7ff..e58fb8a 100644 --- a/models.go +++ b/models.go @@ -74,6 +74,7 @@ type Freeform struct { type Config struct { ModelID string `json:"modelId"` DuckLevel int `json:"duckLevel"` + SpotDuckLevel *int `json:"spotDuckLevel,omitempty"` FadeDownMs int `json:"fadeDownMs"` FadeUpMs int `json:"fadeUpMs"` Voices map[string]string `json:"voices"` @@ -139,6 +140,10 @@ func mergeConfig(defaults, stored Config) Config { if stored.DuckLevel != 0 { out.DuckLevel = stored.DuckLevel } + if stored.SpotDuckLevel != nil { + v := *stored.SpotDuckLevel + out.SpotDuckLevel = &v + } if stored.FadeDownMs != 0 { out.FadeDownMs = stored.FadeDownMs } diff --git a/player.go b/player.go index b40dfd1..9328002 100644 --- a/player.go +++ b/player.go @@ -855,10 +855,24 @@ func (p *Player) overlayBlocked() bool { if d == nil { return false } - if d.Reservation.Exact != nil { - return true + return overlayBlockedFor(d.Reservation.Exact != nil, d.Media.Kind) +} + +// overlayBlockedFor keeps exact-time plays and events off limits for an +// announcement. A plain playlist spot is not protected: it gets muted under +// the voice instead of making the operator wait for it. +func overlayBlockedFor(exact bool, kind string) bool { + return exact || core.IsEvent(kind) +} + +func (p *Player) activeIsCommercial() bool { + if p == nil { + return false } - return core.IsCommercial(d.Media.Kind) || core.IsEvent(d.Media.Kind) + p.mu.Lock() + d := p.active + p.mu.Unlock() + return d != nil && core.IsCommercial(d.Media.Kind) } func (p *Player) blocksUpdate() bool {