Skip to content

Commit 031e9af

Browse files
author
Michal Warda
committed
Add reconstruction similarity scoring
1 parent 7537ec7 commit 031e9af

12 files changed

Lines changed: 1106 additions & 34 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ curl -f http://localhost:8000/v1/separate \
8484
-o voice-and-residual.zip
8585
```
8686

87+
The durable pipeline also sums the stored stereo-mapped stems after separation,
88+
reassembles the chunk joins into one joined stereo WAV per original record, and
89+
records a 0–100 sample-aligned reconstruction similarity score plus correlation,
90+
level, error, SNR, and per-channel metrics. These artifacts, chunk diagnostics,
91+
and the dataset-wide source-score distribution are available in the **Split
92+
data** dashboard; the direct model ZIP API remains unchanged.
93+
8794
Generation, prompt, batching, and TF32 policy are configured with the
8895
`SAM_AUDIO_*` environment variables demonstrated in
8996
[`deploy/start-sam-audio-api.sh`](deploy/start-sam-audio-api.sh). A supplied

docs/pipeline/ARCHITECTURE.md

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,25 +46,38 @@ rule are persisted under `adaptive_routing`.
4646
loudness transfer is broadband so it cannot re-EQ the stem. Both are smoothed
4747
with a bidirectional EMA (`alpha=0.03`) before equal-power panning. An SFX-only
4848
pass-through bypasses mapping and keeps the original stereo PCM exactly.
49-
8. Raw and stereo-mapped WAVs, the 120-point pan/loudness trajectory, inference
50-
metadata, timings, and review assertions are stored in S3 and indexed in
51-
DynamoDB. A stereo mapping failure never discards or delays the raw result.
52-
9. Description and transcription tasks share one Audio Flamingo queue so that a
49+
8. After the output sound gate, the stored stereo-mapped stems are summed into
50+
one joined WAV per chunk. Once every chunk is terminal, those chunk joins are
51+
placed back on their source timeline (overlaps are averaged) to produce one
52+
`source.joined.stereo.wav` for the original record. The stored source PCM is
53+
compared sample-for-sample with the normalized stereo original. A 0–100
54+
symmetric waveform-agreement score penalizes phase, timing, polarity, and
55+
level error; correlation, left/right scores, level delta, normalized RMSE,
56+
SNR, coverage, and limiter gain remain alongside it for diagnosis. Chunk
57+
scores remain available for locating a problem. An identity SFX pass-through
58+
reconstructs exactly at 100.
59+
9. Raw and stereo-mapped WAVs, the joined reconstruction, similarity metrics,
60+
the 120-point pan/loudness trajectory, inference metadata, timings, and
61+
review assertions are stored in S3 and indexed in DynamoDB. A stereo mapping
62+
failure never discards or delays the raw result.
63+
10. Description and transcription tasks share one Audio Flamingo queue so that a
5364
single loaded model processes one item at a time. It creates one source-scene
5465
analysis per audible source, one description per music stem, and one
5566
speaker-labelled transcript per voice stem. Tasks exist in DynamoDB before
5667
SQS delivery and are reconciled after worker/message loss.
57-
10. A browser dashboard shows job/queue progress and exact remaining review
68+
11. A browser dashboard shows job/queue progress and exact remaining review
5869
counts. Its reviewer mode pulls the next `uncertain` or `failure` assertion,
5970
starts audio automatically, displays the prompt/assertion, and accepts
6071
one-key decisions.
61-
11. The dataset explorer reports source count, duration, input/chunk/stem storage,
72+
12. The dataset explorer reports source count, duration, input/chunk/stem storage,
6273
processing counts, stem mix, and review backlog. Each source displays the
6374
original recording first, then only the music/voice/SFX stems that passed the
6475
output sound gate, grouped by chunk, followed by its processing route and
65-
model annotations. Raw mono playback is the default; a `Stereo mapped`
66-
toggle swaps only the stem players and leaves the original recording
67-
untouched. Each stem also shows its smoothed left/right trajectory.
76+
model annotations. Stereo-mapped playback is the default; a `Raw` toggle
77+
swaps only the stem players and leaves the original recording untouched.
78+
Each stem also shows its smoothed left/right trajectory. The explorer plays
79+
the full joined result beside its original, retains each chunk join and its
80+
diagnostics, and graphs the dataset-wide source-score distribution.
6881

6982
## AWS resources
7083

@@ -103,14 +116,18 @@ jobs/{job_id}/sources/{source_id}/{filename}
103116
jobs/{job_id}/chunks/{source_id}/{chunk_index}.wav
104117
jobs/{job_id}/stems/{source_id}/{chunk_index}/{music|voice|sfx}.wav
105118
jobs/{job_id}/stems/{source_id}/{chunk_index}/{music|voice|sfx}.stereo.wav
119+
jobs/{job_id}/reconstructions/{source_id}/{chunk_index}.joined.stereo.wav
120+
jobs/{job_id}/reconstructions/{source_id}/source.joined.stereo.wav
106121
jobs/{job_id}/metadata/{source_id}/{chunk_index}.json
107122
jobs/{job_id}/metadata/{source_id}/{chunk_index}.stereo.json
108123
```
109124

