From b24a3ff2eb9e4b7f865b9cb354ba01ebd23db672 Mon Sep 17 00:00:00 2001 From: Till Toenshoff Date: Fri, 14 Aug 2026 15:23:16 +0200 Subject: [PATCH] Fix framework import headers --- CMakeLists.txt | 21 ++- scripts/avfdebug.sh | 88 ++++++++++ scripts/snapshot_baseline.py | 234 +++++++++++++++++++++++++++ scripts/stage_framework_header.cmake | 18 +++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 scripts/avfdebug.sh create mode 100644 scripts/snapshot_baseline.py create mode 100644 scripts/stage_framework_header.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 3187e53..70267b4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -356,7 +356,26 @@ if(APPLE AND ENABLE_MACOS_FRAMEWORK) set(FRAMEWORK_VER_DIR ${FRAMEWORK_DIR}/Versions/A) set(FRAMEWORK_INFO_PLIST ${CMAKE_CURRENT_BINARY_DIR}/${FRAMEWORK_NAME}-Info.plist) # Ship all public headers (developers can opt into lower-level APIs). - file(GLOB FRAMEWORK_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp) + # Keep source headers portable, but stage framework-qualified sibling + # includes as required for public Apple framework headers. + file(GLOB FRAMEWORK_SOURCE_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp) + set(FRAMEWORK_STAGED_HEADER_DIR ${CMAKE_CURRENT_BINARY_DIR}/framework_headers) + set(FRAMEWORK_HEADERS) + foreach(SOURCE_HEADER IN LISTS FRAMEWORK_SOURCE_HEADERS) + get_filename_component(HEADER_NAME ${SOURCE_HEADER} NAME) + set(STAGED_HEADER ${FRAMEWORK_STAGED_HEADER_DIR}/${HEADER_NAME}) + add_custom_command( + OUTPUT ${STAGED_HEADER} + COMMAND ${CMAKE_COMMAND} + -DINPUT_HEADER=${SOURCE_HEADER} + -DOUTPUT_HEADER=${STAGED_HEADER} + -DFRAMEWORK_NAME=${FRAMEWORK_NAME} + -P ${CMAKE_CURRENT_SOURCE_DIR}/scripts/stage_framework_header.cmake + DEPENDS ${SOURCE_HEADER} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/stage_framework_header.cmake + VERBATIM + ) + list(APPEND FRAMEWORK_HEADERS ${STAGED_HEADER}) + endforeach() # Generate the plist at configure time. A multiline echo inside the custom target produces # invalid Makefiles because the generated recipe contains unindented lines. diff --git a/scripts/avfdebug.sh b/scripts/avfdebug.sh new file mode 100644 index 0000000..0be3ef9 --- /dev/null +++ b/scripts/avfdebug.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Usage: AVF_INPUT=path/to/input.m4a + +set -euo pipefail + +FILE_IN="${AVF_INPUT:-}" +TMPDIR_SAFE="${TMPDIR:-/tmp}" +export SWIFT_MODULE_CACHE_PATH="${SWIFT_MODULE_CACHE_PATH:-$TMPDIR_SAFE/swift_module_cache}" +export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-$TMPDIR_SAFE/clang_module_cache}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$TMPDIR_SAFE}" +export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES +mkdir -p "$SWIFT_MODULE_CACHE_PATH" "$CLANG_MODULE_CACHE_PATH" + +if [ -z "$FILE_IN" ] ]; then + echo "AVF_INPUT env var must be set" >&2 + exit 1 +fi +if [ ! -f "$FILE_IN" ]; then + echo "input not found: $FILE_IN" >&2; exit 1 +fi + + +SWIFT_SRC="$(mktemp "$TMPDIR_SAFE/avf_urltext.XXXXXX.swift")" +cat >"$SWIFT_SRC" <<'SWIFT' +import AVFoundation +import Foundation + +let path = ProcessInfo.processInfo.environment["AVF_FILE"] ?? "" +if path.isEmpty { + fputs("AVF_FILE not set\n", stderr) + exit(1) +} +let url = URL(fileURLWithPath: path) +let asset = AVURLAsset(url: url) + +print("AVF dump for \(path)") +for t in asset.tracks { + print("track id=\(t.trackID) media=\(t.mediaType.rawValue) language=\(t.languageCode ?? "nil")") +} + +let groups = asset.chapterMetadataGroups(bestMatchingPreferredLanguages: Locale.preferredLanguages) +print("chapters=\(groups.count)") +for (idx, group) in groups.enumerated() { + let tr = group.timeRange + let start = CMTimeGetSeconds(tr.start) + let dur = CMTimeGetSeconds(tr.duration) + print(String(format: "[%02d] start=%.3f dur=%.3f", idx, start, dur)) + for item in group.items { + let key = item.commonKey?.rawValue ?? (item.key?.description ?? "(no key)") + let ident = item.identifier?.rawValue ?? "(no ident)" + var val: String = "(non-string)" + if let s = item.stringValue { + val = s + } else if let d = item.dataValue { + val = "data len=\(d.count)" + } + let extra = item.extraAttributes ?? [:] + let locale = item.locale?.identifier ?? "nil" + print(" item key=\(key) ident=\(ident) locale=\(locale) value=\(val) extra=\(extra)") + } +} + +// Dump raw text samples for every text track so the shell harness can grep payloads. +let textTracks = asset.tracks(withMediaType: .text) +for t in textTracks { + guard let reader = try? AVAssetReader(asset: asset) else { continue } + let output = AVAssetReaderTrackOutput(track: t, outputSettings: nil) + reader.add(output) + if !reader.startReading() { continue } + while let sb = output.copyNextSampleBuffer() { + let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb)) + let dur = CMTimeGetSeconds(CMSampleBufferGetDuration(sb)) + if let bb = CMSampleBufferGetDataBuffer(sb) { + let len = CMBlockBufferGetDataLength(bb) + var data = Data(count: len) + data.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in + _ = CMBlockBufferCopyDataBytes(bb, atOffset: 0, dataLength: len, destination: ptr.baseAddress!) + } + let text = String(data: data, encoding: .utf8) ?? "" + print(String(format: "sample track=%d pts=%.3f dur=%.3f payload=%@", t.trackID, pts, dur, text)) + } + } +} +SWIFT + +trap 'rm -f "$SWIFT_SRC"' EXIT +LOG_OUT=$(AVF_FILE="$FILE_IN" swift "$SWIFT_SRC") +echo "$LOG_OUT" diff --git a/scripts/snapshot_baseline.py b/scripts/snapshot_baseline.py new file mode 100644 index 0000000..8a598c3 --- /dev/null +++ b/scripts/snapshot_baseline.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +import argparse +import os +from dataclasses import dataclass +from datetime import datetime +from types import SimpleNamespace +from typing import List, Optional + +import eval_refine as evalr +import refine_experiments as rexp + + +@dataclass +class SetSpec: + input_path: str + golden_path: str + + +def load_sets(path: str) -> List[SetSpec]: + sets: List[SetSpec] = [] + input_path: Optional[str] = None + golden_path: Optional[str] = None + base_dir = os.path.dirname(path) + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + if line.startswith("Input:"): + input_path = os.path.join(base_dir, line.split("Input:", 1)[1].strip()) + elif line.startswith("Golden:"): + golden = line.split("Golden:", 1)[1].strip() + if golden == "NOT_AVAILABLE_SO_FAR": + golden_path = None + else: + golden_path = os.path.join(base_dir, golden) + elif line.startswith("===="): + if input_path and golden_path: + sets.append(SetSpec(input_path=input_path, golden_path=golden_path)) + input_path = None + golden_path = None + if input_path and golden_path: + sets.append(SetSpec(input_path=input_path, golden_path=golden_path)) + return sets + + +def build_args() -> SimpleNamespace: + # Baseline defaults aligned with refine_experiments.py. + return SimpleNamespace( + mode="interval", + sample_rate=44100.0, + min_support=2, + min_score=0.0, + min_seconds=180.0, + max_seconds=600.0, + merge_window_seconds=60.0, + short_seconds=90.0, + single_short_min_score=3.2, + changeover_mode="wis", + cluster_gap_seconds=240.0, + sparse_min_span_seconds=0.0, + sparse_max_gap_seconds=0.0, + bin_seconds=30.0, + hmm_max_states=60, + hmm_stay_prob=0.95, + hysteresis_switch_ratio=0.55, + hysteresis_hold_bins=2, + interval_min_seconds=120.0, + repeat_penalty=0.0, + wis_support_scale=6.0, + wis_support_floor=0.3, + wis_duration_short=45.0, + wis_duration_mid=90.0, + wis_duration_short_factor=0.25, + wis_duration_mid_factor=0.5, + wis_gap_scale=0.0, + wis_gap_penalty_max=0.0, + wis_span_scale=180.0, + wis_span_floor=0.5, + wis_neighbor_support=5.0, + wis_neighbor_score=10.0, + wis_neighbor_short_seconds=90.0, + wis_neighbor_short_factor=0.5, + blend_noise_hard=False, + density_scale=0.0, + density_floor=1.0, + wis_streak_floor=0.6, + wis_gap_cv_floor=0.6, + ) + + +def dedupe_tracks(tracks: List[rexp.Track]) -> List[rexp.Track]: + def track_key(track: rexp.Track) -> Optional[str]: + norm_title = rexp.normalize(track.title) + if not norm_title: + return None + return f"{rexp.normalize(track.artist)}|{norm_title}" + + deduped: List[rexp.Track] = [] + best_by_key = {} + for t in tracks: + key = track_key(t) + if not key: + deduped.append(t) + continue + current = best_by_key.get(key) + if not current: + best_by_key[key] = t + continue + if (t.score, t.support, -t.frame) > (current.score, current.support, -current.frame): + best_by_key[key] = t + if best_by_key: + deduped.extend(best_by_key.values()) + tracks = sorted(deduped, key=lambda t: t.frame) + return tracks + + +def to_eval_tracks(tracks: List[rexp.Track]) -> List[evalr.Track]: + seen = set() + unique: List[evalr.Track] = [] + for t in tracks: + key = (evalr.normalize(t.artist), evalr.normalize(t.title)) + if key in seen: + continue + seen.add(key) + unique.append(evalr.Track(artist=t.artist, title=t.title)) + return unique + + +def evaluate(refined: List[rexp.Track], golden: List[evalr.Track], inputs: List[evalr.Track]): + refined_tracks = to_eval_tracks(refined) + matched = [] + for r in refined_tracks: + g = evalr.best_match(r, golden, relaxed=False) + if g: + matched.append((r, g)) + + matched_golden = {g for _, g in matched} + precision = len(matched) / len(refined_tracks) if refined_tracks else 0.0 + recall = len(matched) / len(golden) if golden else 0.0 + + recoverable = 0 + recoverable_missed = 0 + near_recoverable = 0 + for g in golden: + has_strict = False + has_near = False + for it in inputs: + if evalr.match_track(it, g, relaxed=False): + has_strict = True + break + if evalr.near_match(g, it): + has_near = True + if has_strict: + recoverable += 1 + if g not in matched_golden: + recoverable_missed += 1 + elif has_near: + near_recoverable += 1 + + recoverable_missed_rate = (recoverable_missed / recoverable) if recoverable else 0.0 + return { + "refined": len(refined_tracks), + "golden": len(golden), + "matched": len(matched), + "precision": precision, + "recall": recall, + "recoverable": recoverable, + "recoverable_missed": recoverable_missed, + "recoverable_missed_rate": recoverable_missed_rate, + "near_recoverable": near_recoverable, + } + + +def run_set(set_spec: SetSpec, args: SimpleNamespace) -> List[rexp.Track]: + rows = rexp.parse_inputs(set_spec.input_path) + tracks = rexp.build_clusters( + rows, + int(args.cluster_gap_seconds * args.sample_rate), + args.min_support, + int(args.sparse_min_span_seconds * args.sample_rate), + int(args.sparse_max_gap_seconds * args.sample_rate), + ) + tracks = rexp.pipeline(tracks, args.sample_rate, args) + tracks = rexp.weighted_interval_schedule(tracks, args.sample_rate, args.interval_min_seconds, args.repeat_penalty) + return dedupe_tracks(tracks) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Snapshot baseline refinement metrics.") + parser.add_argument("--sets", default="Training/training_sets.txt") + parser.add_argument("--out", default="Training/metrics_baseline.md") + args = parser.parse_args() + + sets = load_sets(args.sets) + if not sets: + print("No training sets found.") + return 1 + + baseline_args = build_args() + rows = [] + for s in sets: + golden = evalr.parse_golden(s.golden_path) + inputs = evalr.parse_inputs(s.input_path) + refined = run_set(s, baseline_args) + metrics = evaluate(refined, golden, inputs) + rows.append((s, metrics)) + + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(args.out, "w", encoding="utf-8") as f: + f.write("# Refinement Baseline Metrics\n\n") + f.write(f"Snapshot: {ts}\n\n") + f.write("Defaults:\n") + f.write("- mode: interval, changeover: wis\n") + f.write("- min_support: 2, merge_window: 60s, short_seconds: 90s, interval_min: 120s\n") + f.write("- WIS: support_scale=6.0 support_floor=0.3 span_scale=180 span_floor=0.5\n") + f.write("- WIS duration: short=45 mid=90 short_factor=0.25 mid_factor=0.5\n") + f.write("- WIS gap: scale=0.0 penalty_max=0.0\n\n") + f.write("| Set | Refined | Golden | Matched | Precision | Recall | Recoverable | Recoverable Missed | Recoverable Missed Rate | Near Recoverable |\n") + f.write("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n") + for s, m in rows: + name = os.path.basename(s.golden_path) + f.write( + f"| {name} | {m['refined']} | {m['golden']} | {m['matched']} | " + f"{m['precision']:.3f} | {m['recall']:.3f} | {m['recoverable']} | " + f"{m['recoverable_missed']} | {m['recoverable_missed_rate']:.3f} | " + f"{m['near_recoverable']} |\n" + ) + print(f"Wrote {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/stage_framework_header.cmake b/scripts/stage_framework_header.cmake new file mode 100644 index 0000000..2996474 --- /dev/null +++ b/scripts/stage_framework_header.cmake @@ -0,0 +1,18 @@ +if(NOT DEFINED INPUT_HEADER OR NOT DEFINED OUTPUT_HEADER OR NOT DEFINED FRAMEWORK_NAME) + message(FATAL_ERROR "INPUT_HEADER, OUTPUT_HEADER, and FRAMEWORK_NAME are required") +endif() + +file(READ "${INPUT_HEADER}" header_contents) + +# Source-tree headers use sibling quoted includes so standalone CMake builds +# remain portable. Public headers inside an Apple framework must address those +# same siblings through the framework namespace. +string(REGEX REPLACE + "#include[ \t]+\"([A-Za-z0-9_./-]+\\.hpp)\"" + "#include <${FRAMEWORK_NAME}/\\1>" + staged_header_contents + "${header_contents}") + +get_filename_component(output_directory "${OUTPUT_HEADER}" DIRECTORY) +file(MAKE_DIRECTORY "${output_directory}") +file(WRITE "${OUTPUT_HEADER}" "${staged_header_contents}")