1package vchan23// Loudness: custom channels' sound can be evened out to one level, as ATSC4// A/85 asks of broadcast TV (-24 LKFS), so a loud YouTube upload doesn't5// follow a quiet Jellyfin episode. Nothing extra is read for it: the sound6// is metered as it streams, in the raw samples each decoder hands the7// encoder (ITU-R BS.1770: K-weighted, gated 400 ms blocks), and turned up8// or down slowly toward the target as each video's loudness becomes9// known. What a video measured is remembered, so its next airing starts10// at the right level.1112import (13 "encoding/json"14 "math"15 "os"16 "sync"17)1819// DefaultLoudness is broadcast TV's level (ATSC A/85), in LUFS (the same20// as LKFS): the level to even custom channels out to.21const DefaultLoudness = -24.02223// loudness is what airings of a video measured: its gated integrated24// loudness, over so many seconds of gated sound.25type loudness struct {26 LUFS float64 `json:"lufs"`27 Seconds float64 `json:"seconds,omitempty"`28}2930const (31 minGain = -20.032 maxGain = 12.033 // quietest is the loudness below which a video is taken for silent34 // and left as it is, rather than raised.35 quietest = -50.036 // peakCeiling is the sample peak the leveled sound is held under,37 // -1 dBFS, so raised sound doesn't clip.38 peakCeiling = 0.89139)4041// gainFor is the gain in dB that brings sound of lufs to target: within42// -20 to +12 dB, and none for near silence.43func gainFor(lufs, target float64) float64 {44 if target == 0 || math.IsNaN(lufs) || lufs < quietest {45 return 046 }47 return min(max(target-lufs, minGain), maxGain)48}4950// levelMemory remembers videos' loudness from one airing to the next.51type levelMemory interface {52 recall(key string) (loudness, bool)53 remember(key string, l loudness)54}5556// itemLevel says how to level an item's sound.57type itemLevel struct {58 Target float64 // LUFS59 // Key is the video in Memory, which remembers what it measures; nil60 // remembers nothing.61 Key string62 Memory levelMemory63 // Assumed is the loudness taken for a video not yet measured, 0 for64 // the target's.65 Assumed float6466}6768// start is the gain an airing starts at: from what the video measured69// before, else what's assumed of it.70func (lv *itemLevel) start() float64 {71 if lv.Memory != nil {72 if l, ok := lv.Memory.recall(lv.Key); ok {73 return gainFor(l.LUFS, lv.Target)74 }75 }76 if lv.Assumed != 0 {77 return gainFor(lv.Assumed, lv.Target)78 }79 return 080}8182// remembered merges a new measurement into what was known: an average of83// their energies, by seconds measured, the old counting at most an hour84// so new airings keep mattering.85func remembered(old, new loudness, known bool) loudness {86 if !known || old.Seconds <= 0 {87 return new88 }89 wo, wn := min(old.Seconds, 3600), new.Seconds90 e := (wo*math.Pow(10, old.LUFS/10) + wn*math.Pow(10, new.LUFS/10)) / (wo + wn)91 return loudness{LUFS: math.Round(100*10*math.Log10(e)) / 100, Seconds: min(old.Seconds+new.Seconds, 1e6)}92}9394// The meter: BS.1770's K-weighting at 48 kHz, two biquads per channel.95var kWeight = [2]biquad{96 {b0: 1.53512485958697, b1: -2.69169618940638, b2: 1.19839281085285, a1: -1.69065929318241, a2: 0.73248077421585},97 {b0: 1.0, b1: -2.0, b2: 1.0, a1: -1.99004745483398, a2: 0.99007225036621},98}99100type biquad struct{ b0, b1, b2, a1, a2 float64 }101102const (103 subBlock = sampleRate / 10 // 100 ms: a quarter block, the gating step104 // The gated loudness is kept as a histogram of block loudness, 0.1 dB105 // a bin, from the absolute gate (-70 LUFS) to +5.106 histFloor = -70.0107 histBins = 750108)109110// meter measures the gated integrated loudness of 48 kHz stereo (ITU-R111// BS.1770-4): mean squares of K-weighted sound over 400 ms blocks, every112// 100 ms; blocks under -70 LUFS left out, then those 10 LU under the rest.113type meter struct {114 state [2][2][2]float64 // by channel and stage, the filters' state115 subE [4]float64 // the last four 100 ms energies116 subs int // 100 ms parts measured117 acc float64 // energy of the 100 ms part under way118 n int // its samples so far119 count [histBins]int32 // blocks over the absolute gate, by loudness120 energy [histBins]float64121 blocks int122 total float64123}124125// add meters one stereo sample, reporting whether it completed a 100 ms126// step.127func (m *meter) add(l, r float64) bool {128 for c, x := range [2]float64{l, r} {129 for s := range kWeight {130 q, st := &kWeight[s], &m.state[c][s]131 y := q.b0*x + st[0]132 st[0] = q.b1*x - q.a1*y + st[1]133 st[1] = q.b2*x - q.a2*y134 x = y135 }136 m.acc += x * x137 }138 if m.n++; m.n < subBlock {139 return false140 }141 m.subE[m.subs%4] = m.acc142 m.subs++143 m.acc, m.n = 0, 0144 if m.subs >= 4 {145 z := (m.subE[0] + m.subE[1] + m.subE[2] + m.subE[3]) / (4 * subBlock)146 if l := blockLoudness(z); l >= histFloor {147 b := min(int((l-histFloor)*10), histBins-1)148 m.count[b]++149 m.energy[b] += z150 m.blocks++151 m.total += z152 }153 }154 return true155}156157func blockLoudness(z float64) float64 { return -0.691 + 10*math.Log10(z) }158159// integrated is the gated loudness so far, and how many seconds of sound160// it covers; NaN before any sound.161func (m *meter) integrated() (lufs, seconds float64) {162 if m.blocks == 0 {163 return math.NaN(), 0164 }165 gate := blockLoudness(m.total/float64(m.blocks)) - 10166 from := max(int(math.Ceil((gate-histFloor)*10)), 0)167 var e float64168 var n int32169 for b := from; b < histBins; b++ {170 e += m.energy[b]171 n += m.count[b]172 }173 if n == 0 {174 return math.NaN(), 0175 }176 return blockLoudness(e / float64(n)), float64(n) / 10177}178179// The automatic gain: it holds the start gain until a few seconds of sound180// are measured, then moves toward the gain the measured loudness asks for,181// trusting it more as more is measured, no faster than 3 dB a second.182const (183 settle = 3.0 // seconds measured before the gain moves184 trusted = 12.0 // seconds measured for the measurement to count fully185 trustedIf = 60.0 // likewise when the video's loudness was remembered186 glide = 2.0 // the time constant of the gain's moves, seconds187 maxSlew = 0.3 // dB per 100 ms step: 3 dB a second at most188 // release is how fast the peak guard lets go, per sample: 50 ms.189 release = 1.0 / (0.05 * sampleRate)190)191192// leveler levels one airing's sound as it streams.193type leveler struct {194 lv *itemLevel195 m meter196 start float64 // dB197 recall bool // start came from a measurement198 gain float64 // dB, where the gain is heading this step199 cur float64 // linear gain on the next sample200 step float64 // its change per sample this step201 guard float64 // the peak guard's gain, linear, at most 1202 samples int203}204205func newLeveler(lv *itemLevel) *leveler {206 l := &leveler{lv: lv, start: lv.start(), guard: 1}207 if lv.Memory != nil {208 _, l.recall = lv.Memory.recall(lv.Key)209 }210 l.gain = l.start211 l.cur = math.Pow(10, l.gain/20)212 return l213}214215// process levels raw 16-bit stereo samples in place.216func (l *leveler) process(b []byte) {217 for i := 0; i+4 <= len(b); i += 4 {218 left := float64(int16(uint16(b[i])|uint16(b[i+1])<<8)) / 32768219 right := float64(int16(uint16(b[i+2])|uint16(b[i+3])<<8)) / 32768220 if l.m.add(left, right) {221 l.adjust()222 }223 g := l.cur * l.guard224 peak := max(math.Abs(left), math.Abs(right)) * g225 if peak > peakCeiling {226 l.guard = peakCeiling / (max(math.Abs(left), math.Abs(right)) * l.cur)227 g = l.cur * l.guard228 } else if l.guard < 1 {229 l.guard += (1 - l.guard) * release230 }231 putSample(b[i:], left*g)232 putSample(b[i+2:], right*g)233 l.cur += l.step234 l.samples++235 }236}237238func putSample(b []byte, x float64) {239 v := int32(math.Round(x * 32768))240 v = min(max(v, -32768), 32767)241 b[0], b[1] = byte(v), byte(v>>8)242}243244// adjust sets the gain's course for the next 100 ms.245func (l *leveler) adjust() {246 want := l.start247 lufs, secs := l.m.integrated()248 if secs >= settle && !math.IsNaN(lufs) {249 full := trusted250 if l.recall {251 full = trustedIf252 }253 trust := min((secs-settle)/(full-settle), 1)254 want = l.start + (gainFor(lufs, l.lv.Target)-l.start)*trust255 }256 move := (want - l.gain) * 0.1 / glide257 l.gain += min(max(move, -maxSlew), maxSlew)258 next := math.Pow(10, l.gain/20)259 l.step = (next - l.cur) / subBlock260}261262// finish remembers what the airing measured.263func (l *leveler) finish() {264 if l.lv.Memory == nil {265 return266 }267 lufs, secs := l.m.integrated()268 if secs < settle || math.IsNaN(lufs) {269 return270 }271 old, known := l.lv.Memory.recall(l.lv.Key)272 l.lv.Memory.remember(l.lv.Key, remembered(old, loudness{LUFS: math.Round(lufs*100) / 100, Seconds: secs}, known))273}274275// loudnessFile remembers loudness in a hidden file in a channel's folder,276// by key.277type loudnessFile struct {278 mu sync.Mutex279 path string280 loaded bool281 m map[string]loudness282 keep map[string]bool // the keys in use; others are dropped on saving283}284285// loudnessFileName names the file in a channel's folder.286const loudnessFileName = ".loudness.json"287288func (c *loudnessFile) load() {289 if c.loaded {290 return291 }292 c.loaded, c.m = true, map[string]loudness{}293 if raw, err := os.ReadFile(c.path); err == nil && c.path != "" {294 _ = json.Unmarshal(raw, &c.m)295 }296}297298func (c *loudnessFile) recall(key string) (loudness, bool) {299 c.mu.Lock()300 defer c.mu.Unlock()301 c.load()302 l, ok := c.m[key]303 return l, ok304}305306// count is how many of keys are remembered.307func (c *loudnessFile) count(keys []string) int {308 c.mu.Lock()309 defer c.mu.Unlock()310 c.load()311 n := 0312 for _, k := range keys {313 if _, ok := c.m[k]; ok {314 n++315 }316 }317 return n318}319320// use sets the file and the keys in use.321func (c *loudnessFile) use(path string, keys []string) {322 c.mu.Lock()323 defer c.mu.Unlock()324 if path != c.path {325 c.path, c.loaded = path, false326 }327 c.keep = map[string]bool{}328 for _, k := range keys {329 c.keep[k] = true330 }331}332333// remember records a measurement and saves the file, whole or not at all.334func (c *loudnessFile) remember(key string, l loudness) {335 c.mu.Lock()336 defer c.mu.Unlock()337 c.load()338 c.m[key] = l339 for k := range c.m {340 if c.keep != nil && !c.keep[k] && k != key {341 delete(c.m, k)342 }343 }344 raw, err := json.Marshal(c.m)345 if err != nil || c.path == "" {346 return347 }348 tmp := c.path + ".tmp"349 if os.WriteFile(tmp, raw, 0o644) == nil {350 _ = os.Rename(tmp, c.path)351 }352}353354// memoryLevels remembers loudness while the server runs.355type memoryLevels struct {356 mu sync.Mutex357 m map[string]loudness358}359360func (c *memoryLevels) recall(key string) (loudness, bool) {361 c.mu.Lock()362 defer c.mu.Unlock()363 l, ok := c.m[key]364 return l, ok365}366367func (c *memoryLevels) remember(key string, l loudness) {368 c.mu.Lock()369 defer c.mu.Unlock()370 if c.m == nil {371 c.m = map[string]loudness{}372 }373 c.m[key] = l374}