110125
Source and stem records include SHA-256 hashes. A stem record includes the
111126
source chunk hash, prompt, model/settings fingerprint, automated status,
112127
reviewer status, raw and stereo S3 keys/hashes/sizes, stereo mapping summary,
113-
timings, and raw Judge/CLAP evidence. The raw `s3_key` is never replaced by its
128+
chunk reconstruction key/hash/size and waveform similarity metrics, timings,
129+
and raw Judge/CLAP evidence. The source record stores the corresponding full
130+
record reconstruction and metrics. The raw `s3_key` is never replaced by its
114131
mapped companion.
115132

116133
## Review contract

docs/pipeline/OPERATIONS.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,39 @@ ssh ubuntu@ec2-44-211-31-38.compute-1.amazonaws.com \
9797
Use `--max-chunks N` for a canary. Mapping is CPU-only and does not require a
9898
SAM or Audio Flamingo restart.
9999

100+
## Joined reconstruction and similarity
101+
102+
After stereo mapping and the output sound gate, the pipeline sums only the
103+
stored stereo variants into `{chunk}.joined.stereo.wav`. After all chunks for a
104+
record are terminal, it places those joins back on the source timeline and
105+
overlap-averages them into `source.joined.stereo.wav`. The primary dataset score
106+
compares that stored source PCM directly with the normalized stereo original,
107+
without gain fitting or time alignment:
108+
109+
`100 × max(0, 2 × dot(original, joined) / (energy(original) + energy(joined)))`
110+
111+
This makes 100 an exact reconstruction; missing-output silence against an
112+
audible original, opposite polarity, or another non-positive match scores 0.
113+
The source record also retains waveform correlation,
114+
left/right scores, level delta, normalized RMSE, SNR, coverage, and final limiter
115+
gain. Chunk records retain the same diagnostics plus the included stem types so
116+
a low source score can be localized. In **Split data**, the dataset histogram
117+
shows the source-score distribution, the source detail plays Original beside
118+
Joined, and each chunk exposes its normalized original and diagnostic join.
119+
120+
Backfill completed jobs from existing stereo companions without rerunning SAM or
121+
Audio Flamingo:
122+
123+
```bash
124+
ssh ubuntu@ec2-44-211-31-38.compute-1.amazonaws.com \
125+
"sudo bash -c 'set -a; source /etc/sam-audio-pipeline.env; set +a; \
126+
cd /home/ubuntu/sam-audio-deploy/pipeline; \
127+
.venv/bin/sam-pipeline-reconstruction-backfill --job-id JOB_ID'"
128+
```
129+
130+
The command is idempotent; use `--force` to regenerate an existing joined
131+
artifact or `--max-chunks N` for a canary.
132+
100133
## AudioSet validation batches
101134

102135
Acquire a reproducible random sample from the official AudioSet segment CSVs.

docs/pipeline/PROGRESS.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ was an example batch size, not a workflow limit.
3232
original-plus-stems source explorer.
3333
- [x] Add frequency-aware stereo/loudness reconstruction while preserving every
3434
raw mono stem, plus a dashboard playback toggle and smoothed trajectory plots.
35+
- [x] Join stored stereo stems, persist phase/level-sensitive waveform fidelity
36+
metrics, and graph the dataset-wide reconstruction score distribution.
3537
- [x] Require stereo input, gate SAM targets with source-scene presence, support
3638
single-stage inference, and use identity pass-through for pure SFX sources.
3739
- [x] Add persistent datasets, successive upload jobs, and reconciliation of
@@ -93,6 +95,13 @@ was an example batch size, not a workflow limit.
9395
source was repaired from its stored scene evidence and now has one SFX-only
9496
stem; raw, mapped, and original normalized PCM are sample-identical. The
9597
dataset now indexes 282 stems (93 music, 90 voice, 99 SFX).
98+
- Joined-stereo reconstruction is backfilled for all 100 stereo sources in seed
99+
`20260713` and all 98 stereo sources in seed `20260714`. Their mean waveform
100+
similarity is 82.79% (7.11–100) and 88.02% (24.92–100), respectively. The
101+
repaired writing/SFX-only record scores exactly 100 with correlation 1.0 and
102+
0 dB level error. Four additional reconstructable legacy sources (five
103+
chunks) were also migrated, for 202 full-source joins from 203 chunk joins;
104+
mono and sound-gated sources correctly have no joined artifact.
96105
- Live mono-filter job `5ec26b1bb28b420a9245e21d475d26be` completed with
97106
`non_stereo_input`, one input channel, zero chunks, zero stems, and zero model
98107
tasks. A live `targets=voice` API canary produced only voice and residual
@@ -106,7 +115,7 @@ was an example batch size, not a workflow limit.
106115
- Recovery job `1d07979ee96a45a98cf220b102fd7006` was uploaded without the
107116
completion callback. Reconciliation found the S3 object, enqueued ingestion,
108117
and completed it as sound-gated. All three queues were empty afterward.
109-
- Local and remote validation: 36 pipeline tests pass; the existing 12 SAM
118+
- Local and remote validation: 39 pipeline tests pass; the existing 12 SAM
110119
batching tests also pass.
111120
- Docker build execution remains unverified because neither the local Mac nor
112121
the current H100 host has a Docker daemon. Both Dockerfiles remain model-free.

