| @@ -209,16 +209,22 @@ Generator/Upsampler und Hardwarewriter werden als getrennte Stufen mit kleinem, | |||||
| - Queue-Close erwartet, dass Generator/Writer vor dem Schließen stoppen, sonst droht Panik beim Schreiben. | - Queue-Close erwartet, dass Generator/Writer vor dem Schließen stoppen, sonst droht Panik beim Schreiben. | ||||
| ### WS-01-T2 — Writer-Worker einführen | ### WS-01-T2 — Writer-Worker einführen | ||||
| - **Status:** TODO | |||||
| - **Owner:** offen | |||||
| - **Status:** VERIFIED | |||||
| - **Owner:** Lead Coderaffe | |||||
| - **Code-Orte:** | - **Code-Orte:** | ||||
| - `internal/app/engine.go` | |||||
| - `internal/platform/*` | |||||
| - `internal/app/engine.go` (run loop, `writerLoop`, `cloneFrame`, Stats) | |||||
| - `internal/dsp/*` (FMUpsampler / Resampler copy `GeneratedAt` für Cycle-Metriken) | |||||
| - **Ziel:** | - **Ziel:** | ||||
| Nur noch ein dedizierter Worker besitzt `driver.Write()`. | |||||
| Generator/Upsampler liefern Frames in die FrameQueue, `driver.Write()` läuft nur noch im dedizierten Writer. | |||||
| - **Akzeptanzpunkte:** | - **Akzeptanzpunkte:** | ||||
| - Write-Latenz pro Frame messbar | |||||
| - Timinginteraktionen klar isoliert | |||||
| - `writerLoop()` ist die einzige Stelle mit `driver.Write()` und zieht aus der Queue. | |||||
| - FrameQueue ist ein echter Puffer (Generator klont Frames, Writer poppt) und `EngineStats.Queue` zeigt den Füllstand. | |||||
| - Write- und Cycle-Latenzen plus `LateBuffers` bleiben in `EngineStats` sichtbar (`MaxWriteMs`, `LateBuffers`, `MaxCycleMs`). | |||||
| - **Nachweis:** | |||||
| - `go test ./...` (Engine + Queue + DSP) läuft erfolgreich. | |||||
| - `EngineStats` berichtet weiterhin über Queue-/Writer-Metriken. | |||||
| - **Restrisiken:** | |||||
| - Frame-Klonierung pro Chunk erhöht Heap-Pressure; spätere Workstreams sollten Pooling / Zero-Copy prüfen. | |||||
| ### WS-01-T3 — Supervisor-Schicht einführen | ### WS-01-T3 — Supervisor-Schicht einführen | ||||
| - **Status:** TODO | - **Status:** TODO | ||||
| @@ -356,7 +356,10 @@ func (e *Engine) Stats() EngineStats { | |||||
| } | } | ||||
| func (e *Engine) run(ctx context.Context) { | func (e *Engine) run(ctx context.Context) { | ||||
| e.wg.Add(1) | |||||
| go e.writerLoop(ctx) | |||||
| defer e.wg.Done() | defer e.wg.Done() | ||||
| for { | for { | ||||
| if ctx.Err() != nil { | if ctx.Err() != nil { | ||||
| return | return | ||||
| @@ -373,13 +376,27 @@ func (e *Engine) run(ctx context.Context) { | |||||
| t0 := time.Now() | t0 := time.Now() | ||||
| frame := e.generator.GenerateFrame(e.chunkDuration) | frame := e.generator.GenerateFrame(e.chunkDuration) | ||||
| frame.GeneratedAt = t0 | |||||
| t1 := time.Now() | t1 := time.Now() | ||||
| if e.upsampler != nil { | if e.upsampler != nil { | ||||
| frame = e.upsampler.Process(frame) | frame = e.upsampler.Process(frame) | ||||
| frame.GeneratedAt = t0 | |||||
| } | } | ||||
| t2 := time.Now() | t2 := time.Now() | ||||
| if err := e.frameQueue.Push(ctx, frame); err != nil { | |||||
| genDur := t1.Sub(t0) | |||||
| upDur := t2.Sub(t1) | |||||
| updateMaxDuration(&e.maxGenerateNs, genDur) | |||||
| updateMaxDuration(&e.maxUpsampleNs, upDur) | |||||
| enqueued := cloneFrame(frame) | |||||
| if enqueued == nil { | |||||
| e.lastError.Store("engine: frame clone failed") | |||||
| e.underruns.Add(1) | |||||
| continue | |||||
| } | |||||
| if err := e.frameQueue.Push(ctx, enqueued); err != nil { | |||||
| if ctx.Err() != nil { | if ctx.Err() != nil { | ||||
| return | return | ||||
| } | } | ||||
| @@ -395,8 +412,13 @@ func (e *Engine) run(ctx context.Context) { | |||||
| } | } | ||||
| continue | continue | ||||
| } | } | ||||
| } | |||||
| } | |||||
| popFrame, err := e.frameQueue.Pop(ctx) | |||||
| func (e *Engine) writerLoop(ctx context.Context) { | |||||
| defer e.wg.Done() | |||||
| for { | |||||
| frame, err := e.frameQueue.Pop(ctx) | |||||
| if err != nil { | if err != nil { | ||||
| if ctx.Err() != nil { | if ctx.Err() != nil { | ||||
| return | return | ||||
| @@ -409,25 +431,23 @@ func (e *Engine) run(ctx context.Context) { | |||||
| continue | continue | ||||
| } | } | ||||
| t3 := time.Now() | |||||
| n, err := e.driver.Write(ctx, popFrame) | |||||
| t4 := time.Now() | |||||
| writeStart := time.Now() | |||||
| n, err := e.driver.Write(ctx, frame) | |||||
| writeDur := time.Since(writeStart) | |||||
| genDur := t1.Sub(t0) | |||||
| upDur := t2.Sub(t1) | |||||
| writeDur := t4.Sub(t3) | |||||
| cycleDur := t4.Sub(t0) | |||||
| cycleDur := writeDur | |||||
| if !frame.GeneratedAt.IsZero() { | |||||
| cycleDur = time.Since(frame.GeneratedAt) | |||||
| } | |||||
| updateMaxDuration(&e.maxGenerateNs, genDur) | |||||
| updateMaxDuration(&e.maxUpsampleNs, upDur) | |||||
| updateMaxDuration(&e.maxWriteNs, writeDur) | updateMaxDuration(&e.maxWriteNs, writeDur) | ||||
| updateMaxDuration(&e.maxCycleNs, cycleDur) | updateMaxDuration(&e.maxCycleNs, cycleDur) | ||||
| if cycleDur > e.chunkDuration { | if cycleDur > e.chunkDuration { | ||||
| late := e.lateBuffers.Add(1) | late := e.lateBuffers.Add(1) | ||||
| if late <= 5 || late%20 == 0 { | if late <= 5 || late%20 == 0 { | ||||
| log.Printf("TX LATE: cycle=%s budget=%s gen=%s up=%s write=%s over=%s", | |||||
| cycleDur, e.chunkDuration, genDur, upDur, writeDur, cycleDur-e.chunkDuration) | |||||
| log.Printf("TX LATE: cycle=%s budget=%s write=%s over=%s", | |||||
| cycleDur, e.chunkDuration, writeDur, cycleDur-e.chunkDuration) | |||||
| } | } | ||||
| } | } | ||||
| @@ -444,7 +464,23 @@ func (e *Engine) run(ctx context.Context) { | |||||
| } | } | ||||
| continue | continue | ||||
| } | } | ||||
| e.chunksProduced.Add(1) | e.chunksProduced.Add(1) | ||||
| e.totalSamples.Add(uint64(n)) | e.totalSamples.Add(uint64(n)) | ||||
| } | } | ||||
| } | } | ||||
| func cloneFrame(src *output.CompositeFrame) *output.CompositeFrame { | |||||
| if src == nil { | |||||
| return nil | |||||
| } | |||||
| samples := make([]output.IQSample, len(src.Samples)) | |||||
| copy(samples, src.Samples) | |||||
| return &output.CompositeFrame{ | |||||
| Samples: samples, | |||||
| SampleRateHz: src.SampleRateHz, | |||||
| Timestamp: src.Timestamp, | |||||
| GeneratedAt: src.GeneratedAt, | |||||
| Sequence: src.Sequence, | |||||
| } | |||||
| } | |||||
| @@ -147,7 +147,7 @@ func (u *FMUpsampler) Process(frame *output.CompositeFrame) *output.CompositeFra | |||||
| pos := u.srcPos | pos := u.srcPos | ||||
| n := 0 | n := 0 | ||||
| for pos < float64(srcLen) && n < maxOut { | for pos < float64(srcLen) && n < maxOut { | ||||
| vi := int(pos) // virtual index (integer part) | |||||
| vi := int(pos) // virtual index (integer part) | |||||
| frac := pos - float64(vi) | frac := pos - float64(vi) | ||||
| pA := phaseAt(vi) | pA := phaseAt(vi) | ||||
| @@ -171,6 +171,7 @@ func (u *FMUpsampler) Process(frame *output.CompositeFrame) *output.CompositeFra | |||||
| u.outFrame.SampleRateHz = u.dstRate | u.outFrame.SampleRateHz = u.dstRate | ||||
| u.outFrame.Timestamp = frame.Timestamp | u.outFrame.Timestamp = frame.Timestamp | ||||
| u.outFrame.Sequence = frame.Sequence | u.outFrame.Sequence = frame.Sequence | ||||
| u.outFrame.GeneratedAt = frame.GeneratedAt | |||||
| return &u.outFrame | return &u.outFrame | ||||
| } | } | ||||
| @@ -54,6 +54,7 @@ func ResampleIQ(frame *output.CompositeFrame, targetRateHz float64) *output.Comp | |||||
| Samples: dst, | Samples: dst, | ||||
| SampleRateHz: targetRateHz, | SampleRateHz: targetRateHz, | ||||
| Timestamp: frame.Timestamp, | Timestamp: frame.Timestamp, | ||||
| GeneratedAt: frame.GeneratedAt, | |||||
| Sequence: frame.Sequence, | Sequence: frame.Sequence, | ||||
| } | } | ||||
| } | } | ||||
| @@ -76,6 +76,7 @@ func (u *FMPhaseUpsampler) Process(frame *output.CompositeFrame) *output.Composi | |||||
| Samples: dst, | Samples: dst, | ||||
| SampleRateHz: u.dstRate, | SampleRateHz: u.dstRate, | ||||
| Timestamp: frame.Timestamp, | Timestamp: frame.Timestamp, | ||||
| GeneratedAt: frame.GeneratedAt, | |||||
| Sequence: frame.Sequence, | Sequence: frame.Sequence, | ||||
| } | } | ||||
| } | } | ||||
| @@ -19,6 +19,7 @@ type CompositeFrame struct { | |||||
| Samples []IQSample | Samples []IQSample | ||||
| SampleRateHz float64 | SampleRateHz float64 | ||||
| Timestamp time.Time | Timestamp time.Time | ||||
| GeneratedAt time.Time | |||||
| Sequence uint64 | Sequence uint64 | ||||
| } | } | ||||