pipeline/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ sam-pipeline-worker = "sam_audio_pipeline.worker:main"
1919
sam-pipeline-audioset = "sam_audio_pipeline.audioset:main"
2020
sam-pipeline-submit = "sam_audio_pipeline.submit:main"
2121
sam-pipeline-stereo-backfill = "sam_audio_pipeline.stereo:main"
22+
sam-pipeline-reconstruction-backfill = "sam_audio_pipeline.reconstruction:main"
2223
sam-audio-flamingo-api = "sam_audio_pipeline.flamingo_api:main"
2324

2425
[dependency-groups]

pipeline/src/sam_audio_pipeline/api.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import mimetypes
66
import re
7+
import statistics
78
import uuid
89
from collections import Counter, defaultdict
910
from contextlib import asynccontextmanager
@@ -65,6 +66,33 @@ def _review_counts(aws: PipelineAWS) -> dict[str, int]:
6566
}
6667

6768

69+
def _percentile(values: list[float], quantile: float) -> float | None:
70+
if not values:
71+
return None
72+
ordered = sorted(values)
73+
position = (len(ordered) - 1) * quantile
74+
lower = int(position)
75+
upper = min(lower + 1, len(ordered) - 1)
76+
weight = position - lower
77+
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
78+
79+
80+
def _similarity_summary(scores: list[float]) -> dict[str, Any]:
81+
return {
82+
"count": len(scores),
83+
"mean": round(statistics.fmean(scores), 4) if scores else None,
84+
"median": round(statistics.median(scores), 4) if scores else None,
85+
"minimum": round(min(scores), 4) if scores else None,
86+
"maximum": round(max(scores), 4) if scores else None,
87+
"p10": round(value, 4)
88+
if (value := _percentile(scores, 0.1)) is not None
89+
else None,
90+
"p90": round(value, 4)
91+
if (value := _percentile(scores, 0.9)) is not None
92+
else None,
93+
}
94+
95+
6896
def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
6997
dataset = aws.get(f"DATASET#{dataset_id}", "META")
7098
if not dataset:
@@ -121,6 +149,21 @@ def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
121149
),
122150
None,
123151
)
152+
chunk_reconstruction_scores = [
153+
float(score)
154+
for chunk in source_chunks
155+
if (
156+
score := chunk.get("reconstruction", {})
157+
.get("metrics", {})
158+
.get("similarity_score")
159+
)
160+
is not None
161+
]
162+
source_similarity = (
163+
source.get("reconstruction", {})
164+
.get("metrics", {})
165+
.get("similarity_score")
166+
)
124167
all_sources.append(
125168
{
126169
**source,
@@ -146,6 +189,22 @@ def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
146189
)
147190
),
148191
"selected_order": route,
192+
"reconstruction_count": (
193+
1 if source_similarity is not None else 0
194+
),
195+
"reconstructed_chunk_count": len(
196+
chunk_reconstruction_scores
197+
),
198+
"similarity_score": (
199+
round(float(source_similarity), 4)
200+
if source_similarity is not None
201+
else None
202+
),
203+
"similarity_minimum": (
204+
round(min(chunk_reconstruction_scores), 4)
205+
if chunk_reconstruction_scores
206+
else None
207+
),
149208
}
150209
)
151210
job_summaries.append(
@@ -165,6 +224,41 @@ def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
165224
chunk_bytes = sum(int(chunk.get("bytes") or 0) for chunk in all_chunks)
166225
stem_bytes = sum(int(stem.get("bytes") or 0) for stem in all_stems)
167226
stereo_bytes = sum(int(stem.get("stereo_bytes") or 0) for stem in all_stems)
227+
chunk_reconstruction_bytes = sum(
228+
int(chunk.get("reconstruction", {}).get("bytes") or 0)
229+
for chunk in all_chunks
230+
)
231+
source_reconstruction_bytes = sum(
232+
int(source.get("reconstruction", {}).get("bytes") or 0)
233+
for source in all_sources
234+
)
235+
reconstruction_bytes = (
236+
chunk_reconstruction_bytes + source_reconstruction_bytes
237+
)
238+
reconstructions = []
239+
for source in all_sources:
240+
reconstruction = source.get("reconstruction", {})
241+
metrics = reconstruction.get("metrics", {})
242+
score = metrics.get("similarity_score")
243+
if score is None:
244+
continue
245+
reconstructions.append(
246+
{
247+
"job_id": source.get("job_id"),
248+
"source_id": source.get("source_id"),
249+
"filename": source.get("filename"),
250+
"chunk_count": source.get("chunk_count"),
251+
"duration_seconds": source.get("duration_seconds"),
252+
"similarity_score": float(score),
253+
"waveform_correlation": metrics.get("waveform_correlation"),
254+
"level_delta_db": metrics.get("level_delta_db"),
255+
"snr_db": metrics.get("snr_db"),
256+
"selected_order": source.get("selected_order"),
257+
}
258+
)
259+
similarity_scores = [
260+
float(item["similarity_score"]) for item in reconstructions
261+
]
168262
review_remaining = sum(
169263
stem.get("effective_status") in {"failure", "uncertain"} for stem in all_stems
170264
)
@@ -184,7 +278,16 @@ def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
184278
"chunk_bytes": chunk_bytes,
185279
"stem_bytes": stem_bytes,
186280
"stereo_bytes": stereo_bytes,
187-
"total_bytes": input_bytes + chunk_bytes + stem_bytes + stereo_bytes,
281+
"reconstruction_bytes": reconstruction_bytes,
282+
"chunk_reconstruction_bytes": chunk_reconstruction_bytes,
283+
"source_reconstruction_bytes": source_reconstruction_bytes,
284+
"total_bytes": (
285+
input_bytes
286+
+ chunk_bytes
287+
+ stem_bytes
288+
+ stereo_bytes
289+
+ reconstruction_bytes
290+
),
188291
"chunks": len(all_chunks),
189292
"audible_chunks": sum(
190293
chunk.get("status") != "skipped" for chunk in all_chunks
@@ -194,6 +297,11 @@ def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
194297
),
195298
"stems": len(all_stems),
196299
"stereo_stems": sum(bool(stem.get("stereo_s3_key")) for stem in all_stems),
300+
"reconstructed_chunks": sum(
301+
bool(chunk.get("reconstruction")) for chunk in all_chunks
302+
),
303+
"reconstructed_sources": len(reconstructions),
304+
"similarity": _similarity_summary(similarity_scores),
197305
"stems_by_type": dict(
198306
Counter(str(stem.get("stem_type")) for stem in all_stems)
199307
),
@@ -216,6 +324,10 @@ def _dataset_snapshot(aws: PipelineAWS, dataset_id: str) -> dict[str, Any]:
216324
key=lambda source: str(source.get("created_at", "")),
217325
reverse=True,
218326
),
327+
"reconstructions": sorted(
328+
reconstructions,
329+
key=lambda item: float(item["similarity_score"]),
330+
),
219331
}
220332

221333

@@ -432,13 +544,19 @@ def get_source(job_id: str, source_id: str, request: Request) -> dict[str, Any]:
432544
if not source:
433545
raise HTTPException(status_code=404, detail="Source not found")
434546
job = next(item for item in items if item["SK"] == "META")
547+
source_reconstruction = source.get("reconstruction", {})
435548
source = {
436549
**source,
437550
"audio_url": (
438551
store.presign_download(source["s3_key"])
439552
if source.get("s3_key") and source.get("status") != "failed"
440553
else None
441554
),
555+
"joined_audio_url": (
556+
store.presign_download(source_reconstruction["s3_key"])
557+
if source_reconstruction.get("s3_key")
558+
else None
559+
),
442560
}
443561
chunks = sorted(
444562
[
@@ -499,6 +617,7 @@ def get_source(job_id: str, source_id: str, request: Request) -> dict[str, Any]:
499617
expanded_chunks = []
500618
for chunk in chunks:
501619
chunk_id = str(chunk["chunk_id"])
620+
reconstruction = chunk.get("reconstruction", {})
502621
expanded_chunks.append(
503622
{
504623
**chunk,
@@ -514,6 +633,11 @@ def get_source(job_id: str, source_id: str, request: Request) -> dict[str, Any]:
514633
),
515634
),
516635
"omitted_stems": sorted(omitted_by_chunk[chunk_id]),
636+
"joined_audio_url": (
637+
store.presign_download(reconstruction["s3_key"])
638+
if reconstruction.get("s3_key")
639+
else None
640+
),
517641
}
518642
)
519643
annotations = [

0 commit comments

Comments
 (0)