From c85fcada7f9f0fedd138e4397476f92943a58628 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:40:56 +0900 Subject: [PATCH 001/160] test(core): require strict playable stem artifact references --- .../tests/playable_stem_artifact_reference.rs | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 apps/desktop/core/tests/playable_stem_artifact_reference.rs diff --git a/apps/desktop/core/tests/playable_stem_artifact_reference.rs b/apps/desktop/core/tests/playable_stem_artifact_reference.rs new file mode 100644 index 000000000..371b3b811 --- /dev/null +++ b/apps/desktop/core/tests/playable_stem_artifact_reference.rs @@ -0,0 +1,204 @@ +//! Contract tests for path-free playable-stem artifact references. + +#[path = "../src/playable_stem_contract.rs"] +mod playable_stem_contract; + +use playable_stem_contract::{ + PlayableStemArtifactSetReference, PlaybackStemKind, PLAYABLE_STEM_ARTIFACT_VERSION, +}; +use std::path::Path; + +const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const CONTENT_HASH: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn valid_reference_json() -> String { + format!( + r#"{{ + "artifactSetId": "{ARTIFACT_SET_ID}", + "formatVersion": 1, + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008, + "appliedGain": 1.0, + "stemArtifacts": [ + {{ + "artifactId": "stem-vocals", + "stemKind": "vocals", + "fileSizeBytes": 172, + "contentHashSha256": "{CONTENT_HASH}", + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }}, + {{ + "artifactId": "stem-bass", + "stemKind": "bass", + "fileSizeBytes": 172, + "contentHashSha256": "{CONTENT_HASH}", + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }}, + {{ + "artifactId": "stem-drums", + "stemKind": "drums", + "fileSizeBytes": 172, + "contentHashSha256": "{CONTENT_HASH}", + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }}, + {{ + "artifactId": "stem-other", + "stemKind": "other", + "fileSizeBytes": 172, + "contentHashSha256": "{CONTENT_HASH}", + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }} + ] + }}"# + ) +} + +#[test] +fn parses_complete_path_free_reference() { + let reference: PlayableStemArtifactSetReference = + serde_json::from_str(&valid_reference_json()).expect("valid reference should parse"); + + assert_eq!(reference.artifact_set_id(), ARTIFACT_SET_ID); + assert_eq!(reference.format_version(), PLAYABLE_STEM_ARTIFACT_VERSION); + assert_eq!(reference.sample_rate(), 8000); + assert_eq!(reference.channel_count(), 1); + assert_eq!(reference.sample_count(), 64); + assert_eq!(reference.duration_seconds(), 0.008); + assert_eq!(reference.applied_gain(), 1.0); + assert_eq!( + reference + .stem_artifacts() + .iter() + .map(|artifact| artifact.stem_kind()) + .collect::>(), + vec![ + PlaybackStemKind::Vocals, + PlaybackStemKind::Bass, + PlaybackStemKind::Drums, + PlaybackStemKind::Other, + ] + ); + assert_eq!( + reference.artifact_relative_path(Path::new("/app/temp"), PlaybackStemKind::Bass), + Path::new("/app/temp") + .join("playable-stems-v1") + .join(ARTIFACT_SET_ID) + .join("bass.wav") + ); +} + +#[test] +fn serialized_reference_never_contains_a_native_path() { + let reference: PlayableStemArtifactSetReference = + serde_json::from_str(&valid_reference_json()).expect("valid reference should parse"); + let serialized = serde_json::to_string(&reference).expect("reference should serialize"); + + assert!(!serialized.to_ascii_lowercase().contains("path")); + assert!(!serialized.contains("/app/temp")); + let reparsed: PlayableStemArtifactSetReference = + serde_json::from_str(&serialized).expect("serialized reference should parse"); + assert_eq!(reparsed, reference); +} + +#[test] +fn rejects_unknown_path_and_storage_fields() { + for extra_field in [ + r#", "nativeFilePath": "/secret/audio.wav""#, + r#", "artifactRoot": "/secret""#, + r#", "sourcePath": "C:\\secret\\audio.wav""#, + ] { + let malformed = valid_reference_json().replacen( + r#""artifactId": "stem-vocals""#, + &format!(r#""artifactId": "stem-vocals"{extra_field}"#), + 1, + ); + assert!(serde_json::from_str::(&malformed).is_err()); + } +} + +#[test] +fn rejects_invalid_set_and_artifact_identifiers() { + for malformed in [ + valid_reference_json().replace(ARTIFACT_SET_ID, "A"), + valid_reference_json().replace(ARTIFACT_SET_ID, "a/../../b"), + valid_reference_json().replacen("stem-vocals", "stem-bass", 1), + valid_reference_json().replacen(CONTENT_HASH, "ABC", 1), + ] { + assert!(serde_json::from_str::(&malformed).is_err()); + } +} + +#[test] +fn rejects_missing_duplicate_reordered_or_unknown_stems() { + let valid_json = valid_reference_json(); + let vocals_start = valid_json.find(r#" {{ + "artifactId": "stem-vocals""#).unwrap(); + let bass_start = valid_json.find(r#" {{ + "artifactId": "stem-bass""#).unwrap(); + let vocals_block = &valid_json[vocals_start..bass_start]; + + let missing = valid_json.replacen(vocals_block, "", 1); + let duplicate = valid_json.replacen(vocals_block, &format!("{vocals_block}{vocals_block}"), 1); + let reordered = valid_json + .replacen(vocals_block, "__VOCALS_BLOCK__", 1) + .replacen( + r#" {{ + "artifactId": "stem-bass""#, + &format!(r#"{vocals_block} {{ + "artifactId": "stem-bass""#), + 1, + ) + .replace("__VOCALS_BLOCK__", ""); + let unknown = valid_json.replacen("\"stemKind\": \"other\"", "\"stemKind\": \"guitar\"", 1); + + for malformed in [missing, duplicate, reordered, unknown] { + assert!(serde_json::from_str::(&malformed).is_err()); + } +} + +#[test] +fn rejects_set_level_media_and_alignment_mismatch() { + for malformed in [ + valid_reference_json().replacen("\"formatVersion\": 1", "\"formatVersion\": 2", 1), + valid_reference_json().replacen("\"sampleRate\": 8000", "\"sampleRate\": 7999", 1), + valid_reference_json().replacen("\"channelCount\": 1", "\"channelCount\": 2", 1), + valid_reference_json().replacen("\"sampleCount\": 64", "\"sampleCount\": 0", 1), + valid_reference_json().replacen("\"durationSeconds\": 0.008", "\"durationSeconds\": 0.009", 1), + valid_reference_json().replacen("\"appliedGain\": 1.0", "\"appliedGain\": 0.0", 1), + ] { + assert!(serde_json::from_str::(&malformed).is_err()); + } +} + +#[test] +fn rejects_per_stem_metadata_mismatch() { + for malformed in [ + valid_reference_json().replacen("\"fileSizeBytes\": 172", "\"fileSizeBytes\": 171", 1), + valid_reference_json().replacen("\"mediaType\": \"audio/wav\"", "\"mediaType\": \"audio/mpeg\"", 1), + valid_reference_json().replacen("\"sampleRate\": 8000", "\"sampleRate\": 16000", 2), + valid_reference_json().replacen("\"sampleCount\": 64", "\"sampleCount\": 63", 2), + valid_reference_json().replacen("\"durationSeconds\": 0.008", "\"durationSeconds\": 0.007", 2), + ] { + assert!(serde_json::from_str::(&malformed).is_err()); + } +} From 99bc70f7bf33e842f2f72eb0e847a0abbee1222a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:42:15 +0900 Subject: [PATCH 002/160] feat(core): validate path-free playable stem references --- .../core/src/playable_stem_contract.rs | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 apps/desktop/core/src/playable_stem_contract.rs diff --git a/apps/desktop/core/src/playable_stem_contract.rs b/apps/desktop/core/src/playable_stem_contract.rs new file mode 100644 index 000000000..d7ef1ca6a --- /dev/null +++ b/apps/desktop/core/src/playable_stem_contract.rs @@ -0,0 +1,368 @@ +//! Path-free contract for locally generated playable stem artifacts. +//! +//! The analysis process may publish aligned WAV files below an app-owned +//! project temporary root, but it cannot grant filesystem authority by returning +//! a path. This module validates the path-free metadata used by the native +//! process to derive and verify the only permitted file locations. + +use serde::{Deserialize, Deserializer, Serialize}; +use std::path::{Path, PathBuf}; + +/// Version of the playable-stem artifact and metadata contract. +pub const PLAYABLE_STEM_ARTIFACT_VERSION: u8 = 1; + +/// Smallest accepted sample rate for generated playback artifacts. +pub const MIN_PLAYBACK_SAMPLE_RATE_HZ: u32 = 8_000; + +/// Largest accepted sample rate for generated playback artifacts. +pub const MAX_PLAYBACK_SAMPLE_RATE_HZ: u32 = 192_000; + +const PCM16_BYTES_PER_SAMPLE: u64 = 2; +const CANONICAL_WAVE_HEADER_BYTES: u64 = 44; +const SHA256_HEX_CHARACTER_COUNT: usize = 64; +const DURATION_RELATIVE_TOLERANCE: f64 = 1e-12; + +/// Canonical source kinds produced by the current BandScope separation model. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlaybackStemKind { + /// Isolated vocal source. + Vocals, + /// Isolated bass source. + Bass, + /// Isolated drum source. + Drums, + /// Remaining instruments not identified as a more specific source. + Other, +} + +impl PlaybackStemKind { + /// Return all supported sources in their canonical wire order. + pub const fn canonical_order() -> [Self; 4] { + [Self::Vocals, Self::Bass, Self::Drums, Self::Other] + } + + /// Return the exact artifact identifier owned by this source kind. + pub const fn artifact_id(self) -> &'static str { + match self { + Self::Vocals => "stem-vocals", + Self::Bass => "stem-bass", + Self::Drums => "stem-drums", + Self::Other => "stem-other", + } + } + + /// Return the fixed WAV filename owned by this source kind. + pub const fn file_name(self) -> &'static str { + match self { + Self::Vocals => "vocals.wav", + Self::Bass => "bass.wav", + Self::Drums => "drums.wav", + Self::Other => "other.wav", + } + } +} + +/// Path-free metadata for one generated source file. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayableStemArtifactReference { + artifact_id: String, + stem_kind: PlaybackStemKind, + file_size_bytes: u64, + content_hash_sha256: String, + media_type: String, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, +} + +impl PlayableStemArtifactReference { + /// Return the canonical artifact identifier. + pub fn artifact_id(&self) -> &str { + &self.artifact_id + } + + /// Return the canonical source kind. + pub const fn stem_kind(&self) -> PlaybackStemKind { + self.stem_kind + } + + /// Return the expected on-disk byte size. + pub const fn file_size_bytes(&self) -> u64 { + self.file_size_bytes + } + + /// Return the lowercase SHA-256 digest of the complete WAV file. + pub fn content_hash_sha256(&self) -> &str { + &self.content_hash_sha256 + } + + /// Return the exact media type for the generated artifact. + pub fn media_type(&self) -> &str { + &self.media_type + } + + /// Return the expected sample rate. + pub const fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Return the expected channel count. + pub const fn channel_count(&self) -> u8 { + self.channel_count + } + + /// Return the expected number of mono PCM samples. + pub const fn sample_count(&self) -> u64 { + self.sample_count + } + + /// Return the expected media duration. + pub const fn duration_seconds(&self) -> f64 { + self.duration_seconds + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawPlayableStemArtifactReference { + artifact_id: String, + stem_kind: PlaybackStemKind, + file_size_bytes: u64, + content_hash_sha256: String, + media_type: String, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, +} + +/// Path-free metadata for one complete and aligned generated source set. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayableStemArtifactSetReference { + artifact_set_id: String, + format_version: u8, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, + applied_gain: f64, + stem_artifacts: Vec, +} + +impl PlayableStemArtifactSetReference { + /// Return the lowercase SHA-256 identity used as the fixed directory name. + pub fn artifact_set_id(&self) -> &str { + &self.artifact_set_id + } + + /// Return the artifact contract version. + pub const fn format_version(&self) -> u8 { + self.format_version + } + + /// Return the common sample rate for every source. + pub const fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Return the common channel count for every source. + pub const fn channel_count(&self) -> u8 { + self.channel_count + } + + /// Return the common sample count for every source. + pub const fn sample_count(&self) -> u64 { + self.sample_count + } + + /// Return the common duration for every source. + pub const fn duration_seconds(&self) -> f64 { + self.duration_seconds + } + + /// Return the set-wide gain applied before PCM16 encoding. + pub const fn applied_gain(&self) -> f64 { + self.applied_gain + } + + /// Return the four canonical artifact references in wire order. + pub fn stem_artifacts(&self) -> &[PlayableStemArtifactReference] { + &self.stem_artifacts + } + + /// Derive the only permitted path for one artifact from a native-owned root. + pub fn artifact_relative_path( + &self, + project_temp_root: &Path, + stem_kind: PlaybackStemKind, + ) -> PathBuf { + project_temp_root + .join("playable-stems-v1") + .join(&self.artifact_set_id) + .join(stem_kind.file_name()) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawPlayableStemArtifactSetReference { + artifact_set_id: String, + format_version: u8, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, + applied_gain: f64, + stem_artifacts: Vec, +} + +impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawPlayableStemArtifactSetReference::deserialize(deserializer)?; + validate_sha256_hex(&raw.artifact_set_id, "artifactSetId") + .map_err(serde::de::Error::custom)?; + if raw.format_version != PLAYABLE_STEM_ARTIFACT_VERSION { + return Err(serde::de::Error::custom( + "unsupported playable stem artifact formatVersion", + )); + } + if !(MIN_PLAYBACK_SAMPLE_RATE_HZ..=MAX_PLAYBACK_SAMPLE_RATE_HZ) + .contains(&raw.sample_rate) + { + return Err(serde::de::Error::custom( + "playable stem sampleRate is outside the supported range", + )); + } + if raw.channel_count != 1 { + return Err(serde::de::Error::custom( + "playable stem channelCount must be one", + )); + } + if raw.sample_count == 0 { + return Err(serde::de::Error::custom( + "playable stem sampleCount must be positive", + )); + } + let expected_duration = raw.sample_count as f64 / raw.sample_rate as f64; + validate_duration(raw.duration_seconds, expected_duration) + .map_err(serde::de::Error::custom)?; + if !raw.applied_gain.is_finite() || raw.applied_gain <= 0.0 || raw.applied_gain > 1.0 { + return Err(serde::de::Error::custom( + "playable stem appliedGain must be finite and within (0, 1]", + )); + } + if raw.stem_artifacts.len() != PlaybackStemKind::canonical_order().len() { + return Err(serde::de::Error::custom( + "playable stem artifact set must contain exactly four sources", + )); + } + + let expected_file_size = raw + .sample_count + .checked_mul(PCM16_BYTES_PER_SAMPLE) + .and_then(|sample_bytes| sample_bytes.checked_add(CANONICAL_WAVE_HEADER_BYTES)) + .ok_or_else(|| serde::de::Error::custom("playable stem file size overflow"))?; + let mut stem_artifacts = Vec::with_capacity(raw.stem_artifacts.len()); + for (raw_artifact, expected_stem_kind) in raw + .stem_artifacts + .into_iter() + .zip(PlaybackStemKind::canonical_order()) + { + validate_artifact( + &raw_artifact, + expected_stem_kind, + raw.sample_rate, + raw.channel_count, + raw.sample_count, + raw.duration_seconds, + expected_file_size, + ) + .map_err(serde::de::Error::custom)?; + stem_artifacts.push(PlayableStemArtifactReference { + artifact_id: raw_artifact.artifact_id, + stem_kind: raw_artifact.stem_kind, + file_size_bytes: raw_artifact.file_size_bytes, + content_hash_sha256: raw_artifact.content_hash_sha256, + media_type: raw_artifact.media_type, + sample_rate: raw_artifact.sample_rate, + channel_count: raw_artifact.channel_count, + sample_count: raw_artifact.sample_count, + duration_seconds: raw_artifact.duration_seconds, + }); + } + + Ok(Self { + artifact_set_id: raw.artifact_set_id, + format_version: raw.format_version, + sample_rate: raw.sample_rate, + channel_count: raw.channel_count, + sample_count: raw.sample_count, + duration_seconds: raw.duration_seconds, + applied_gain: raw.applied_gain, + stem_artifacts, + }) + } +} + +fn validate_artifact( + artifact: &RawPlayableStemArtifactReference, + expected_stem_kind: PlaybackStemKind, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, + expected_file_size: u64, +) -> Result<(), String> { + if artifact.stem_kind != expected_stem_kind { + return Err("playable stems must use canonical source order".to_string()); + } + if artifact.artifact_id != expected_stem_kind.artifact_id() { + return Err("playable stem artifactId does not match stemKind".to_string()); + } + validate_sha256_hex(&artifact.content_hash_sha256, "contentHashSha256")?; + if artifact.media_type != "audio/wav" { + return Err("playable stem mediaType must be audio/wav".to_string()); + } + if artifact.sample_rate != sample_rate + || artifact.channel_count != channel_count + || artifact.sample_count != sample_count + { + return Err("playable stem media metadata is not aligned with its set".to_string()); + } + validate_duration(artifact.duration_seconds, duration_seconds)?; + if artifact.file_size_bytes != expected_file_size { + return Err("playable stem fileSizeBytes does not match canonical PCM16 WAV".to_string()); + } + Ok(()) +} + +fn validate_sha256_hex(value: &str, field_name: &str) -> Result<(), String> { + if value.len() != SHA256_HEX_CHARACTER_COUNT + || !value + .bytes() + .all(|character| character.is_ascii_digit() || (b'a'..=b'f').contains(&character)) + { + return Err(format!( + "playable stem {field_name} must be lowercase SHA-256 hex" + )); + } + Ok(()) +} + +fn validate_duration(actual_duration: f64, expected_duration: f64) -> Result<(), String> { + let tolerance = expected_duration.abs().max(1.0) * DURATION_RELATIVE_TOLERANCE; + if !actual_duration.is_finite() + || actual_duration <= 0.0 + || (actual_duration - expected_duration).abs() > tolerance + { + return Err("playable stem durationSeconds is inconsistent".to_string()); + } + Ok(()) +} From 52f7f3ec91d07f40b1a4ce01d7798b6a2528989b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:44:49 +0900 Subject: [PATCH 003/160] test(core): make playable stem contract fixtures structural --- .../tests/playable_stem_artifact_reference.rs | 333 +++++++++++------- 1 file changed, 215 insertions(+), 118 deletions(-) diff --git a/apps/desktop/core/tests/playable_stem_artifact_reference.rs b/apps/desktop/core/tests/playable_stem_artifact_reference.rs index 371b3b811..27efb3c61 100644 --- a/apps/desktop/core/tests/playable_stem_artifact_reference.rs +++ b/apps/desktop/core/tests/playable_stem_artifact_reference.rs @@ -6,6 +6,7 @@ mod playable_stem_contract; use playable_stem_contract::{ PlayableStemArtifactSetReference, PlaybackStemKind, PLAYABLE_STEM_ARTIFACT_VERSION, }; +use serde_json::{json, Map, Value}; use std::path::Path; const ARTIFACT_SET_ID: &str = @@ -13,70 +14,70 @@ const ARTIFACT_SET_ID: &str = const CONTENT_HASH: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -fn valid_reference_json() -> String { - format!( - r#"{{ - "artifactSetId": "{ARTIFACT_SET_ID}", - "formatVersion": 1, - "sampleRate": 8000, - "channelCount": 1, - "sampleCount": 64, - "durationSeconds": 0.008, - "appliedGain": 1.0, - "stemArtifacts": [ - {{ - "artifactId": "stem-vocals", - "stemKind": "vocals", - "fileSizeBytes": 172, - "contentHashSha256": "{CONTENT_HASH}", - "mediaType": "audio/wav", - "sampleRate": 8000, - "channelCount": 1, - "sampleCount": 64, - "durationSeconds": 0.008 - }}, - {{ - "artifactId": "stem-bass", - "stemKind": "bass", - "fileSizeBytes": 172, - "contentHashSha256": "{CONTENT_HASH}", - "mediaType": "audio/wav", - "sampleRate": 8000, - "channelCount": 1, - "sampleCount": 64, - "durationSeconds": 0.008 - }}, - {{ - "artifactId": "stem-drums", - "stemKind": "drums", - "fileSizeBytes": 172, - "contentHashSha256": "{CONTENT_HASH}", - "mediaType": "audio/wav", - "sampleRate": 8000, - "channelCount": 1, - "sampleCount": 64, - "durationSeconds": 0.008 - }}, - {{ - "artifactId": "stem-other", - "stemKind": "other", - "fileSizeBytes": 172, - "contentHashSha256": "{CONTENT_HASH}", - "mediaType": "audio/wav", - "sampleRate": 8000, - "channelCount": 1, - "sampleCount": 64, - "durationSeconds": 0.008 - }} - ] - }}"# - ) +fn valid_stem_artifact(stem_kind: &str) -> Value { + json!({ + "artifactId": format!("stem-{stem_kind}"), + "stemKind": stem_kind, + "fileSizeBytes": 172, + "contentHashSha256": CONTENT_HASH, + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }) +} + +fn valid_reference_value() -> Value { + json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008, + "appliedGain": 1.0, + "stemArtifacts": [ + valid_stem_artifact("vocals"), + valid_stem_artifact("bass"), + valid_stem_artifact("drums"), + valid_stem_artifact("other") + ] + }) +} + +fn parse_reference( + reference_value: Value, +) -> Result { + serde_json::from_value(reference_value) +} + +fn reference_object_mut(reference_value: &mut Value) -> &mut Map { + reference_value + .as_object_mut() + .expect("reference fixture must remain an object") +} + +fn stem_artifacts_mut(reference_value: &mut Value) -> &mut Vec { + reference_object_mut(reference_value) + .get_mut("stemArtifacts") + .and_then(Value::as_array_mut) + .expect("stemArtifacts fixture must remain an array") +} + +fn stem_artifact_object_mut( + reference_value: &mut Value, + artifact_index: usize, +) -> &mut Map { + stem_artifacts_mut(reference_value)[artifact_index] + .as_object_mut() + .expect("stem artifact fixture must remain an object") } #[test] -fn parses_complete_path_free_reference() { - let reference: PlayableStemArtifactSetReference = - serde_json::from_str(&valid_reference_json()).expect("valid reference should parse"); +fn parses_complete_path_free_reference_and_exposes_metadata() { + let reference = + parse_reference(valid_reference_value()).expect("valid reference should parse"); assert_eq!(reference.artifact_set_id(), ARTIFACT_SET_ID); assert_eq!(reference.format_version(), PLAYABLE_STEM_ARTIFACT_VERSION); @@ -85,9 +86,10 @@ fn parses_complete_path_free_reference() { assert_eq!(reference.sample_count(), 64); assert_eq!(reference.duration_seconds(), 0.008); assert_eq!(reference.applied_gain(), 1.0); + + let stem_artifacts = reference.stem_artifacts(); assert_eq!( - reference - .stem_artifacts() + stem_artifacts .iter() .map(|artifact| artifact.stem_kind()) .collect::>(), @@ -98,8 +100,17 @@ fn parses_complete_path_free_reference() { PlaybackStemKind::Other, ] ); + let vocal_artifact = &stem_artifacts[0]; + assert_eq!(vocal_artifact.artifact_id(), "stem-vocals"); + assert_eq!(vocal_artifact.file_size_bytes(), 172); + assert_eq!(vocal_artifact.content_hash_sha256(), CONTENT_HASH); + assert_eq!(vocal_artifact.media_type(), "audio/wav"); + assert_eq!(vocal_artifact.sample_rate(), 8000); + assert_eq!(vocal_artifact.channel_count(), 1); + assert_eq!(vocal_artifact.sample_count(), 64); + assert_eq!(vocal_artifact.duration_seconds(), 0.008); assert_eq!( - reference.artifact_relative_path(Path::new("/app/temp"), PlaybackStemKind::Bass), + reference.derive_artifact_path(Path::new("/app/temp"), PlaybackStemKind::Bass), Path::new("/app/temp") .join("playable-stems-v1") .join(ARTIFACT_SET_ID) @@ -109,8 +120,8 @@ fn parses_complete_path_free_reference() { #[test] fn serialized_reference_never_contains_a_native_path() { - let reference: PlayableStemArtifactSetReference = - serde_json::from_str(&valid_reference_json()).expect("valid reference should parse"); + let reference = + parse_reference(valid_reference_value()).expect("valid reference should parse"); let serialized = serde_json::to_string(&reference).expect("reference should serialize"); assert!(!serialized.to_ascii_lowercase().contains("path")); @@ -122,83 +133,169 @@ fn serialized_reference_never_contains_a_native_path() { #[test] fn rejects_unknown_path_and_storage_fields() { - for extra_field in [ - r#", "nativeFilePath": "/secret/audio.wav""#, - r#", "artifactRoot": "/secret""#, - r#", "sourcePath": "C:\\secret\\audio.wav""#, + for (field_name, field_value) in [ + ("nativeFilePath", "/secret/audio.wav"), + ("artifactRoot", "/secret"), + ("sourcePath", "C:\\secret\\audio.wav"), ] { - let malformed = valid_reference_json().replacen( - r#""artifactId": "stem-vocals""#, - &format!(r#""artifactId": "stem-vocals"{extra_field}"#), - 1, - ); - assert!(serde_json::from_str::(&malformed).is_err()); + let mut malformed = valid_reference_value(); + stem_artifact_object_mut(&mut malformed, 0) + .insert(field_name.to_string(), json!(field_value)); + assert!(parse_reference(malformed).is_err()); } + + let mut malformed_set = valid_reference_value(); + reference_object_mut(&mut malformed_set) + .insert("storageRoot".to_string(), json!("/secret")); + assert!(parse_reference(malformed_set).is_err()); } #[test] -fn rejects_invalid_set_and_artifact_identifiers() { +fn rejects_invalid_set_artifact_and_hash_identifiers() { + let mut malformed_set_case = valid_reference_value(); + reference_object_mut(&mut malformed_set_case) + .insert("artifactSetId".to_string(), json!("A".repeat(64))); + + let mut malformed_set_path = valid_reference_value(); + reference_object_mut(&mut malformed_set_path) + .insert("artifactSetId".to_string(), json!("a/../../b")); + + let mut malformed_artifact_id = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_artifact_id, 0) + .insert("artifactId".to_string(), json!("stem-bass")); + + let mut malformed_hash_case = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_hash_case, 0) + .insert("contentHashSha256".to_string(), json!("B".repeat(64))); + + let mut malformed_hash_length = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_hash_length, 0) + .insert("contentHashSha256".to_string(), json!("b".repeat(63))); + for malformed in [ - valid_reference_json().replace(ARTIFACT_SET_ID, "A"), - valid_reference_json().replace(ARTIFACT_SET_ID, "a/../../b"), - valid_reference_json().replacen("stem-vocals", "stem-bass", 1), - valid_reference_json().replacen(CONTENT_HASH, "ABC", 1), + malformed_set_case, + malformed_set_path, + malformed_artifact_id, + malformed_hash_case, + malformed_hash_length, ] { - assert!(serde_json::from_str::(&malformed).is_err()); + assert!(parse_reference(malformed).is_err()); } } #[test] fn rejects_missing_duplicate_reordered_or_unknown_stems() { - let valid_json = valid_reference_json(); - let vocals_start = valid_json.find(r#" {{ - "artifactId": "stem-vocals""#).unwrap(); - let bass_start = valid_json.find(r#" {{ - "artifactId": "stem-bass""#).unwrap(); - let vocals_block = &valid_json[vocals_start..bass_start]; - - let missing = valid_json.replacen(vocals_block, "", 1); - let duplicate = valid_json.replacen(vocals_block, &format!("{vocals_block}{vocals_block}"), 1); - let reordered = valid_json - .replacen(vocals_block, "__VOCALS_BLOCK__", 1) - .replacen( - r#" {{ - "artifactId": "stem-bass""#, - &format!(r#"{vocals_block} {{ - "artifactId": "stem-bass""#), - 1, - ) - .replace("__VOCALS_BLOCK__", ""); - let unknown = valid_json.replacen("\"stemKind\": \"other\"", "\"stemKind\": \"guitar\"", 1); + let mut missing = valid_reference_value(); + stem_artifacts_mut(&mut missing).remove(0); + + let mut duplicate = valid_reference_value(); + let duplicate_artifact = stem_artifacts_mut(&mut duplicate)[0].clone(); + stem_artifacts_mut(&mut duplicate).push(duplicate_artifact); + + let mut reordered = valid_reference_value(); + stem_artifacts_mut(&mut reordered).swap(0, 1); + + let mut unknown = valid_reference_value(); + stem_artifact_object_mut(&mut unknown, 3) + .insert("stemKind".to_string(), json!("guitar")); for malformed in [missing, duplicate, reordered, unknown] { - assert!(serde_json::from_str::(&malformed).is_err()); + assert!(parse_reference(malformed).is_err()); } } #[test] -fn rejects_set_level_media_and_alignment_mismatch() { +fn rejects_set_level_version_media_and_alignment_mismatch() { + let mut unsupported_version = valid_reference_value(); + reference_object_mut(&mut unsupported_version) + .insert("formatVersion".to_string(), json!(2)); + + let mut low_sample_rate = valid_reference_value(); + reference_object_mut(&mut low_sample_rate) + .insert("sampleRate".to_string(), json!(7999)); + + let mut high_sample_rate = valid_reference_value(); + reference_object_mut(&mut high_sample_rate) + .insert("sampleRate".to_string(), json!(192001)); + + let mut stereo = valid_reference_value(); + reference_object_mut(&mut stereo).insert("channelCount".to_string(), json!(2)); + + let mut empty = valid_reference_value(); + reference_object_mut(&mut empty).insert("sampleCount".to_string(), json!(0)); + + let mut duration_mismatch = valid_reference_value(); + reference_object_mut(&mut duration_mismatch) + .insert("durationSeconds".to_string(), json!(0.009)); + + let mut zero_gain = valid_reference_value(); + reference_object_mut(&mut zero_gain).insert("appliedGain".to_string(), json!(0.0)); + + let mut excessive_gain = valid_reference_value(); + reference_object_mut(&mut excessive_gain) + .insert("appliedGain".to_string(), json!(1.1)); + for malformed in [ - valid_reference_json().replacen("\"formatVersion\": 1", "\"formatVersion\": 2", 1), - valid_reference_json().replacen("\"sampleRate\": 8000", "\"sampleRate\": 7999", 1), - valid_reference_json().replacen("\"channelCount\": 1", "\"channelCount\": 2", 1), - valid_reference_json().replacen("\"sampleCount\": 64", "\"sampleCount\": 0", 1), - valid_reference_json().replacen("\"durationSeconds\": 0.008", "\"durationSeconds\": 0.009", 1), - valid_reference_json().replacen("\"appliedGain\": 1.0", "\"appliedGain\": 0.0", 1), + unsupported_version, + low_sample_rate, + high_sample_rate, + stereo, + empty, + duration_mismatch, + zero_gain, + excessive_gain, ] { - assert!(serde_json::from_str::(&malformed).is_err()); + assert!(parse_reference(malformed).is_err()); } } #[test] -fn rejects_per_stem_metadata_mismatch() { +fn rejects_file_size_overflow_before_accepting_artifacts() { + let mut malformed = valid_reference_value(); + let oversized_sample_count = u64::MAX; + let oversized_duration = oversized_sample_count as f64 / 8000.0; + reference_object_mut(&mut malformed) + .insert("sampleCount".to_string(), json!(oversized_sample_count)); + reference_object_mut(&mut malformed) + .insert("durationSeconds".to_string(), json!(oversized_duration)); + + assert!(parse_reference(malformed).is_err()); +} + +#[test] +fn rejects_each_per_stem_metadata_mismatch() { + let mut size_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut size_mismatch, 0) + .insert("fileSizeBytes".to_string(), json!(171)); + + let mut media_type_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut media_type_mismatch, 0) + .insert("mediaType".to_string(), json!("audio/mpeg")); + + let mut sample_rate_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut sample_rate_mismatch, 0) + .insert("sampleRate".to_string(), json!(16000)); + + let mut channel_count_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut channel_count_mismatch, 0) + .insert("channelCount".to_string(), json!(2)); + + let mut sample_count_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut sample_count_mismatch, 0) + .insert("sampleCount".to_string(), json!(63)); + + let mut duration_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut duration_mismatch, 0) + .insert("durationSeconds".to_string(), json!(0.007)); + for malformed in [ - valid_reference_json().replacen("\"fileSizeBytes\": 172", "\"fileSizeBytes\": 171", 1), - valid_reference_json().replacen("\"mediaType\": \"audio/wav\"", "\"mediaType\": \"audio/mpeg\"", 1), - valid_reference_json().replacen("\"sampleRate\": 8000", "\"sampleRate\": 16000", 2), - valid_reference_json().replacen("\"sampleCount\": 64", "\"sampleCount\": 63", 2), - valid_reference_json().replacen("\"durationSeconds\": 0.008", "\"durationSeconds\": 0.007", 2), + size_mismatch, + media_type_mismatch, + sample_rate_mismatch, + channel_count_mismatch, + sample_count_mismatch, + duration_mismatch, ] { - assert!(serde_json::from_str::(&malformed).is_err()); + assert!(parse_reference(malformed).is_err()); } } From 3975078780868ef027dbb92dbc8db1ae536c1f7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:46:30 +0900 Subject: [PATCH 004/160] refactor(core): name native playable stem path derivation --- apps/desktop/core/src/playable_stem_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/src/playable_stem_contract.rs b/apps/desktop/core/src/playable_stem_contract.rs index d7ef1ca6a..e026f5aac 100644 --- a/apps/desktop/core/src/playable_stem_contract.rs +++ b/apps/desktop/core/src/playable_stem_contract.rs @@ -195,7 +195,7 @@ impl PlayableStemArtifactSetReference { } /// Derive the only permitted path for one artifact from a native-owned root. - pub fn artifact_relative_path( + pub fn derive_artifact_path( &self, project_temp_root: &Path, stem_kind: PlaybackStemKind, From c5cfac357240f7126a33bf84b4d1798cee9eb0c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:47:56 +0900 Subject: [PATCH 005/160] test(core): cover nonfinite stem contract guards --- .../core/src/playable_stem_contract.rs | 121 +++++++++++------- 1 file changed, 74 insertions(+), 47 deletions(-) diff --git a/apps/desktop/core/src/playable_stem_contract.rs b/apps/desktop/core/src/playable_stem_contract.rs index e026f5aac..fcca5fdd9 100644 --- a/apps/desktop/core/src/playable_stem_contract.rs +++ b/apps/desktop/core/src/playable_stem_contract.rs @@ -221,56 +221,57 @@ struct RawPlayableStemArtifactSetReference { } impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { - fn deserialize(deserializer: D) -> Result + fn deserialize( + artifact_deserializer: ArtifactDeserializer, + ) -> Result where - D: Deserializer<'de>, + ArtifactDeserializer: Deserializer<'de>, { - let raw = RawPlayableStemArtifactSetReference::deserialize(deserializer)?; - validate_sha256_hex(&raw.artifact_set_id, "artifactSetId") + let raw_artifact_set = + RawPlayableStemArtifactSetReference::deserialize(artifact_deserializer)?; + validate_sha256_hex(&raw_artifact_set.artifact_set_id, "artifactSetId") .map_err(serde::de::Error::custom)?; - if raw.format_version != PLAYABLE_STEM_ARTIFACT_VERSION { + if raw_artifact_set.format_version != PLAYABLE_STEM_ARTIFACT_VERSION { return Err(serde::de::Error::custom( "unsupported playable stem artifact formatVersion", )); } if !(MIN_PLAYBACK_SAMPLE_RATE_HZ..=MAX_PLAYBACK_SAMPLE_RATE_HZ) - .contains(&raw.sample_rate) + .contains(&raw_artifact_set.sample_rate) { return Err(serde::de::Error::custom( "playable stem sampleRate is outside the supported range", )); } - if raw.channel_count != 1 { + if raw_artifact_set.channel_count != 1 { return Err(serde::de::Error::custom( "playable stem channelCount must be one", )); } - if raw.sample_count == 0 { + if raw_artifact_set.sample_count == 0 { return Err(serde::de::Error::custom( "playable stem sampleCount must be positive", )); } - let expected_duration = raw.sample_count as f64 / raw.sample_rate as f64; - validate_duration(raw.duration_seconds, expected_duration) + let expected_duration = + raw_artifact_set.sample_count as f64 / raw_artifact_set.sample_rate as f64; + validate_duration(raw_artifact_set.duration_seconds, expected_duration) .map_err(serde::de::Error::custom)?; - if !raw.applied_gain.is_finite() || raw.applied_gain <= 0.0 || raw.applied_gain > 1.0 { - return Err(serde::de::Error::custom( - "playable stem appliedGain must be finite and within (0, 1]", - )); - } - if raw.stem_artifacts.len() != PlaybackStemKind::canonical_order().len() { + validate_applied_gain(raw_artifact_set.applied_gain) + .map_err(serde::de::Error::custom)?; + if raw_artifact_set.stem_artifacts.len() != PlaybackStemKind::canonical_order().len() { return Err(serde::de::Error::custom( "playable stem artifact set must contain exactly four sources", )); } - let expected_file_size = raw + let expected_file_size = raw_artifact_set .sample_count .checked_mul(PCM16_BYTES_PER_SAMPLE) .and_then(|sample_bytes| sample_bytes.checked_add(CANONICAL_WAVE_HEADER_BYTES)) .ok_or_else(|| serde::de::Error::custom("playable stem file size overflow"))?; - let mut stem_artifacts = Vec::with_capacity(raw.stem_artifacts.len()); - for (raw_artifact, expected_stem_kind) in raw + let mut stem_artifacts = Vec::with_capacity(raw_artifact_set.stem_artifacts.len()); + for (raw_artifact, expected_stem_kind) in raw_artifact_set .stem_artifacts .into_iter() .zip(PlaybackStemKind::canonical_order()) @@ -278,10 +279,10 @@ impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { validate_artifact( &raw_artifact, expected_stem_kind, - raw.sample_rate, - raw.channel_count, - raw.sample_count, - raw.duration_seconds, + raw_artifact_set.sample_rate, + raw_artifact_set.channel_count, + raw_artifact_set.sample_count, + raw_artifact_set.duration_seconds, expected_file_size, ) .map_err(serde::de::Error::custom)?; @@ -299,20 +300,20 @@ impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { } Ok(Self { - artifact_set_id: raw.artifact_set_id, - format_version: raw.format_version, - sample_rate: raw.sample_rate, - channel_count: raw.channel_count, - sample_count: raw.sample_count, - duration_seconds: raw.duration_seconds, - applied_gain: raw.applied_gain, + artifact_set_id: raw_artifact_set.artifact_set_id, + format_version: raw_artifact_set.format_version, + sample_rate: raw_artifact_set.sample_rate, + channel_count: raw_artifact_set.channel_count, + sample_count: raw_artifact_set.sample_count, + duration_seconds: raw_artifact_set.duration_seconds, + applied_gain: raw_artifact_set.applied_gain, stem_artifacts, }) } } fn validate_artifact( - artifact: &RawPlayableStemArtifactReference, + raw_artifact: &RawPlayableStemArtifactReference, expected_stem_kind: PlaybackStemKind, sample_rate: u32, channel_count: u8, @@ -320,34 +321,34 @@ fn validate_artifact( duration_seconds: f64, expected_file_size: u64, ) -> Result<(), String> { - if artifact.stem_kind != expected_stem_kind { + if raw_artifact.stem_kind != expected_stem_kind { return Err("playable stems must use canonical source order".to_string()); } - if artifact.artifact_id != expected_stem_kind.artifact_id() { + if raw_artifact.artifact_id != expected_stem_kind.artifact_id() { return Err("playable stem artifactId does not match stemKind".to_string()); } - validate_sha256_hex(&artifact.content_hash_sha256, "contentHashSha256")?; - if artifact.media_type != "audio/wav" { + validate_sha256_hex(&raw_artifact.content_hash_sha256, "contentHashSha256")?; + if raw_artifact.media_type != "audio/wav" { return Err("playable stem mediaType must be audio/wav".to_string()); } - if artifact.sample_rate != sample_rate - || artifact.channel_count != channel_count - || artifact.sample_count != sample_count + if raw_artifact.sample_rate != sample_rate + || raw_artifact.channel_count != channel_count + || raw_artifact.sample_count != sample_count { return Err("playable stem media metadata is not aligned with its set".to_string()); } - validate_duration(artifact.duration_seconds, duration_seconds)?; - if artifact.file_size_bytes != expected_file_size { + validate_duration(raw_artifact.duration_seconds, duration_seconds)?; + if raw_artifact.file_size_bytes != expected_file_size { return Err("playable stem fileSizeBytes does not match canonical PCM16 WAV".to_string()); } Ok(()) } -fn validate_sha256_hex(value: &str, field_name: &str) -> Result<(), String> { - if value.len() != SHA256_HEX_CHARACTER_COUNT - || !value - .bytes() - .all(|character| character.is_ascii_digit() || (b'a'..=b'f').contains(&character)) +fn validate_sha256_hex(hash_value: &str, field_name: &str) -> Result<(), String> { + if hash_value.len() != SHA256_HEX_CHARACTER_COUNT + || !hash_value.bytes().all(|hex_character| { + hex_character.is_ascii_digit() || (b'a'..=b'f').contains(&hex_character) + }) { return Err(format!( "playable stem {field_name} must be lowercase SHA-256 hex" @@ -357,12 +358,38 @@ fn validate_sha256_hex(value: &str, field_name: &str) -> Result<(), String> { } fn validate_duration(actual_duration: f64, expected_duration: f64) -> Result<(), String> { - let tolerance = expected_duration.abs().max(1.0) * DURATION_RELATIVE_TOLERANCE; + let duration_tolerance = + expected_duration.abs().max(1.0) * DURATION_RELATIVE_TOLERANCE; if !actual_duration.is_finite() || actual_duration <= 0.0 - || (actual_duration - expected_duration).abs() > tolerance + || (actual_duration - expected_duration).abs() > duration_tolerance { return Err("playable stem durationSeconds is inconsistent".to_string()); } Ok(()) } + +fn validate_applied_gain(applied_gain: f64) -> Result<(), String> { + if !applied_gain.is_finite() || applied_gain <= 0.0 || applied_gain > 1.0 { + return Err("playable stem appliedGain must be finite and within (0, 1]".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod contract_unit_tests { + use super::{validate_applied_gain, validate_duration, validate_sha256_hex}; + + #[test] + fn internal_numeric_guards_reject_nonfinite_values() { + assert!(validate_duration(f64::NAN, 1.0).is_err()); + assert!(validate_duration(f64::INFINITY, 1.0).is_err()); + assert!(validate_applied_gain(f64::NAN).is_err()); + assert!(validate_applied_gain(f64::INFINITY).is_err()); + } + + #[test] + fn sha256_guard_accepts_numeric_lowercase_hex() { + assert!(validate_sha256_hex(&"0".repeat(64), "testHash").is_ok()); + } +} From 2f4892008cfd13681feb4dc658f6c073312f3079 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:48:55 +0900 Subject: [PATCH 006/160] test(core): cover every fixed playable stem filename --- .../tests/playable_stem_artifact_reference.rs | 129 ++++++++++-------- 1 file changed, 74 insertions(+), 55 deletions(-) diff --git a/apps/desktop/core/tests/playable_stem_artifact_reference.rs b/apps/desktop/core/tests/playable_stem_artifact_reference.rs index 27efb3c61..52375fb69 100644 --- a/apps/desktop/core/tests/playable_stem_artifact_reference.rs +++ b/apps/desktop/core/tests/playable_stem_artifact_reference.rs @@ -76,22 +76,25 @@ fn stem_artifact_object_mut( #[test] fn parses_complete_path_free_reference_and_exposes_metadata() { - let reference = + let artifact_reference = parse_reference(valid_reference_value()).expect("valid reference should parse"); - assert_eq!(reference.artifact_set_id(), ARTIFACT_SET_ID); - assert_eq!(reference.format_version(), PLAYABLE_STEM_ARTIFACT_VERSION); - assert_eq!(reference.sample_rate(), 8000); - assert_eq!(reference.channel_count(), 1); - assert_eq!(reference.sample_count(), 64); - assert_eq!(reference.duration_seconds(), 0.008); - assert_eq!(reference.applied_gain(), 1.0); + assert_eq!(artifact_reference.artifact_set_id(), ARTIFACT_SET_ID); + assert_eq!( + artifact_reference.format_version(), + PLAYABLE_STEM_ARTIFACT_VERSION + ); + assert_eq!(artifact_reference.sample_rate(), 8000); + assert_eq!(artifact_reference.channel_count(), 1); + assert_eq!(artifact_reference.sample_count(), 64); + assert_eq!(artifact_reference.duration_seconds(), 0.008); + assert_eq!(artifact_reference.applied_gain(), 1.0); - let stem_artifacts = reference.stem_artifacts(); + let stem_artifacts = artifact_reference.stem_artifacts(); assert_eq!( stem_artifacts .iter() - .map(|artifact| artifact.stem_kind()) + .map(|stem_artifact| stem_artifact.stem_kind()) .collect::>(), vec![ PlaybackStemKind::Vocals, @@ -109,26 +112,35 @@ fn parses_complete_path_free_reference_and_exposes_metadata() { assert_eq!(vocal_artifact.channel_count(), 1); assert_eq!(vocal_artifact.sample_count(), 64); assert_eq!(vocal_artifact.duration_seconds(), 0.008); - assert_eq!( - reference.derive_artifact_path(Path::new("/app/temp"), PlaybackStemKind::Bass), - Path::new("/app/temp") - .join("playable-stems-v1") - .join(ARTIFACT_SET_ID) - .join("bass.wav") - ); + + for (stem_kind, file_name) in [ + (PlaybackStemKind::Vocals, "vocals.wav"), + (PlaybackStemKind::Bass, "bass.wav"), + (PlaybackStemKind::Drums, "drums.wav"), + (PlaybackStemKind::Other, "other.wav"), + ] { + assert_eq!( + artifact_reference.derive_artifact_path(Path::new("/app/temp"), stem_kind), + Path::new("/app/temp") + .join("playable-stems-v1") + .join(ARTIFACT_SET_ID) + .join(file_name) + ); + } } #[test] fn serialized_reference_never_contains_a_native_path() { - let reference = + let artifact_reference = parse_reference(valid_reference_value()).expect("valid reference should parse"); - let serialized = serde_json::to_string(&reference).expect("reference should serialize"); - - assert!(!serialized.to_ascii_lowercase().contains("path")); - assert!(!serialized.contains("/app/temp")); - let reparsed: PlayableStemArtifactSetReference = - serde_json::from_str(&serialized).expect("serialized reference should parse"); - assert_eq!(reparsed, reference); + let serialized_reference = + serde_json::to_string(&artifact_reference).expect("reference should serialize"); + + assert!(!serialized_reference.to_ascii_lowercase().contains("path")); + assert!(!serialized_reference.contains("/app/temp")); + let reparsed_reference: PlayableStemArtifactSetReference = + serde_json::from_str(&serialized_reference).expect("serialized reference should parse"); + assert_eq!(reparsed_reference, artifact_reference); } #[test] @@ -138,10 +150,10 @@ fn rejects_unknown_path_and_storage_fields() { ("artifactRoot", "/secret"), ("sourcePath", "C:\\secret\\audio.wav"), ] { - let mut malformed = valid_reference_value(); - stem_artifact_object_mut(&mut malformed, 0) + let mut malformed_reference = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_reference, 0) .insert(field_name.to_string(), json!(field_value)); - assert!(parse_reference(malformed).is_err()); + assert!(parse_reference(malformed_reference).is_err()); } let mut malformed_set = valid_reference_value(); @@ -172,35 +184,40 @@ fn rejects_invalid_set_artifact_and_hash_identifiers() { stem_artifact_object_mut(&mut malformed_hash_length, 0) .insert("contentHashSha256".to_string(), json!("b".repeat(63))); - for malformed in [ + for malformed_reference in [ malformed_set_case, malformed_set_path, malformed_artifact_id, malformed_hash_case, malformed_hash_length, ] { - assert!(parse_reference(malformed).is_err()); + assert!(parse_reference(malformed_reference).is_err()); } } #[test] fn rejects_missing_duplicate_reordered_or_unknown_stems() { - let mut missing = valid_reference_value(); - stem_artifacts_mut(&mut missing).remove(0); + let mut missing_reference = valid_reference_value(); + stem_artifacts_mut(&mut missing_reference).remove(0); - let mut duplicate = valid_reference_value(); - let duplicate_artifact = stem_artifacts_mut(&mut duplicate)[0].clone(); - stem_artifacts_mut(&mut duplicate).push(duplicate_artifact); + let mut duplicate_reference = valid_reference_value(); + let duplicate_artifact = stem_artifacts_mut(&mut duplicate_reference)[0].clone(); + stem_artifacts_mut(&mut duplicate_reference).push(duplicate_artifact); - let mut reordered = valid_reference_value(); - stem_artifacts_mut(&mut reordered).swap(0, 1); + let mut reordered_reference = valid_reference_value(); + stem_artifacts_mut(&mut reordered_reference).swap(0, 1); - let mut unknown = valid_reference_value(); - stem_artifact_object_mut(&mut unknown, 3) + let mut unknown_reference = valid_reference_value(); + stem_artifact_object_mut(&mut unknown_reference, 3) .insert("stemKind".to_string(), json!("guitar")); - for malformed in [missing, duplicate, reordered, unknown] { - assert!(parse_reference(malformed).is_err()); + for malformed_reference in [ + missing_reference, + duplicate_reference, + reordered_reference, + unknown_reference, + ] { + assert!(parse_reference(malformed_reference).is_err()); } } @@ -218,11 +235,13 @@ fn rejects_set_level_version_media_and_alignment_mismatch() { reference_object_mut(&mut high_sample_rate) .insert("sampleRate".to_string(), json!(192001)); - let mut stereo = valid_reference_value(); - reference_object_mut(&mut stereo).insert("channelCount".to_string(), json!(2)); + let mut stereo_reference = valid_reference_value(); + reference_object_mut(&mut stereo_reference) + .insert("channelCount".to_string(), json!(2)); - let mut empty = valid_reference_value(); - reference_object_mut(&mut empty).insert("sampleCount".to_string(), json!(0)); + let mut empty_reference = valid_reference_value(); + reference_object_mut(&mut empty_reference) + .insert("sampleCount".to_string(), json!(0)); let mut duration_mismatch = valid_reference_value(); reference_object_mut(&mut duration_mismatch) @@ -235,31 +254,31 @@ fn rejects_set_level_version_media_and_alignment_mismatch() { reference_object_mut(&mut excessive_gain) .insert("appliedGain".to_string(), json!(1.1)); - for malformed in [ + for malformed_reference in [ unsupported_version, low_sample_rate, high_sample_rate, - stereo, - empty, + stereo_reference, + empty_reference, duration_mismatch, zero_gain, excessive_gain, ] { - assert!(parse_reference(malformed).is_err()); + assert!(parse_reference(malformed_reference).is_err()); } } #[test] fn rejects_file_size_overflow_before_accepting_artifacts() { - let mut malformed = valid_reference_value(); + let mut malformed_reference = valid_reference_value(); let oversized_sample_count = u64::MAX; let oversized_duration = oversized_sample_count as f64 / 8000.0; - reference_object_mut(&mut malformed) + reference_object_mut(&mut malformed_reference) .insert("sampleCount".to_string(), json!(oversized_sample_count)); - reference_object_mut(&mut malformed) + reference_object_mut(&mut malformed_reference) .insert("durationSeconds".to_string(), json!(oversized_duration)); - assert!(parse_reference(malformed).is_err()); + assert!(parse_reference(malformed_reference).is_err()); } #[test] @@ -288,7 +307,7 @@ fn rejects_each_per_stem_metadata_mismatch() { stem_artifact_object_mut(&mut duration_mismatch, 0) .insert("durationSeconds".to_string(), json!(0.007)); - for malformed in [ + for malformed_reference in [ size_mismatch, media_type_mismatch, sample_rate_mismatch, @@ -296,6 +315,6 @@ fn rejects_each_per_stem_metadata_mismatch() { sample_count_mismatch, duration_mismatch, ] { - assert!(parse_reference(malformed).is_err()); + assert!(parse_reference(malformed_reference).is_err()); } } From e362c43768b5d5f4b337270562458ee3249e484e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:56:38 +0900 Subject: [PATCH 007/160] test(core): pin classic RIFF PCM16 sample limit --- .../tests/playable_stem_artifact_reference.rs | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/desktop/core/tests/playable_stem_artifact_reference.rs b/apps/desktop/core/tests/playable_stem_artifact_reference.rs index 52375fb69..190ff45ed 100644 --- a/apps/desktop/core/tests/playable_stem_artifact_reference.rs +++ b/apps/desktop/core/tests/playable_stem_artifact_reference.rs @@ -4,7 +4,8 @@ mod playable_stem_contract; use playable_stem_contract::{ - PlayableStemArtifactSetReference, PlaybackStemKind, PLAYABLE_STEM_ARTIFACT_VERSION, + PlayableStemArtifactSetReference, PlaybackStemKind, + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT, PLAYABLE_STEM_ARTIFACT_VERSION, }; use serde_json::{json, Map, Value}; use std::path::Path; @@ -74,6 +75,24 @@ fn stem_artifact_object_mut( .expect("stem artifact fixture must remain an object") } +fn set_sample_geometry(reference_value: &mut Value, sample_count: u64) { + let sample_rate = 8_000_u64; + let duration_seconds = sample_count as f64 / sample_rate as f64; + let file_size_bytes = 44_u64 + (sample_count * 2_u64); + reference_object_mut(reference_value) + .insert("sampleCount".to_string(), json!(sample_count)); + reference_object_mut(reference_value) + .insert("durationSeconds".to_string(), json!(duration_seconds)); + for stem_artifact in stem_artifacts_mut(reference_value) { + let stem_artifact_object = stem_artifact + .as_object_mut() + .expect("stem artifact fixture must remain an object"); + stem_artifact_object.insert("sampleCount".to_string(), json!(sample_count)); + stem_artifact_object.insert("durationSeconds".to_string(), json!(duration_seconds)); + stem_artifact_object.insert("fileSizeBytes".to_string(), json!(file_size_bytes)); + } +} + #[test] fn parses_complete_path_free_reference_and_exposes_metadata() { let artifact_reference = @@ -269,16 +288,29 @@ fn rejects_set_level_version_media_and_alignment_mismatch() { } #[test] -fn rejects_file_size_overflow_before_accepting_artifacts() { - let mut malformed_reference = valid_reference_value(); - let oversized_sample_count = u64::MAX; - let oversized_duration = oversized_sample_count as f64 / 8000.0; - reference_object_mut(&mut malformed_reference) - .insert("sampleCount".to_string(), json!(oversized_sample_count)); - reference_object_mut(&mut malformed_reference) - .insert("durationSeconds".to_string(), json!(oversized_duration)); - - assert!(parse_reference(malformed_reference).is_err()); +fn enforces_classic_riff_sample_count_boundary() { + let mut maximum_reference = valid_reference_value(); + set_sample_geometry( + &mut maximum_reference, + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT, + ); + let parsed_maximum = + parse_reference(maximum_reference).expect("classic RIFF maximum should parse"); + assert_eq!( + parsed_maximum.sample_count(), + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT + ); + assert_eq!( + parsed_maximum.stem_artifacts()[0].file_size_bytes(), + 4_294_967_302 + ); + + let mut excessive_reference = valid_reference_value(); + set_sample_geometry( + &mut excessive_reference, + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT + 1, + ); + assert!(parse_reference(excessive_reference).is_err()); } #[test] From df2a305b3b12b4d02c3ee0b670186218b626b50a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:57:33 +0900 Subject: [PATCH 008/160] fix(core): enforce classic RIFF PCM16 size limit --- .../core/src/playable_stem_contract.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/desktop/core/src/playable_stem_contract.rs b/apps/desktop/core/src/playable_stem_contract.rs index fcca5fdd9..6442e037a 100644 --- a/apps/desktop/core/src/playable_stem_contract.rs +++ b/apps/desktop/core/src/playable_stem_contract.rs @@ -19,9 +19,16 @@ pub const MAX_PLAYBACK_SAMPLE_RATE_HZ: u32 = 192_000; const PCM16_BYTES_PER_SAMPLE: u64 = 2; const CANONICAL_WAVE_HEADER_BYTES: u64 = 44; +const RIFF_CHUNK_PREFIX_BYTES: u64 = 8; +const RIFF_CHUNK_OVERHEAD_BYTES: u64 = + CANONICAL_WAVE_HEADER_BYTES - RIFF_CHUNK_PREFIX_BYTES; const SHA256_HEX_CHARACTER_COUNT: usize = 64; const DURATION_RELATIVE_TOLERANCE: f64 = 1e-12; +/// Largest mono PCM16 sample count representable by a classic RIFF/WAV header. +pub const MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT: u64 = + (u32::MAX as u64 - RIFF_CHUNK_OVERHEAD_BYTES) / PCM16_BYTES_PER_SAMPLE; + /// Canonical source kinds produced by the current BandScope separation model. #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "snake_case")] @@ -253,6 +260,11 @@ impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { "playable stem sampleCount must be positive", )); } + if raw_artifact_set.sample_count > MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT { + return Err(serde::de::Error::custom( + "playable stem sampleCount exceeds the classic RIFF/WAV limit", + )); + } let expected_duration = raw_artifact_set.sample_count as f64 / raw_artifact_set.sample_rate as f64; validate_duration(raw_artifact_set.duration_seconds, expected_duration) @@ -265,11 +277,8 @@ impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { )); } - let expected_file_size = raw_artifact_set - .sample_count - .checked_mul(PCM16_BYTES_PER_SAMPLE) - .and_then(|sample_bytes| sample_bytes.checked_add(CANONICAL_WAVE_HEADER_BYTES)) - .ok_or_else(|| serde::de::Error::custom("playable stem file size overflow"))?; + let expected_file_size = CANONICAL_WAVE_HEADER_BYTES + + raw_artifact_set.sample_count * PCM16_BYTES_PER_SAMPLE; let mut stem_artifacts = Vec::with_capacity(raw_artifact_set.stem_artifacts.len()); for (raw_artifact, expected_stem_kind) in raw_artifact_set .stem_artifacts From a22d8905a936d671229fa070d195243a60463827 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:03:35 +0900 Subject: [PATCH 009/160] feat(core): isolate native artifact metadata from renderer status --- .../core/src/analysis_process_status.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/desktop/core/src/analysis_process_status.rs diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs new file mode 100644 index 000000000..cab7f154e --- /dev/null +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -0,0 +1,78 @@ +//! Parse analysis-process status without leaking native artifact metadata. +//! +//! Python analysis may emit a path-free playable-stem artifact reference for the +//! trusted native process. The renderer status must not carry that reference's +//! hashes, sizes, or storage-derived identity. This module removes and validates +//! the optional native-only field before deserializing the existing public status. + +use crate::{ + playable_stem_contract::PlayableStemArtifactSetReference, AnalysisJobState, + AnalysisJobStatus, +}; +use serde_json::Value; + +const PROCESS_STATUS_ERROR: &str = "Analysis engine returned an invalid response."; + +/// One validated analysis-process status split into public and native-only data. +#[derive(Clone, Debug)] +pub struct AnalysisProcessStatus { + renderer_status: AnalysisJobStatus, + playable_stem_artifact_set: Option, +} + +impl AnalysisProcessStatus { + /// Return the renderer-safe status after native-only metadata was removed. + pub const fn renderer_status(&self) -> &AnalysisJobStatus { + &self.renderer_status + } + + /// Return the optional native-only playable-stem reference. + pub const fn playable_stem_artifact_set( + &self, + ) -> Option<&PlayableStemArtifactSetReference> { + self.playable_stem_artifact_set.as_ref() + } + + /// Consume the parsed envelope and return its independently owned parts. + pub fn into_parts( + self, + ) -> ( + AnalysisJobStatus, + Option, + ) { + (self.renderer_status, self.playable_stem_artifact_set) + } +} + +/// Parse one JSONL status and isolate its optional native-only artifact reference. +pub fn parse_analysis_process_status( + process_status_json: &str, +) -> Result { + let mut process_status_value = + serde_json::from_str::(process_status_json).map_err(|_| PROCESS_STATUS_ERROR)?; + let process_status_object = process_status_value + .as_object_mut() + .ok_or(PROCESS_STATUS_ERROR)?; + let playable_stem_value = process_status_object.remove("playableStemArtifactSet"); + + let renderer_status = + serde_json::from_value::(process_status_value) + .map_err(|_| PROCESS_STATUS_ERROR)?; + let playable_stem_artifact_set = playable_stem_value + .map(serde_json::from_value::) + .transpose() + .map_err(|_| PROCESS_STATUS_ERROR)?; + + if playable_stem_artifact_set.is_some() + && (!matches!(&renderer_status.state, AnalysisJobState::Succeeded) + || renderer_status.result.is_none() + || renderer_status.error.is_some()) + { + return Err(PROCESS_STATUS_ERROR); + } + + Ok(AnalysisProcessStatus { + renderer_status, + playable_stem_artifact_set, + }) +} From a40a60782d23913e6a6b7d4bcf6dc27bcb2d6dc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:05:00 +0900 Subject: [PATCH 010/160] test(core): require native-only process status isolation --- .../core/tests/analysis_process_status.rs | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 apps/desktop/core/tests/analysis_process_status.rs diff --git a/apps/desktop/core/tests/analysis_process_status.rs b/apps/desktop/core/tests/analysis_process_status.rs new file mode 100644 index 000000000..511f9fa14 --- /dev/null +++ b/apps/desktop/core/tests/analysis_process_status.rs @@ -0,0 +1,258 @@ +//! Process-boundary tests for native-only playable-stem status metadata. + +use bandscope_desktop_core::{ + analysis_process_status::parse_analysis_process_status, AnalysisJobState, +}; +use serde_json::{json, Value}; + +const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const CONTENT_HASH: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const PROCESS_STATUS_ERROR: &str = "Analysis engine returned an invalid response."; + +fn playable_stem_artifact_set() -> Value { + json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008, + "appliedGain": 1.0, + "stemArtifacts": [ + playable_stem_artifact("vocals"), + playable_stem_artifact("bass"), + playable_stem_artifact("drums"), + playable_stem_artifact("other") + ] + }) +} + +fn playable_stem_artifact(stem_kind: &str) -> Value { + json!({ + "artifactId": format!("stem-{stem_kind}"), + "stemKind": stem_kind, + "fileSizeBytes": 172, + "contentHashSha256": CONTENT_HASH, + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }) +} + +fn succeeded_status() -> Value { + json!({ + "jobId": "job-playable-stems", + "state": "succeeded", + "requestedAt": "2026-09-04T00:00:00Z", + "updatedAt": "2026-09-04T00:00:01Z", + "progressLabel": "Analysis ready", + "progressStage": "ready", + "progressPercent": 100, + "cacheStatus": "stored", + "result": { + "id": "rights-cleared-song", + "title": "Rights-cleared fixture", + "sections": [], + "exportSummary": { + "format": "cue-sheet", + "headline": "Check the first section.", + "focusSections": [] + } + } + }) +} + +fn queued_status() -> Value { + json!({ + "jobId": "job-queued", + "state": "queued", + "requestedAt": "2026-09-04T00:00:00Z", + "updatedAt": "2026-09-04T00:00:00Z", + "progressLabel": "Queued for analysis", + "progressStage": "queued", + "progressPercent": 0, + "cacheStatus": "disabled" + }) +} + +fn parse_status_value( + process_status_value: Value, +) -> Result { + parse_analysis_process_status( + &serde_json::to_string(&process_status_value) + .expect("process status fixture should serialize"), + ) +} + +#[test] +fn isolates_native_artifact_reference_from_renderer_status() { + let mut process_status_value = succeeded_status(); + process_status_value + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + let process_status = + parse_status_value(process_status_value).expect("complete process status should parse"); + assert_eq!(process_status.renderer_status().job_id, "job-playable-stems"); + assert!(matches!( + &process_status.renderer_status().state, + AnalysisJobState::Succeeded + )); + assert_eq!( + process_status + .playable_stem_artifact_set() + .expect("native artifact reference should be retained") + .artifact_set_id(), + ARTIFACT_SET_ID + ); + + let renderer_status_json = serde_json::to_string(process_status.renderer_status()) + .expect("renderer status should serialize"); + assert!(!renderer_status_json.contains("playableStemArtifactSet")); + assert!(!renderer_status_json.contains(CONTENT_HASH)); + assert!(!renderer_status_json.to_ascii_lowercase().contains("path")); + + let (renderer_status, playable_stem_artifact_set) = process_status.into_parts(); + assert_eq!(renderer_status.job_id, "job-playable-stems"); + assert_eq!( + playable_stem_artifact_set + .expect("consumed native artifact reference should remain available") + .stem_artifacts() + .len(), + 4 + ); +} + +#[test] +fn preserves_legacy_status_without_playable_stem_metadata() { + let process_status = + parse_status_value(queued_status()).expect("legacy queued status should parse"); + + assert_eq!(process_status.renderer_status().job_id, "job-queued"); + assert!(process_status.playable_stem_artifact_set().is_none()); +} + +#[test] +fn rejects_native_artifact_metadata_on_nonterminal_or_failed_status() { + let mut running_status = queued_status(); + let running_status_object = running_status + .as_object_mut() + .expect("status fixture must remain an object"); + running_status_object.insert("state".to_string(), json!("running")); + running_status_object.insert("playableStemArtifactSet".to_string(), playable_stem_artifact_set()); + + let mut failed_status = queued_status(); + let failed_status_object = failed_status + .as_object_mut() + .expect("status fixture must remain an object"); + failed_status_object.insert("state".to_string(), json!("failed")); + failed_status_object.insert( + "error".to_string(), + json!({"code": "engine_unavailable", "message": "Analysis failed."}), + ); + failed_status_object.insert("playableStemArtifactSet".to_string(), playable_stem_artifact_set()); + + for invalid_status in [running_status, failed_status] { + assert_eq!(parse_status_value(invalid_status), Err(PROCESS_STATUS_ERROR)); + } +} + +#[test] +fn rejects_artifact_metadata_without_a_result_or_with_an_error() { + let mut missing_result = succeeded_status(); + let missing_result_object = missing_result + .as_object_mut() + .expect("status fixture must remain an object"); + missing_result_object.remove("result"); + missing_result_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + let mut success_with_error = succeeded_status(); + let success_with_error_object = success_with_error + .as_object_mut() + .expect("status fixture must remain an object"); + success_with_error_object.insert( + "error".to_string(), + json!({"code": "engine_unavailable", "message": "Contradictory status."}), + ); + success_with_error_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + for invalid_status in [missing_result, success_with_error] { + assert_eq!(parse_status_value(invalid_status), Err(PROCESS_STATUS_ERROR)); + } +} + +#[test] +fn rejects_null_malformed_or_path_bearing_artifact_metadata() { + let mut null_artifact_set = succeeded_status(); + null_artifact_set + .as_object_mut() + .expect("status fixture must remain an object") + .insert("playableStemArtifactSet".to_string(), Value::Null); + + let mut malformed_artifact_set = succeeded_status(); + malformed_artifact_set + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + json!({"artifactSetId": ARTIFACT_SET_ID}), + ); + + let mut path_bearing_artifact_set = playable_stem_artifact_set(); + path_bearing_artifact_set + .get_mut("stemArtifacts") + .and_then(Value::as_array_mut) + .and_then(|stem_artifacts| stem_artifacts.first_mut()) + .and_then(Value::as_object_mut) + .expect("stem artifact fixture must remain an object") + .insert( + "nativeFilePath".to_string(), + json!("/Users/private/audio.wav"), + ); + let mut path_bearing_status = succeeded_status(); + path_bearing_status + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + path_bearing_artifact_set, + ); + + for invalid_status in [ + null_artifact_set, + malformed_artifact_set, + path_bearing_status, + ] { + assert_eq!(parse_status_value(invalid_status), Err(PROCESS_STATUS_ERROR)); + } +} + +#[test] +fn preserves_existing_unknown_field_and_json_shape_rejection() { + let mut unknown_status = queued_status(); + unknown_status + .as_object_mut() + .expect("status fixture must remain an object") + .insert("unexpectedField".to_string(), json!(true)); + + assert_eq!(parse_status_value(unknown_status), Err(PROCESS_STATUS_ERROR)); + assert_eq!( + parse_analysis_process_status("not-json"), + Err(PROCESS_STATUS_ERROR) + ); + assert_eq!(parse_analysis_process_status("[]"), Err(PROCESS_STATUS_ERROR)); +} From ace6947c6efd27fd4939c77654a9c2eda4d86cb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:06:00 +0900 Subject: [PATCH 011/160] test(core): make process-status failures type-safe --- .../core/tests/analysis_process_status.rs | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/apps/desktop/core/tests/analysis_process_status.rs b/apps/desktop/core/tests/analysis_process_status.rs index 511f9fa14..19c4c375a 100644 --- a/apps/desktop/core/tests/analysis_process_status.rs +++ b/apps/desktop/core/tests/analysis_process_status.rs @@ -1,7 +1,8 @@ //! Process-boundary tests for native-only playable-stem status metadata. use bandscope_desktop_core::{ - analysis_process_status::parse_analysis_process_status, AnalysisJobState, + analysis_process_status::{parse_analysis_process_status, AnalysisProcessStatus}, + AnalysisJobState, }; use serde_json::{json, Value}; @@ -81,13 +82,25 @@ fn queued_status() -> Value { fn parse_status_value( process_status_value: Value, -) -> Result { +) -> Result { parse_analysis_process_status( &serde_json::to_string(&process_status_value) .expect("process status fixture should serialize"), ) } +fn assert_invalid_status(process_status_value: Value) { + let process_error = parse_status_value(process_status_value) + .expect_err("malformed process status must fail closed"); + assert_eq!(process_error, PROCESS_STATUS_ERROR); +} + +fn assert_invalid_json(process_status_json: &str) { + let process_error = parse_analysis_process_status(process_status_json) + .expect_err("malformed JSONL status must fail closed"); + assert_eq!(process_error, PROCESS_STATUS_ERROR); +} + #[test] fn isolates_native_artifact_reference_from_renderer_status() { let mut process_status_value = succeeded_status(); @@ -101,7 +114,10 @@ fn isolates_native_artifact_reference_from_renderer_status() { let process_status = parse_status_value(process_status_value).expect("complete process status should parse"); - assert_eq!(process_status.renderer_status().job_id, "job-playable-stems"); + assert_eq!( + process_status.renderer_status().job_id, + "job-playable-stems" + ); assert!(matches!( &process_status.renderer_status().state, AnalysisJobState::Succeeded @@ -147,7 +163,10 @@ fn rejects_native_artifact_metadata_on_nonterminal_or_failed_status() { .as_object_mut() .expect("status fixture must remain an object"); running_status_object.insert("state".to_string(), json!("running")); - running_status_object.insert("playableStemArtifactSet".to_string(), playable_stem_artifact_set()); + running_status_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); let mut failed_status = queued_status(); let failed_status_object = failed_status @@ -158,10 +177,13 @@ fn rejects_native_artifact_metadata_on_nonterminal_or_failed_status() { "error".to_string(), json!({"code": "engine_unavailable", "message": "Analysis failed."}), ); - failed_status_object.insert("playableStemArtifactSet".to_string(), playable_stem_artifact_set()); + failed_status_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); for invalid_status in [running_status, failed_status] { - assert_eq!(parse_status_value(invalid_status), Err(PROCESS_STATUS_ERROR)); + assert_invalid_status(invalid_status); } } @@ -191,7 +213,7 @@ fn rejects_artifact_metadata_without_a_result_or_with_an_error() { ); for invalid_status in [missing_result, success_with_error] { - assert_eq!(parse_status_value(invalid_status), Err(PROCESS_STATUS_ERROR)); + assert_invalid_status(invalid_status); } } @@ -237,7 +259,7 @@ fn rejects_null_malformed_or_path_bearing_artifact_metadata() { malformed_artifact_set, path_bearing_status, ] { - assert_eq!(parse_status_value(invalid_status), Err(PROCESS_STATUS_ERROR)); + assert_invalid_status(invalid_status); } } @@ -249,10 +271,7 @@ fn preserves_existing_unknown_field_and_json_shape_rejection() { .expect("status fixture must remain an object") .insert("unexpectedField".to_string(), json!(true)); - assert_eq!(parse_status_value(unknown_status), Err(PROCESS_STATUS_ERROR)); - assert_eq!( - parse_analysis_process_status("not-json"), - Err(PROCESS_STATUS_ERROR) - ); - assert_eq!(parse_analysis_process_status("[]"), Err(PROCESS_STATUS_ERROR)); + assert_invalid_status(unknown_status); + assert_invalid_json("not-json"); + assert_invalid_json("[]"); } From 4955cbe51f744160084fe217abe936c825a5a301 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:08:06 +0900 Subject: [PATCH 012/160] chore(repair): verify playable stem core integration --- .../playable-stem-core-contract-repair.yml | 81 +++++++++++++++++++ .../apply_playable_stem_core_modules.py | 42 ++++++++++ 2 files changed, 123 insertions(+) create mode 100644 .github/workflows/playable-stem-core-contract-repair.yml create mode 100755 scripts/repairs/apply_playable_stem_core_modules.py diff --git a/.github/workflows/playable-stem-core-contract-repair.yml b/.github/workflows/playable-stem-core-contract-repair.yml new file mode 100644 index 000000000..8e4b4b306 --- /dev/null +++ b/.github/workflows/playable-stem-core-contract-repair.yml @@ -0,0 +1,81 @@ +name: playable-stem-core-contract-repair + +on: + push: + branches: + - feat/playable-stem-native-contract-961 + paths: + - scripts/repairs/apply_playable_stem_core_modules.py + - .github/workflows/playable-stem-core-contract-repair.yml + +permissions: + contents: write + +concurrency: + group: playable-stem-core-contract-repair-${{ github.repository }}-1160 + cancel-in-progress: true + +jobs: + repair-and-verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: true + fetch-depth: 0 + - name: Verify exact branch identity + shell: bash + run: | + test "$GITHUB_REF_NAME" = "feat/playable-stem-native-contract-961" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + - name: Install reviewed Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal \ + --component rustfmt \ + --component clippy + - name: Apply exact crate-root repair + run: python scripts/repairs/apply_playable_stem_core_modules.py + - name: Check Rust formatting + run: | + cargo +1.97.1 fmt \ + --manifest-path apps/desktop/core/Cargo.toml \ + --all \ + -- \ + --check + - name: Test complete desktop core + run: | + cargo +1.97.1 test \ + --manifest-path apps/desktop/core/Cargo.toml \ + --locked + - name: Reject every Clippy warning + run: | + cargo +1.97.1 clippy \ + --manifest-path apps/desktop/core/Cargo.toml \ + --all-targets \ + --locked \ + -- \ + -D warnings + - name: Verify source diff integrity + run: git diff --check + - name: Remove completed repair machinery + run: | + rm scripts/repairs/apply_playable_stem_core_modules.py + rm .github/workflows/playable-stem-core-contract-repair.yml + - name: Commit verified core integration + shell: bash + run: | + git config user.name "bandscope-repair-bot" + git config user.email "bandscope-repair-bot@users.noreply.github.com" + git add \ + apps/desktop/core/src/lib.rs \ + apps/desktop/core/src/analysis_process_status.rs \ + apps/desktop/core/src/playable_stem_contract.rs \ + apps/desktop/core/tests/analysis_process_status.rs \ + apps/desktop/core/tests/playable_stem_artifact_reference.rs \ + scripts/repairs/apply_playable_stem_core_modules.py \ + .github/workflows/playable-stem-core-contract-repair.yml + git diff --cached --quiet && { echo "No verified core delta was produced." >&2; exit 1; } + git commit -m "feat(core): publish playable stem process contracts" + git push origin HEAD:feat/playable-stem-native-contract-961 diff --git a/scripts/repairs/apply_playable_stem_core_modules.py b/scripts/repairs/apply_playable_stem_core_modules.py new file mode 100755 index 000000000..59cf6dafd --- /dev/null +++ b/scripts/repairs/apply_playable_stem_core_modules.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Publish the reviewed playable-stem core modules through the crate root once. + +The GitHub connector cannot apply a two-line unified patch to the large crate +root. This exact-marker repair therefore adds only the two reviewed module +exports, fails when the source moved, and is removed by its owning workflow +only after Rust formatting, tests, and Clippy all pass. +""" + +from __future__ import annotations + +from pathlib import Path + + +CORE_LIBRARY_PATH = Path("apps/desktop/core/src/lib.rs") +MODULE_MARKER = "use serde::{Deserialize, Deserializer, Serialize};\n" +MODULE_DECLARATIONS = ( + "pub mod analysis_process_status;\n" + "pub mod playable_stem_contract;\n\n" +) + + +def main() -> None: + """Insert both module declarations before the first crate dependency import.""" + library_source = CORE_LIBRARY_PATH.read_text(encoding="utf-8") + if MODULE_DECLARATIONS in library_source: + raise RuntimeError("Playable stem core modules are already published.") + marker_count = library_source.count(MODULE_MARKER) + if marker_count != 1: + raise RuntimeError( + f"Expected one core module marker, found {marker_count}." + ) + repaired_source = library_source.replace( + MODULE_MARKER, + MODULE_DECLARATIONS + MODULE_MARKER, + 1, + ) + CORE_LIBRARY_PATH.write_text(repaired_source, encoding="utf-8") + + +if __name__ == "__main__": + main() From 64bed35f2852d6f38ea7ec14661c2bb50988759c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 12:06:46 +0900 Subject: [PATCH 013/160] test(core): bind playable stem contract to production crate --- apps/desktop/core/tests/playable_stem_artifact_reference.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/core/tests/playable_stem_artifact_reference.rs b/apps/desktop/core/tests/playable_stem_artifact_reference.rs index 190ff45ed..ee1793742 100644 --- a/apps/desktop/core/tests/playable_stem_artifact_reference.rs +++ b/apps/desktop/core/tests/playable_stem_artifact_reference.rs @@ -1,9 +1,6 @@ //! Contract tests for path-free playable-stem artifact references. -#[path = "../src/playable_stem_contract.rs"] -mod playable_stem_contract; - -use playable_stem_contract::{ +use bandscope_desktop_core::playable_stem_contract::{ PlayableStemArtifactSetReference, PlaybackStemKind, MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT, PLAYABLE_STEM_ARTIFACT_VERSION, }; From 804be80a8b59b7822cb3d68611769d6cd21270c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:08:07 +0900 Subject: [PATCH 014/160] fix(core): publish playable stem process contracts --- apps/desktop/core/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..b6dcd297c 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -6,6 +6,9 @@ //! measured for coverage) on any platform without a windowing system or a //! bundled frontend. +pub mod analysis_process_status; +pub mod playable_stem_contract; + use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::{ From 27e1818769b87f300dfa4385b94798f9a29cc4be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 17:07:23 +0900 Subject: [PATCH 015/160] fix(stems): consume strict process status at Tauri boundary --- apps/desktop/src-tauri/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 6e142bd1f..68f682197 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -415,7 +415,12 @@ fn run_analysis_engine( if trimmed.is_empty() { continue; } - if let Ok(status) = serde_json::from_str::(trimmed) { + if let Ok(process_status) = + analysis_process_status::parse_analysis_process_status(trimmed) + { + // Native stem metadata is validated here but remains outside renderer state + // until playback admission verifies the actual WAV files. + let status = process_status.renderer_status().clone(); last_status = Some(status.clone()); if status_tx.send(status).is_err() { break; From a22a20a9ea420b6ebac43bfcdee26dc435e5f349 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:05:48 +0900 Subject: [PATCH 016/160] feat(core): add streaming SHA-256 integrity verifier --- apps/desktop/core/src/sha256_integrity.rs | 321 ++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 apps/desktop/core/src/sha256_integrity.rs diff --git a/apps/desktop/core/src/sha256_integrity.rs b/apps/desktop/core/src/sha256_integrity.rs new file mode 100644 index 000000000..a65c3b4a9 --- /dev/null +++ b/apps/desktop/core/src/sha256_integrity.rs @@ -0,0 +1,321 @@ +//! Streaming SHA-256 used to bind native media bytes to validated metadata. +//! +//! BandScope uses this implementation only for deterministic local integrity +//! verification. It follows the SHA-256 operations specified by NIST FIPS 180-4 +//! and is exercised against published-style known-answer vectors; those tests do +//! not constitute CAVP validation. + +use std::io::{self, Read}; + +const BLOCK_BYTES: usize = 64; +const DIGEST_BYTES: usize = 32; + +const INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; + +const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, + 0x7137_4491, + 0xb5c0_fbcf, + 0xe9b5_dba5, + 0x3956_c25b, + 0x59f1_11f1, + 0x923f_82a4, + 0xab1c_5ed5, + 0xd807_aa98, + 0x1283_5b01, + 0x2431_85be, + 0x550c_7dc3, + 0x72be_5d74, + 0x80de_b1fe, + 0x9bdc_06a7, + 0xc19b_f174, + 0xe49b_69c1, + 0xefbe_4786, + 0x0fc1_9dc6, + 0x240c_a1cc, + 0x2de9_2c6f, + 0x4a74_84aa, + 0x5cb0_a9dc, + 0x76f9_88da, + 0x983e_5152, + 0xa831_c66d, + 0xb003_27c8, + 0xbf59_7fc7, + 0xc6e0_0bf3, + 0xd5a7_9147, + 0x06ca_6351, + 0x1429_2967, + 0x27b7_0a85, + 0x2e1b_2138, + 0x4d2c_6dfc, + 0x5338_0d13, + 0x650a_7354, + 0x766a_0abb, + 0x81c2_c92e, + 0x9272_2c85, + 0xa2bf_e8a1, + 0xa81a_664b, + 0xc24b_8b70, + 0xc76c_51a3, + 0xd192_e819, + 0xd699_0624, + 0xf40e_3585, + 0x106a_a070, + 0x19a4_c116, + 0x1e37_6c08, + 0x2748_774c, + 0x34b0_bcb5, + 0x391c_0cb3, + 0x4ed8_aa4a, + 0x5b9c_ca4f, + 0x682e_6ff3, + 0x748f_82ee, + 0x78a5_636f, + 0x84c8_7814, + 0x8cc7_0208, + 0x90be_fffa, + 0xa450_6ceb, + 0xbef9_a3f7, + 0xc671_78f2, +]; + +#[derive(Clone)] +struct Sha256State { + state: [u32; 8], + buffer: [u8; BLOCK_BYTES], + buffer_len: usize, + message_len_bytes: u64, +} + +impl Default for Sha256State { + fn default() -> Self { + Self { + state: INITIAL_STATE, + buffer: [0; BLOCK_BYTES], + buffer_len: 0, + message_len_bytes: 0, + } + } +} + +impl Sha256State { + fn update(&mut self, mut bytes: &[u8]) { + self.message_len_bytes = self + .message_len_bytes + .checked_add(bytes.len() as u64) + .expect("BandScope SHA-256 input length must fit the FIPS 180-4 length field"); + + if self.buffer_len != 0 { + let available = BLOCK_BYTES - self.buffer_len; + let copied = available.min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + copied] + .copy_from_slice(&bytes[..copied]); + self.buffer_len += copied; + bytes = &bytes[copied..]; + if self.buffer_len == BLOCK_BYTES { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } + } + + while bytes.len() >= BLOCK_BYTES { + let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES] + .try_into() + .expect("SHA-256 block slice has fixed length"); + self.compress(block); + bytes = &bytes[BLOCK_BYTES..]; + } + + if !bytes.is_empty() { + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + } + + fn finalize(mut self) -> [u8; DIGEST_BYTES] { + let message_len_bits = self + .message_len_bytes + .checked_mul(8) + .expect("BandScope SHA-256 input length must fit the FIPS 180-4 bit length field"); + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; BLOCK_BYTES]; + self.buffer_len = 0; + } + self.buffer[self.buffer_len..56].fill(0); + self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); + let final_block = self.buffer; + self.compress(&final_block); + + let mut digest = [0u8; DIGEST_BYTES]; + for (index, word) in self.state.into_iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + digest + } + + fn compress(&mut self, block: &[u8; BLOCK_BYTES]) { + let mut schedule = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes( + chunk + .try_into() + .expect("SHA-256 message word has exactly four bytes"), + ); + } + for index in 16..64 { + let sigma0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let sigma1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for index in 0..64 { + let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(big_sigma1) + .wrapping_add(choose) + .wrapping_add(ROUND_CONSTANTS[index]) + .wrapping_add(schedule[index]); + let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temp2 = big_sigma0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + self.state[0] = self.state[0].wrapping_add(a); + self.state[1] = self.state[1].wrapping_add(b); + self.state[2] = self.state[2].wrapping_add(c); + self.state[3] = self.state[3].wrapping_add(d); + self.state[4] = self.state[4].wrapping_add(e); + self.state[5] = self.state[5].wrapping_add(f); + self.state[6] = self.state[6].wrapping_add(g); + self.state[7] = self.state[7].wrapping_add(h); + } +} + +/// Read all bytes from `reader` and return their lowercase SHA-256 digest. +/// +/// The function streams fixed-size chunks instead of buffering media files, so +/// verifying a multi-gigabyte classic RIFF/WAV artifact does not scale heap use +/// with file size. +pub fn sha256_hex_reader(mut reader: impl Read) -> io::Result { + let mut state = Sha256State::default(); + let mut chunk = [0u8; 64 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read_bytes) => state.update(&chunk[..read_bytes]), + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + + let digest = state.finalize(); + let mut hex = String::with_capacity(DIGEST_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + hex.push(HEX[(byte >> 4) as usize] as char); + hex.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(hex) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + struct ShortReader { + bytes: Vec, + cursor: usize, + max_chunk: usize, + } + + impl Read for ShortReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if self.cursor == self.bytes.len() { + return Ok(0); + } + let remaining = self.bytes.len() - self.cursor; + let copied = remaining.min(self.max_chunk).min(output.len()); + output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]); + self.cursor += copied; + Ok(copied) + } + } + + #[test] + fn matches_sha256_known_answer_vectors() { + for (message, expected) in [ + ( + b"".as_slice(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + b"abc".as_slice(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".as_slice(), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ] { + assert_eq!(sha256_hex_reader(Cursor::new(message)).as_deref(), Ok(expected)); + } + } + + #[test] + fn streaming_short_reads_do_not_change_the_digest() { + let bytes = (0..131_111).map(|index| (index % 251) as u8).collect::>(); + let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference digest should hash"); + let actual = sha256_hex_reader(ShortReader { + bytes, + cursor: 0, + max_chunk: 7, + }) + .expect("short-read digest should hash"); + + assert_eq!(actual, expected); + } + + #[test] + fn matches_the_million_a_sha256_vector() { + let bytes = vec![b'a'; 1_000_000]; + assert_eq!( + sha256_hex_reader(Cursor::new(bytes)).as_deref(), + Ok("cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0") + ); + } +} From 22b88d7fddecdfcd48e4f977c3029cfec7a57369 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:09:06 +0900 Subject: [PATCH 017/160] revert(core): remove unintegrated SHA-256 helper --- apps/desktop/core/src/sha256_integrity.rs | 321 ---------------------- 1 file changed, 321 deletions(-) delete mode 100644 apps/desktop/core/src/sha256_integrity.rs diff --git a/apps/desktop/core/src/sha256_integrity.rs b/apps/desktop/core/src/sha256_integrity.rs deleted file mode 100644 index a65c3b4a9..000000000 --- a/apps/desktop/core/src/sha256_integrity.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Streaming SHA-256 used to bind native media bytes to validated metadata. -//! -//! BandScope uses this implementation only for deterministic local integrity -//! verification. It follows the SHA-256 operations specified by NIST FIPS 180-4 -//! and is exercised against published-style known-answer vectors; those tests do -//! not constitute CAVP validation. - -use std::io::{self, Read}; - -const BLOCK_BYTES: usize = 64; -const DIGEST_BYTES: usize = 32; - -const INITIAL_STATE: [u32; 8] = [ - 0x6a09_e667, - 0xbb67_ae85, - 0x3c6e_f372, - 0xa54f_f53a, - 0x510e_527f, - 0x9b05_688c, - 0x1f83_d9ab, - 0x5be0_cd19, -]; - -const ROUND_CONSTANTS: [u32; 64] = [ - 0x428a_2f98, - 0x7137_4491, - 0xb5c0_fbcf, - 0xe9b5_dba5, - 0x3956_c25b, - 0x59f1_11f1, - 0x923f_82a4, - 0xab1c_5ed5, - 0xd807_aa98, - 0x1283_5b01, - 0x2431_85be, - 0x550c_7dc3, - 0x72be_5d74, - 0x80de_b1fe, - 0x9bdc_06a7, - 0xc19b_f174, - 0xe49b_69c1, - 0xefbe_4786, - 0x0fc1_9dc6, - 0x240c_a1cc, - 0x2de9_2c6f, - 0x4a74_84aa, - 0x5cb0_a9dc, - 0x76f9_88da, - 0x983e_5152, - 0xa831_c66d, - 0xb003_27c8, - 0xbf59_7fc7, - 0xc6e0_0bf3, - 0xd5a7_9147, - 0x06ca_6351, - 0x1429_2967, - 0x27b7_0a85, - 0x2e1b_2138, - 0x4d2c_6dfc, - 0x5338_0d13, - 0x650a_7354, - 0x766a_0abb, - 0x81c2_c92e, - 0x9272_2c85, - 0xa2bf_e8a1, - 0xa81a_664b, - 0xc24b_8b70, - 0xc76c_51a3, - 0xd192_e819, - 0xd699_0624, - 0xf40e_3585, - 0x106a_a070, - 0x19a4_c116, - 0x1e37_6c08, - 0x2748_774c, - 0x34b0_bcb5, - 0x391c_0cb3, - 0x4ed8_aa4a, - 0x5b9c_ca4f, - 0x682e_6ff3, - 0x748f_82ee, - 0x78a5_636f, - 0x84c8_7814, - 0x8cc7_0208, - 0x90be_fffa, - 0xa450_6ceb, - 0xbef9_a3f7, - 0xc671_78f2, -]; - -#[derive(Clone)] -struct Sha256State { - state: [u32; 8], - buffer: [u8; BLOCK_BYTES], - buffer_len: usize, - message_len_bytes: u64, -} - -impl Default for Sha256State { - fn default() -> Self { - Self { - state: INITIAL_STATE, - buffer: [0; BLOCK_BYTES], - buffer_len: 0, - message_len_bytes: 0, - } - } -} - -impl Sha256State { - fn update(&mut self, mut bytes: &[u8]) { - self.message_len_bytes = self - .message_len_bytes - .checked_add(bytes.len() as u64) - .expect("BandScope SHA-256 input length must fit the FIPS 180-4 length field"); - - if self.buffer_len != 0 { - let available = BLOCK_BYTES - self.buffer_len; - let copied = available.min(bytes.len()); - self.buffer[self.buffer_len..self.buffer_len + copied] - .copy_from_slice(&bytes[..copied]); - self.buffer_len += copied; - bytes = &bytes[copied..]; - if self.buffer_len == BLOCK_BYTES { - let block = self.buffer; - self.compress(&block); - self.buffer_len = 0; - } - } - - while bytes.len() >= BLOCK_BYTES { - let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES] - .try_into() - .expect("SHA-256 block slice has fixed length"); - self.compress(block); - bytes = &bytes[BLOCK_BYTES..]; - } - - if !bytes.is_empty() { - self.buffer[..bytes.len()].copy_from_slice(bytes); - self.buffer_len = bytes.len(); - } - } - - fn finalize(mut self) -> [u8; DIGEST_BYTES] { - let message_len_bits = self - .message_len_bytes - .checked_mul(8) - .expect("BandScope SHA-256 input length must fit the FIPS 180-4 bit length field"); - - self.buffer[self.buffer_len] = 0x80; - self.buffer_len += 1; - if self.buffer_len > 56 { - self.buffer[self.buffer_len..].fill(0); - let block = self.buffer; - self.compress(&block); - self.buffer = [0; BLOCK_BYTES]; - self.buffer_len = 0; - } - self.buffer[self.buffer_len..56].fill(0); - self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); - let final_block = self.buffer; - self.compress(&final_block); - - let mut digest = [0u8; DIGEST_BYTES]; - for (index, word) in self.state.into_iter().enumerate() { - digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); - } - digest - } - - fn compress(&mut self, block: &[u8; BLOCK_BYTES]) { - let mut schedule = [0u32; 64]; - for (index, chunk) in block.chunks_exact(4).enumerate() { - schedule[index] = u32::from_be_bytes( - chunk - .try_into() - .expect("SHA-256 message word has exactly four bytes"), - ); - } - for index in 16..64 { - let sigma0 = schedule[index - 15].rotate_right(7) - ^ schedule[index - 15].rotate_right(18) - ^ (schedule[index - 15] >> 3); - let sigma1 = schedule[index - 2].rotate_right(17) - ^ schedule[index - 2].rotate_right(19) - ^ (schedule[index - 2] >> 10); - schedule[index] = schedule[index - 16] - .wrapping_add(sigma0) - .wrapping_add(schedule[index - 7]) - .wrapping_add(sigma1); - } - - let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; - for index in 0..64 { - let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); - let choose = (e & f) ^ ((!e) & g); - let temp1 = h - .wrapping_add(big_sigma1) - .wrapping_add(choose) - .wrapping_add(ROUND_CONSTANTS[index]) - .wrapping_add(schedule[index]); - let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); - let majority = (a & b) ^ (a & c) ^ (b & c); - let temp2 = big_sigma0.wrapping_add(majority); - - h = g; - g = f; - f = e; - e = d.wrapping_add(temp1); - d = c; - c = b; - b = a; - a = temp1.wrapping_add(temp2); - } - - self.state[0] = self.state[0].wrapping_add(a); - self.state[1] = self.state[1].wrapping_add(b); - self.state[2] = self.state[2].wrapping_add(c); - self.state[3] = self.state[3].wrapping_add(d); - self.state[4] = self.state[4].wrapping_add(e); - self.state[5] = self.state[5].wrapping_add(f); - self.state[6] = self.state[6].wrapping_add(g); - self.state[7] = self.state[7].wrapping_add(h); - } -} - -/// Read all bytes from `reader` and return their lowercase SHA-256 digest. -/// -/// The function streams fixed-size chunks instead of buffering media files, so -/// verifying a multi-gigabyte classic RIFF/WAV artifact does not scale heap use -/// with file size. -pub fn sha256_hex_reader(mut reader: impl Read) -> io::Result { - let mut state = Sha256State::default(); - let mut chunk = [0u8; 64 * 1024]; - loop { - match reader.read(&mut chunk) { - Ok(0) => break, - Ok(read_bytes) => state.update(&chunk[..read_bytes]), - Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, - Err(error) => return Err(error), - } - } - - let digest = state.finalize(); - let mut hex = String::with_capacity(DIGEST_BYTES * 2); - const HEX: &[u8; 16] = b"0123456789abcdef"; - for byte in digest { - hex.push(HEX[(byte >> 4) as usize] as char); - hex.push(HEX[(byte & 0x0f) as usize] as char); - } - Ok(hex) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - struct ShortReader { - bytes: Vec, - cursor: usize, - max_chunk: usize, - } - - impl Read for ShortReader { - fn read(&mut self, output: &mut [u8]) -> io::Result { - if self.cursor == self.bytes.len() { - return Ok(0); - } - let remaining = self.bytes.len() - self.cursor; - let copied = remaining.min(self.max_chunk).min(output.len()); - output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]); - self.cursor += copied; - Ok(copied) - } - } - - #[test] - fn matches_sha256_known_answer_vectors() { - for (message, expected) in [ - ( - b"".as_slice(), - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ), - ( - b"abc".as_slice(), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", - ), - ( - b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".as_slice(), - "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", - ), - ] { - assert_eq!(sha256_hex_reader(Cursor::new(message)).as_deref(), Ok(expected)); - } - } - - #[test] - fn streaming_short_reads_do_not_change_the_digest() { - let bytes = (0..131_111).map(|index| (index % 251) as u8).collect::>(); - let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference digest should hash"); - let actual = sha256_hex_reader(ShortReader { - bytes, - cursor: 0, - max_chunk: 7, - }) - .expect("short-read digest should hash"); - - assert_eq!(actual, expected); - } - - #[test] - fn matches_the_million_a_sha256_vector() { - let bytes = vec![b'a'; 1_000_000]; - assert_eq!( - sha256_hex_reader(Cursor::new(bytes)).as_deref(), - Ok("cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0") - ); - } -} From 366024a9578faaccbfc1311d3abc19ac421cd99d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:11:55 +0900 Subject: [PATCH 018/160] feat(stems): establish native playable-stem admission library --- apps/desktop/src-tauri/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 apps/desktop/src-tauri/src/lib.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 000000000..22b68996e --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,6 @@ +//! Native desktop domain services that are independent of the Tauri command surface. +//! +//! Keeping filesystem admission logic here lets BandScope test hostile local media +//! without granting the renderer path or playback authority. + +pub mod playable_stem_admission; From 8306554b86e01b8cc0a4ba1927d43e2f593a05a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:13:52 +0900 Subject: [PATCH 019/160] feat(stems): preflight actual WAV artifacts natively --- .../src-tauri/src/playable_stem_admission.rs | 732 ++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100644 apps/desktop/src-tauri/src/playable_stem_admission.rs diff --git a/apps/desktop/src-tauri/src/playable_stem_admission.rs b/apps/desktop/src-tauri/src/playable_stem_admission.rs new file mode 100644 index 000000000..e24b27e7b --- /dev/null +++ b/apps/desktop/src-tauri/src/playable_stem_admission.rs @@ -0,0 +1,732 @@ +//! Native actual-file preflight for generated rehearsal stem WAVs. +//! +//! A path-free `PlayableStemArtifactSetReference` is metadata, not playback +//! authority. This module derives the only permitted paths from the native-owned +//! project temp root and verifies the complete four-file set before a later +//! playback-authority step may retain any of it. No path or file handle from this +//! module is serializable to the renderer. + +use bandscope_desktop_core::playable_stem_contract::{ + PlaybackStemKind, PlayableStemArtifactReference, PlayableStemArtifactSetReference, +}; +use std::{ + collections::BTreeSet, + ffi::OsString, + fs::{self, File, Metadata}, + io::{self, Read, Seek, SeekFrom}, + path::{Path, PathBuf}, +}; + +const CANONICAL_WAVE_HEADER_BYTES: usize = 44; +const PCM16_BYTES_PER_SAMPLE: u64 = 2; +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +/// Stable, payload-free reasons native stem admission can fail. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlayableStemAdmissionError { + /// The project-owned temp root is absent, not a directory, or redirecting. + InvalidProjectTempRoot, + /// The version/set directory does not have the exact canonical shape. + InvalidArtifactSetLayout, + /// One expected artifact is absent, redirected, non-regular, or unreadable. + InvalidArtifactFile, + /// File length does not match the path-free artifact contract. + FileSizeMismatch, + /// The file is not the canonical mono PCM16 RIFF/WAVE described by metadata. + WaveHeaderMismatch, + /// Complete-file SHA-256 differs from the path-free contract. + ContentHashMismatch, +} + +impl std::fmt::Display for PlayableStemAdmissionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::InvalidProjectTempRoot => "playable stem project temp root is invalid", + Self::InvalidArtifactSetLayout => "playable stem artifact-set layout is invalid", + Self::InvalidArtifactFile => "playable stem artifact file is invalid", + Self::FileSizeMismatch => "playable stem artifact size does not match metadata", + Self::WaveHeaderMismatch => "playable stem WAV header does not match metadata", + Self::ContentHashMismatch => "playable stem content hash does not match metadata", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for PlayableStemAdmissionError {} + +/// One actual file that passed native path/layout/byte/header/hash preflight. +/// +/// This value intentionally does not implement `Serialize`; its path remains a +/// native-process detail and cannot become renderer authority by accident. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightPlayableStemFile { + stem_kind: PlaybackStemKind, + native_path: PathBuf, + file_size_bytes: u64, + content_hash_sha256: String, +} + +impl PreflightPlayableStemFile { + /// Return which canonical stem this actual file represents. + pub const fn stem_kind(&self) -> PlaybackStemKind { + self.stem_kind + } + + /// Return the native-only canonical file path for later authority admission. + pub fn native_path(&self) -> &Path { + &self.native_path + } + + /// Return the byte length verified on the opened file. + pub const fn file_size_bytes(&self) -> u64 { + self.file_size_bytes + } + + /// Return the SHA-256 digest recomputed over the complete opened file. + pub fn content_hash_sha256(&self) -> &str { + &self.content_hash_sha256 + } +} + +/// Complete actual-file preflight result. Partial stem sets are never returned. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightPlayableStemSet { + artifact_set_id: String, + files: Vec, +} + +impl PreflightPlayableStemSet { + /// Return the path-free artifact-set identity that was verified on disk. + pub fn artifact_set_id(&self) -> &str { + &self.artifact_set_id + } + + /// Return all four verified files in canonical vocals/bass/drums/other order. + pub fn files(&self) -> &[PreflightPlayableStemFile] { + &self.files + } +} + +/// Verify the complete generated stem set under a native-owned project temp root. +/// +/// This function grants no playback authority. Callers must still bind the +/// returned files to the current project using #971's revocable native file +/// identity/serving boundary before exposing any opaque renderer handle. +pub fn preflight_playable_stem_set( + project_temp_root: &Path, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result { + validate_directory(project_temp_root) + .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; + let canonical_temp_root = project_temp_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; + if canonical_temp_root != project_temp_root { + return Err(PlayableStemAdmissionError::InvalidProjectTempRoot); + } + + let version_root = canonical_temp_root.join("playable-stems-v1"); + validate_directory(&version_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + let canonical_version_root = version_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + if canonical_version_root != version_root || !canonical_version_root.starts_with(&canonical_temp_root) + { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + let artifact_set_root = canonical_version_root.join(artifact_set.artifact_set_id()); + validate_directory(&artifact_set_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + let canonical_artifact_set_root = artifact_set_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + if canonical_artifact_set_root != artifact_set_root + || canonical_artifact_set_root.parent() != Some(canonical_version_root.as_path()) + { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + validate_exact_artifact_members(&canonical_artifact_set_root)?; + + let expected_stems = PlaybackStemKind::canonical_order(); + let mut verified_files = Vec::with_capacity(expected_stems.len()); + for (artifact, expected_stem) in artifact_set + .stem_artifacts() + .iter() + .zip(expected_stems) + { + if artifact.stem_kind() != expected_stem { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + verified_files.push(preflight_artifact( + &canonical_artifact_set_root, + artifact, + artifact_set, + )?); + } + + if verified_files.len() != expected_stems.len() { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + Ok(PreflightPlayableStemSet { + artifact_set_id: artifact_set.artifact_set_id().to_string(), + files: verified_files, + }) +} + +fn validate_exact_artifact_members( + artifact_set_root: &Path, +) -> Result<(), PlayableStemAdmissionError> { + let actual_members = fs::read_dir(artifact_set_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)? + .map(|entry| { + entry + .map(|entry| entry.file_name()) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout) + }) + .collect::, _>>()?; + let expected_members = PlaybackStemKind::canonical_order() + .into_iter() + .map(|stem_kind| OsString::from(stem_kind.file_name())) + .collect::>(); + + if actual_members != expected_members { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + Ok(()) +} + +fn preflight_artifact( + artifact_set_root: &Path, + artifact: &PlayableStemArtifactReference, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result { + let native_path = artifact_set.derive_artifact_path( + artifact_set_root + .parent() + .and_then(Path::parent) + .ok_or(PlayableStemAdmissionError::InvalidArtifactSetLayout)?, + artifact.stem_kind(), + ); + let expected_path = artifact_set_root.join(artifact.stem_kind().file_name()); + if native_path != expected_path { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + let link_metadata = fs::symlink_metadata(&native_path) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if link_metadata.file_type().is_symlink() + || is_reparse_point(&link_metadata) + || !link_metadata.is_file() + { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + if link_metadata.len() != artifact.file_size_bytes() { + return Err(PlayableStemAdmissionError::FileSizeMismatch); + } + + let canonical_path = native_path + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if canonical_path != native_path || canonical_path.parent() != Some(artifact_set_root) { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + + let mut file = File::open(&canonical_path) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + let opened_metadata = file + .metadata() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if !opened_metadata.is_file() || opened_metadata.len() != artifact.file_size_bytes() { + return Err(PlayableStemAdmissionError::FileSizeMismatch); + } + + validate_wave_header(&mut file, artifact, artifact_set)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + let content_hash_sha256 = sha256_hex_reader(&mut file) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if content_hash_sha256 != artifact.content_hash_sha256() { + return Err(PlayableStemAdmissionError::ContentHashMismatch); + } + + let final_metadata = file + .metadata() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if !final_metadata.is_file() || final_metadata.len() != opened_metadata.len() { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + + Ok(PreflightPlayableStemFile { + stem_kind: artifact.stem_kind(), + native_path: canonical_path, + file_size_bytes: final_metadata.len(), + content_hash_sha256, + }) +} + +fn validate_wave_header( + file: &mut File, + artifact: &PlayableStemArtifactReference, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result<(), PlayableStemAdmissionError> { + let mut header = [0u8; CANONICAL_WAVE_HEADER_BYTES]; + file.seek(SeekFrom::Start(0)) + .and_then(|_| file.read_exact(&mut header)) + .map_err(|_| PlayableStemAdmissionError::WaveHeaderMismatch)?; + + let file_size = artifact.file_size_bytes(); + let expected_data_size = artifact_set + .sample_count() + .checked_mul(PCM16_BYTES_PER_SAMPLE) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + let expected_riff_size = file_size + .checked_sub(8) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + let expected_byte_rate = artifact_set + .sample_rate() + .checked_mul(PCM16_BYTES_PER_SAMPLE as u32) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + + if &header[0..4] != b"RIFF" + || read_u32_le(&header[4..8]) != Some(expected_riff_size as u32) + || &header[8..12] != b"WAVE" + || &header[12..16] != b"fmt " + || read_u32_le(&header[16..20]) != Some(16) + || read_u16_le(&header[20..22]) != Some(1) + || read_u16_le(&header[22..24]) != Some(1) + || read_u32_le(&header[24..28]) != Some(artifact_set.sample_rate()) + || read_u32_le(&header[28..32]) != Some(expected_byte_rate) + || read_u16_le(&header[32..34]) != Some(2) + || read_u16_le(&header[34..36]) != Some(16) + || &header[36..40] != b"data" + || read_u32_le(&header[40..44]) != Some(expected_data_size as u32) + || artifact.sample_rate() != artifact_set.sample_rate() + || artifact.channel_count() != 1 + || artifact.sample_count() != artifact_set.sample_count() + { + return Err(PlayableStemAdmissionError::WaveHeaderMismatch); + } + + Ok(()) +} + +fn read_u16_le(bytes: &[u8]) -> Option { + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_u32_le(bytes: &[u8]) -> Option { + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn validate_directory(path: &Path) -> Result<(), ()> { + let metadata = fs::symlink_metadata(path).map_err(|_| ())?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(()); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &Metadata) -> bool { + false +} + +const SHA256_BLOCK_BYTES: usize = 64; +const SHA256_DIGEST_BYTES: usize = 32; +const SHA256_INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const SHA256_ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, + 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, + 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, + 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, + 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, + 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, + 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, + 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, + 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, + 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, + 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, +]; + +#[derive(Clone)] +struct Sha256State { + state: [u32; 8], + buffer: [u8; SHA256_BLOCK_BYTES], + buffer_len: usize, + message_len_bytes: u64, +} + +impl Default for Sha256State { + fn default() -> Self { + Self { + state: SHA256_INITIAL_STATE, + buffer: [0; SHA256_BLOCK_BYTES], + buffer_len: 0, + message_len_bytes: 0, + } + } +} + +impl Sha256State { + fn update(&mut self, mut bytes: &[u8]) -> io::Result<()> { + self.message_len_bytes = self + .message_len_bytes + .checked_add(bytes.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input is too large"))?; + + if self.buffer_len != 0 { + let copied = (SHA256_BLOCK_BYTES - self.buffer_len).min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + copied] + .copy_from_slice(&bytes[..copied]); + self.buffer_len += copied; + bytes = &bytes[copied..]; + if self.buffer_len == SHA256_BLOCK_BYTES { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } + } + + while bytes.len() >= SHA256_BLOCK_BYTES { + let block: &[u8; SHA256_BLOCK_BYTES] = bytes[..SHA256_BLOCK_BYTES] + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid SHA-256 block"))?; + self.compress(block); + bytes = &bytes[SHA256_BLOCK_BYTES..]; + } + + if !bytes.is_empty() { + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + Ok(()) + } + + fn finalize(mut self) -> io::Result<[u8; SHA256_DIGEST_BYTES]> { + let message_len_bits = self + .message_len_bytes + .checked_mul(8) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input is too large"))?; + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; SHA256_BLOCK_BYTES]; + self.buffer_len = 0; + } + self.buffer[self.buffer_len..56].fill(0); + self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); + let final_block = self.buffer; + self.compress(&final_block); + + let mut digest = [0u8; SHA256_DIGEST_BYTES]; + for (index, word) in self.state.into_iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + Ok(digest) + } + + fn compress(&mut self, block: &[u8; SHA256_BLOCK_BYTES]) { + let mut schedule = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes( + chunk.try_into().expect("SHA-256 message word is four bytes"), + ); + } + for index in 16..64 { + let sigma0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let sigma1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for index in 0..64 { + let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(big_sigma1) + .wrapping_add(choose) + .wrapping_add(SHA256_ROUND_CONSTANTS[index]) + .wrapping_add(schedule[index]); + let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temp2 = big_sigma0.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + self.state[0] = self.state[0].wrapping_add(a); + self.state[1] = self.state[1].wrapping_add(b); + self.state[2] = self.state[2].wrapping_add(c); + self.state[3] = self.state[3].wrapping_add(d); + self.state[4] = self.state[4].wrapping_add(e); + self.state[5] = self.state[5].wrapping_add(f); + self.state[6] = self.state[6].wrapping_add(g); + self.state[7] = self.state[7].wrapping_add(h); + } +} + +fn sha256_hex_reader(mut reader: impl Read) -> io::Result { + let mut state = Sha256State::default(); + let mut chunk = [0u8; 64 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read_bytes) => state.update(&chunk[..read_bytes])?, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + + let digest = state.finalize()?; + let mut hex = String::with_capacity(SHA256_DIGEST_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + hex.push(HEX[(byte >> 4) as usize] as char); + hex.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(hex) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{io::Cursor, time::{SystemTime, UNIX_EPOCH}}; + + const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn unique_temp_root(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-stem-admission-{}-{label}-{unique}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test temp root should be created"); + root.canonicalize().expect("test temp root should canonicalize") + } + + fn pcm16_wave(sample_rate: u32, samples: &[i16]) -> Vec { + let data_size = u32::try_from(samples.len() * 2).expect("test PCM data should fit RIFF"); + let mut bytes = Vec::with_capacity(CANONICAL_WAVE_HEADER_BYTES + data_size as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_size).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_size.to_le_bytes()); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes + } + + fn artifact_set_for_bytes(sample_rate: u32, stem_bytes: &[Vec]) -> PlayableStemArtifactSetReference { + let sample_count = ((stem_bytes[0].len() - CANONICAL_WAVE_HEADER_BYTES) / 2) as u64; + let duration_seconds = sample_count as f64 / sample_rate as f64; + let artifacts = PlaybackStemKind::canonical_order() + .into_iter() + .zip(stem_bytes) + .map(|(stem_kind, bytes)| { + json!({ + "artifactId": stem_kind.artifact_id(), + "stemKind": match stem_kind { + PlaybackStemKind::Vocals => "vocals", + PlaybackStemKind::Bass => "bass", + PlaybackStemKind::Drums => "drums", + PlaybackStemKind::Other => "other", + }, + "fileSizeBytes": bytes.len(), + "contentHashSha256": sha256_hex_reader(Cursor::new(bytes)).expect("fixture should hash"), + "mediaType": "audio/wav", + "sampleRate": sample_rate, + "channelCount": 1, + "sampleCount": sample_count, + "durationSeconds": duration_seconds + }) + }) + .collect::>(); + + serde_json::from_value(json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": sample_rate, + "channelCount": 1, + "sampleCount": sample_count, + "durationSeconds": duration_seconds, + "appliedGain": 1.0, + "stemArtifacts": artifacts + })) + .expect("artifact-set fixture should satisfy the core contract") + } + + fn write_set(root: &Path, stem_bytes: &[Vec]) -> PathBuf { + let set_root = root.join("playable-stems-v1").join(ARTIFACT_SET_ID); + fs::create_dir_all(&set_root).expect("artifact-set fixture directory should be created"); + for (stem_kind, bytes) in PlaybackStemKind::canonical_order().into_iter().zip(stem_bytes) { + fs::write(set_root.join(stem_kind.file_name()), bytes) + .expect("artifact fixture should be written"); + } + set_root + } + + fn canonical_fixture() -> (PathBuf, PlayableStemArtifactSetReference, Vec>) { + let root = unique_temp_root("valid"); + let samples = [0i16, 1, -1, i16::MAX, i16::MIN, 120, -120, 7]; + let stem_bytes = (0..4) + .map(|_| pcm16_wave(8_000, &samples)) + .collect::>(); + let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); + write_set(&root, &stem_bytes); + (root, artifact_set, stem_bytes) + } + + #[test] + fn accepts_only_the_complete_canonical_pcm16_set() { + let (root, artifact_set, _) = canonical_fixture(); + let admitted = preflight_playable_stem_set(&root, &artifact_set) + .expect("complete canonical set should pass native preflight"); + + assert_eq!(admitted.artifact_set_id(), ARTIFACT_SET_ID); + assert_eq!( + admitted.files().iter().map(PreflightPlayableStemFile::stem_kind).collect::>(), + PlaybackStemKind::canonical_order() + ); + assert!(admitted.files().iter().all(|file| file.native_path().starts_with(&root))); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_same_size_content_mutation_by_complete_file_hash() { + let (root, artifact_set, _) = canonical_fixture(); + let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); + let mut mutated = fs::read(&vocals).expect("fixture should read"); + let last = mutated.len() - 1; + mutated[last] ^= 0x01; + fs::write(&vocals, mutated).expect("same-size mutation should write"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::ContentHashMismatch) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_noncanonical_wave_header_even_when_hash_metadata_matches() { + let root = unique_temp_root("header"); + let samples = [0i16; 8]; + let mut stem_bytes = (0..4) + .map(|_| pcm16_wave(8_000, &samples)) + .collect::>(); + stem_bytes[0][34] = 24; + let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); + write_set(&root, &stem_bytes); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::WaveHeaderMismatch) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_unexpected_artifact_set_members() { + let (root, artifact_set, _) = canonical_fixture(); + let unexpected = root + .join("playable-stems-v1") + .join(ARTIFACT_SET_ID) + .join("notes.txt"); + fs::write(unexpected, b"not media").expect("unexpected member should write"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::InvalidArtifactSetLayout) + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_stem_even_when_target_bytes_match() { + use std::os::unix::fs::symlink; + + let (root, artifact_set, stem_bytes) = canonical_fixture(); + let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); + let replacement = root.join("replacement.wav"); + fs::write(&replacement, &stem_bytes[0]).expect("replacement fixture should write"); + fs::remove_file(&vocals).expect("original vocals fixture should be removed"); + symlink(&replacement, &vocals).expect("symlink fixture should be created"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::InvalidArtifactFile) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn sha256_matches_nist_style_known_answer_vectors() { + for (message, expected) in [ + ( + &b""[..], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + &b"abc"[..], + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..], + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ] { + assert_eq!(sha256_hex_reader(Cursor::new(message)).as_deref(), Ok(expected)); + } + } +} From 652bedb33b03f77ccf3fb2598ca0054d3be503b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:17:31 +0900 Subject: [PATCH 020/160] refactor(stems): isolate streaming SHA-256 verifier --- .../src/playable_stem_admission/sha256.rs | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs diff --git a/apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs b/apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs new file mode 100644 index 000000000..82153de27 --- /dev/null +++ b/apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs @@ -0,0 +1,277 @@ +//! Streaming SHA-256 used only for local playable-stem integrity comparison. +//! +//! The operations and constants follow NIST FIPS 180-4 SHA-256. The known-answer +//! tests below are correctness checks, not CAVP validation or a FIPS 140 claim. + +use std::io::{self, Read}; + +const BLOCK_BYTES: usize = 64; +const DIGEST_BYTES: usize = 32; +const INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, + 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, + 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, + 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, + 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, + 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, + 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, + 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, + 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, + 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, + 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, +]; + +#[derive(Clone)] +struct Sha256State { + words: [u32; 8], + buffer: [u8; BLOCK_BYTES], + buffer_len: usize, + message_len_bytes: u64, +} + +impl Default for Sha256State { + fn default() -> Self { + Self { + words: INITIAL_STATE, + buffer: [0; BLOCK_BYTES], + buffer_len: 0, + message_len_bytes: 0, + } + } +} + +impl Sha256State { + fn update(&mut self, mut bytes: &[u8]) -> io::Result<()> { + self.message_len_bytes = self + .message_len_bytes + .checked_add(bytes.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input too large"))?; + + if self.buffer_len != 0 { + let copied = (BLOCK_BYTES - self.buffer_len).min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + copied] + .copy_from_slice(&bytes[..copied]); + self.buffer_len += copied; + bytes = &bytes[copied..]; + if self.buffer_len == BLOCK_BYTES { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } + } + + while bytes.len() >= BLOCK_BYTES { + let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES] + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid SHA-256 block"))?; + self.compress(block); + bytes = &bytes[BLOCK_BYTES..]; + } + + if !bytes.is_empty() { + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + Ok(()) + } + + fn finalize(mut self) -> io::Result<[u8; DIGEST_BYTES]> { + let message_len_bits = self + .message_len_bytes + .checked_mul(8) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input too large"))?; + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; BLOCK_BYTES]; + self.buffer_len = 0; + } + self.buffer[self.buffer_len..56].fill(0); + self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); + let block = self.buffer; + self.compress(&block); + + let mut digest = [0u8; DIGEST_BYTES]; + for (index, word) in self.words.into_iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + Ok(digest) + } + + fn compress(&mut self, block: &[u8; BLOCK_BYTES]) { + let mut schedule = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes( + chunk + .try_into() + .expect("SHA-256 message word always contains four bytes"), + ); + } + for index in 16..64 { + let small_sigma0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let small_sigma1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(small_sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(small_sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.words; + for index in 0..64 { + let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temporary1 = h + .wrapping_add(big_sigma1) + .wrapping_add(choose) + .wrapping_add(ROUND_CONSTANTS[index]) + .wrapping_add(schedule[index]); + let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temporary2 = big_sigma0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temporary1); + d = c; + c = b; + b = a; + a = temporary1.wrapping_add(temporary2); + } + + self.words[0] = self.words[0].wrapping_add(a); + self.words[1] = self.words[1].wrapping_add(b); + self.words[2] = self.words[2].wrapping_add(c); + self.words[3] = self.words[3].wrapping_add(d); + self.words[4] = self.words[4].wrapping_add(e); + self.words[5] = self.words[5].wrapping_add(f); + self.words[6] = self.words[6].wrapping_add(g); + self.words[7] = self.words[7].wrapping_add(h); + } +} + +pub(super) fn sha256_hex_reader(mut reader: impl Read) -> io::Result { + let mut state = Sha256State::default(); + let mut chunk = [0u8; 64 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read_bytes) => state.update(&chunk[..read_bytes])?, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + + let digest = state.finalize()?; + let mut encoded = String::with_capacity(DIGEST_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(encoded) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + struct InterruptedShortReader { + bytes: Vec, + cursor: usize, + interrupted: bool, + } + + impl Read for InterruptedShortReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(io::Error::from(io::ErrorKind::Interrupted)); + } + if self.cursor == self.bytes.len() { + return Ok(0); + } + let copied = 7.min(output.len()).min(self.bytes.len() - self.cursor); + output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]); + self.cursor += copied; + Ok(copied) + } + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _output: &mut [u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Other, "fixture read failure")) + } + } + + #[test] + fn matches_sha256_known_answer_vectors() { + for (message, expected) in [ + ( + &b""[..], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + &b"abc"[..], + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..], + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ] { + assert_eq!(sha256_hex_reader(Cursor::new(message)).as_deref(), Ok(expected)); + } + } + + #[test] + fn hashes_multiple_blocks_and_interrupted_short_reads_identically() { + let bytes = (0..131_111) + .map(|index| (index % 251) as u8) + .collect::>(); + let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference hash should succeed"); + let actual = sha256_hex_reader(InterruptedShortReader { + bytes, + cursor: 0, + interrupted: false, + }) + .expect("interrupted short reads should be retried"); + assert_eq!(actual, expected); + } + + #[test] + fn propagates_non_interrupted_reader_failure() { + let error = sha256_hex_reader(FailingReader).expect_err("reader failure must propagate"); + assert_eq!(error.kind(), io::ErrorKind::Other); + } + + #[test] + fn matches_the_million_a_vector() { + let bytes = vec![b'a'; 1_000_000]; + assert_eq!( + sha256_hex_reader(Cursor::new(bytes)).as_deref(), + Ok("cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0") + ); + } +} From 54d86e4b12649137aa4ffc1c06f708ec7c28f5ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:18:35 +0900 Subject: [PATCH 021/160] refactor(stems): separate actual-file admission domain service --- .../src/playable_stem_admission/mod.rs | 552 ++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 apps/desktop/src-tauri/src/playable_stem_admission/mod.rs diff --git a/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs b/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs new file mode 100644 index 000000000..54993ef75 --- /dev/null +++ b/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs @@ -0,0 +1,552 @@ +//! Native actual-file preflight for generated rehearsal stem WAVs. +//! +//! A path-free `PlayableStemArtifactSetReference` is metadata, not playback +//! authority. This module derives the only permitted paths from the native-owned +//! project temp root and validates the complete four-file set before the existing +//! Active Player authority may retain any of it. No path from this module is +//! serializable to the renderer. + +mod sha256; + +use bandscope_desktop_core::playable_stem_contract::{ + PlaybackStemKind, PlayableStemArtifactReference, PlayableStemArtifactSetReference, +}; +use sha256::sha256_hex_reader; +use std::{ + collections::BTreeSet, + ffi::OsString, + fs::{self, File, Metadata}, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, +}; + +const CANONICAL_WAVE_HEADER_BYTES: usize = 44; +const PCM16_BYTES_PER_SAMPLE: u64 = 2; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +/// Stable, payload-free reasons native stem preflight can fail. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlayableStemAdmissionError { + /// The project-owned temp root is absent, non-directory, or redirecting. + InvalidProjectTempRoot, + /// The version/set directory does not have the exact canonical shape. + InvalidArtifactSetLayout, + /// One expected artifact is absent, redirected, non-regular, or unreadable. + InvalidArtifactFile, + /// File length does not match the path-free artifact contract. + FileSizeMismatch, + /// The file is not canonical mono PCM16 RIFF/WAVE matching the metadata. + WaveHeaderMismatch, + /// Complete-file SHA-256 differs from the path-free contract. + ContentHashMismatch, +} + +impl std::fmt::Display for PlayableStemAdmissionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::InvalidProjectTempRoot => "playable stem project temp root is invalid", + Self::InvalidArtifactSetLayout => "playable stem artifact-set layout is invalid", + Self::InvalidArtifactFile => "playable stem artifact file is invalid", + Self::FileSizeMismatch => "playable stem artifact size does not match metadata", + Self::WaveHeaderMismatch => "playable stem WAV header does not match metadata", + Self::ContentHashMismatch => "playable stem content hash does not match metadata", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for PlayableStemAdmissionError {} + +/// One actual file that passed native path/layout/byte/header/hash preflight. +/// +/// The type intentionally does not implement `Serialize`; the canonical path is +/// trusted-process state, not a renderer contract or playback handle. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightPlayableStemFile { + stem_kind: PlaybackStemKind, + native_path: PathBuf, + file_size_bytes: u64, + content_hash_sha256: String, +} + +impl PreflightPlayableStemFile { + /// Return which canonical stem this file represents. + pub const fn stem_kind(&self) -> PlaybackStemKind { + self.stem_kind + } + + /// Return the canonical native-only path for later authority binding. + pub fn native_path(&self) -> &Path { + &self.native_path + } + + /// Return the byte length checked on the opened file. + pub const fn file_size_bytes(&self) -> u64 { + self.file_size_bytes + } + + /// Return the SHA-256 recomputed across the complete opened file. + pub fn content_hash_sha256(&self) -> &str { + &self.content_hash_sha256 + } +} + +/// Complete preflight result. Partial stem sets are never returned. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightPlayableStemSet { + artifact_set_id: String, + files: Vec, +} + +impl PreflightPlayableStemSet { + /// Return the path-free artifact-set identity verified on disk. + pub fn artifact_set_id(&self) -> &str { + &self.artifact_set_id + } + + /// Return all four files in canonical vocals/bass/drums/other order. + pub fn files(&self) -> &[PreflightPlayableStemFile] { + &self.files + } +} + +/// Verify actual generated stem bytes without granting playback authority. +/// +/// The caller must still bind the returned files to the current project using +/// #971's revocable native file-identity/serving boundary before exposing an +/// opaque source handle. +pub fn preflight_playable_stem_set( + project_temp_root: &Path, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result { + if !project_temp_root.is_absolute() { + return Err(PlayableStemAdmissionError::InvalidProjectTempRoot); + } + validate_directory(project_temp_root) + .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; + let canonical_temp_root = project_temp_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; + + let version_root = canonical_temp_root.join("playable-stems-v1"); + validate_directory(&version_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + let canonical_version_root = version_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + if canonical_version_root.parent() != Some(canonical_temp_root.as_path()) { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + let artifact_set_root = canonical_version_root.join(artifact_set.artifact_set_id()); + validate_directory(&artifact_set_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + let canonical_artifact_set_root = artifact_set_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + if canonical_artifact_set_root.parent() != Some(canonical_version_root.as_path()) { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + validate_exact_artifact_members(&canonical_artifact_set_root)?; + + let expected_stems = PlaybackStemKind::canonical_order(); + let mut verified_files = Vec::with_capacity(expected_stems.len()); + for (artifact, expected_stem) in artifact_set + .stem_artifacts() + .iter() + .zip(expected_stems) + { + if artifact.stem_kind() != expected_stem { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + verified_files.push(preflight_artifact( + &canonical_temp_root, + &canonical_artifact_set_root, + artifact, + artifact_set, + )?); + } + + if verified_files.len() != expected_stems.len() { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + Ok(PreflightPlayableStemSet { + artifact_set_id: artifact_set.artifact_set_id().to_string(), + files: verified_files, + }) +} + +fn validate_exact_artifact_members( + artifact_set_root: &Path, +) -> Result<(), PlayableStemAdmissionError> { + let actual_members = fs::read_dir(artifact_set_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)? + .map(|entry| { + entry + .map(|entry| entry.file_name()) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout) + }) + .collect::, _>>()?; + let expected_members = PlaybackStemKind::canonical_order() + .into_iter() + .map(|stem_kind| OsString::from(stem_kind.file_name())) + .collect::>(); + + if actual_members != expected_members { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + Ok(()) +} + +fn preflight_artifact( + canonical_temp_root: &Path, + artifact_set_root: &Path, + artifact: &PlayableStemArtifactReference, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result { + let native_path = artifact_set.derive_artifact_path(canonical_temp_root, artifact.stem_kind()); + let expected_path = artifact_set_root.join(artifact.stem_kind().file_name()); + if native_path != expected_path { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + let link_metadata = fs::symlink_metadata(&native_path) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if link_metadata.file_type().is_symlink() + || is_reparse_point(&link_metadata) + || !link_metadata.is_file() + { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + if link_metadata.len() != artifact.file_size_bytes() { + return Err(PlayableStemAdmissionError::FileSizeMismatch); + } + + let canonical_path = native_path + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if canonical_path.parent() != Some(artifact_set_root) + || canonical_path.file_name() != Some(OsString::from(artifact.stem_kind().file_name()).as_os_str()) + { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + + let mut file = File::open(&canonical_path) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + let opened_metadata = file + .metadata() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if !opened_metadata.is_file() || opened_metadata.len() != artifact.file_size_bytes() { + return Err(PlayableStemAdmissionError::FileSizeMismatch); + } + + validate_wave_header(&mut file, artifact, artifact_set)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + let content_hash_sha256 = sha256_hex_reader(&mut file) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if content_hash_sha256 != artifact.content_hash_sha256() { + return Err(PlayableStemAdmissionError::ContentHashMismatch); + } + + let final_metadata = file + .metadata() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if !final_metadata.is_file() || final_metadata.len() != opened_metadata.len() { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + + Ok(PreflightPlayableStemFile { + stem_kind: artifact.stem_kind(), + native_path: canonical_path, + file_size_bytes: final_metadata.len(), + content_hash_sha256, + }) +} + +fn validate_wave_header( + file: &mut File, + artifact: &PlayableStemArtifactReference, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result<(), PlayableStemAdmissionError> { + let mut header = [0u8; CANONICAL_WAVE_HEADER_BYTES]; + file.seek(SeekFrom::Start(0)) + .and_then(|_| file.read_exact(&mut header)) + .map_err(|_| PlayableStemAdmissionError::WaveHeaderMismatch)?; + + let expected_data_size = artifact_set + .sample_count() + .checked_mul(PCM16_BYTES_PER_SAMPLE) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + let expected_riff_size = artifact + .file_size_bytes() + .checked_sub(8) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + let expected_byte_rate = artifact_set + .sample_rate() + .checked_mul(PCM16_BYTES_PER_SAMPLE as u32) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + + if &header[0..4] != b"RIFF" + || read_u32_le(&header[4..8]) != Some(expected_riff_size) + || &header[8..12] != b"WAVE" + || &header[12..16] != b"fmt " + || read_u32_le(&header[16..20]) != Some(16) + || read_u16_le(&header[20..22]) != Some(1) + || read_u16_le(&header[22..24]) != Some(1) + || read_u32_le(&header[24..28]) != Some(artifact_set.sample_rate()) + || read_u32_le(&header[28..32]) != Some(expected_byte_rate) + || read_u16_le(&header[32..34]) != Some(2) + || read_u16_le(&header[34..36]) != Some(16) + || &header[36..40] != b"data" + || read_u32_le(&header[40..44]) != Some(expected_data_size) + || artifact.sample_rate() != artifact_set.sample_rate() + || artifact.channel_count() != 1 + || artifact.sample_count() != artifact_set.sample_count() + { + return Err(PlayableStemAdmissionError::WaveHeaderMismatch); + } + Ok(()) +} + +fn read_u16_le(bytes: &[u8]) -> Option { + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_u32_le(bytes: &[u8]) -> Option { + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn validate_directory(path: &Path) -> Result<(), ()> { + let metadata = fs::symlink_metadata(path).map_err(|_| ())?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(()); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &Metadata) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + io::Cursor, + time::{SystemTime, UNIX_EPOCH}, + }; + + const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn unique_temp_root(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-stem-admission-{}-{label}-{unique}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test temp root should be created"); + root.canonicalize().expect("test temp root should canonicalize") + } + + fn pcm16_wave(sample_rate: u32, samples: &[i16]) -> Vec { + let data_size = u32::try_from(samples.len() * 2).expect("test PCM data should fit RIFF"); + let mut bytes = Vec::with_capacity(CANONICAL_WAVE_HEADER_BYTES + data_size as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_size).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_size.to_le_bytes()); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes + } + + fn artifact_set_for_bytes( + sample_rate: u32, + stem_bytes: &[Vec], + ) -> PlayableStemArtifactSetReference { + let sample_count = ((stem_bytes[0].len() - CANONICAL_WAVE_HEADER_BYTES) / 2) as u64; + let duration_seconds = sample_count as f64 / sample_rate as f64; + let artifacts = PlaybackStemKind::canonical_order() + .into_iter() + .zip(stem_bytes) + .map(|(stem_kind, bytes)| { + json!({ + "artifactId": stem_kind.artifact_id(), + "stemKind": match stem_kind { + PlaybackStemKind::Vocals => "vocals", + PlaybackStemKind::Bass => "bass", + PlaybackStemKind::Drums => "drums", + PlaybackStemKind::Other => "other", + }, + "fileSizeBytes": bytes.len(), + "contentHashSha256": sha256_hex_reader(Cursor::new(bytes)).expect("fixture should hash"), + "mediaType": "audio/wav", + "sampleRate": sample_rate, + "channelCount": 1, + "sampleCount": sample_count, + "durationSeconds": duration_seconds + }) + }) + .collect::>(); + + serde_json::from_value(json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": sample_rate, + "channelCount": 1, + "sampleCount": sample_count, + "durationSeconds": duration_seconds, + "appliedGain": 1.0, + "stemArtifacts": artifacts + })) + .expect("artifact-set fixture should satisfy the core contract") + } + + fn write_set(root: &Path, stem_bytes: &[Vec]) -> PathBuf { + let set_root = root.join("playable-stems-v1").join(ARTIFACT_SET_ID); + fs::create_dir_all(&set_root).expect("artifact-set fixture directory should be created"); + for (stem_kind, bytes) in PlaybackStemKind::canonical_order().into_iter().zip(stem_bytes) { + fs::write(set_root.join(stem_kind.file_name()), bytes) + .expect("artifact fixture should be written"); + } + set_root + } + + fn canonical_fixture() -> (PathBuf, PlayableStemArtifactSetReference, Vec>) { + let root = unique_temp_root("valid"); + let samples = [0i16, 1, -1, i16::MAX, i16::MIN, 120, -120, 7]; + let stem_bytes = (0..4) + .map(|_| pcm16_wave(8_000, &samples)) + .collect::>(); + let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); + write_set(&root, &stem_bytes); + (root, artifact_set, stem_bytes) + } + + #[test] + fn accepts_only_the_complete_canonical_pcm16_set() { + let (root, artifact_set, _) = canonical_fixture(); + let preflight = preflight_playable_stem_set(&root, &artifact_set) + .expect("complete canonical set should pass native preflight"); + + assert_eq!(preflight.artifact_set_id(), ARTIFACT_SET_ID); + assert_eq!( + preflight + .files() + .iter() + .map(PreflightPlayableStemFile::stem_kind) + .collect::>(), + PlaybackStemKind::canonical_order() + ); + assert!(preflight + .files() + .iter() + .all(|file| file.native_path().starts_with(&root))); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_relative_project_temp_root() { + let (_, artifact_set, _) = canonical_fixture(); + assert_eq!( + preflight_playable_stem_set(Path::new("relative-project-temp"), &artifact_set), + Err(PlayableStemAdmissionError::InvalidProjectTempRoot) + ); + } + + #[test] + fn rejects_same_size_content_mutation_by_complete_file_hash() { + let (root, artifact_set, _) = canonical_fixture(); + let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); + let mut mutated = fs::read(&vocals).expect("fixture should read"); + let last = mutated.len() - 1; + mutated[last] ^= 0x01; + fs::write(&vocals, mutated).expect("same-size mutation should write"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::ContentHashMismatch) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_noncanonical_wave_header_even_when_hash_metadata_matches() { + let root = unique_temp_root("header"); + let samples = [0i16; 8]; + let mut stem_bytes = (0..4) + .map(|_| pcm16_wave(8_000, &samples)) + .collect::>(); + stem_bytes[0][34] = 24; + let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); + write_set(&root, &stem_bytes); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::WaveHeaderMismatch) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_unexpected_artifact_set_members() { + let (root, artifact_set, _) = canonical_fixture(); + let unexpected = root + .join("playable-stems-v1") + .join(ARTIFACT_SET_ID) + .join("notes.txt"); + fs::write(unexpected, b"not media").expect("unexpected member should write"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::InvalidArtifactSetLayout) + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_stem_even_when_target_bytes_match() { + use std::os::unix::fs::symlink; + + let (root, artifact_set, stem_bytes) = canonical_fixture(); + let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); + let replacement = root.join("replacement.wav"); + fs::write(&replacement, &stem_bytes[0]).expect("replacement fixture should write"); + fs::remove_file(&vocals).expect("original vocals fixture should be removed"); + symlink(&replacement, &vocals).expect("symlink fixture should be created"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::InvalidArtifactFile) + ); + let _ = fs::remove_dir_all(root); + } +} From f2b73fedbc8fb1cd9a520998886c8893282b521e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:18:41 +0900 Subject: [PATCH 022/160] refactor(stems): switch admission module to owned directory layout --- .../src-tauri/src/playable_stem_admission.rs | 732 ------------------ 1 file changed, 732 deletions(-) delete mode 100644 apps/desktop/src-tauri/src/playable_stem_admission.rs diff --git a/apps/desktop/src-tauri/src/playable_stem_admission.rs b/apps/desktop/src-tauri/src/playable_stem_admission.rs deleted file mode 100644 index e24b27e7b..000000000 --- a/apps/desktop/src-tauri/src/playable_stem_admission.rs +++ /dev/null @@ -1,732 +0,0 @@ -//! Native actual-file preflight for generated rehearsal stem WAVs. -//! -//! A path-free `PlayableStemArtifactSetReference` is metadata, not playback -//! authority. This module derives the only permitted paths from the native-owned -//! project temp root and verifies the complete four-file set before a later -//! playback-authority step may retain any of it. No path or file handle from this -//! module is serializable to the renderer. - -use bandscope_desktop_core::playable_stem_contract::{ - PlaybackStemKind, PlayableStemArtifactReference, PlayableStemArtifactSetReference, -}; -use std::{ - collections::BTreeSet, - ffi::OsString, - fs::{self, File, Metadata}, - io::{self, Read, Seek, SeekFrom}, - path::{Path, PathBuf}, -}; - -const CANONICAL_WAVE_HEADER_BYTES: usize = 44; -const PCM16_BYTES_PER_SAMPLE: u64 = 2; -const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; - -/// Stable, payload-free reasons native stem admission can fail. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PlayableStemAdmissionError { - /// The project-owned temp root is absent, not a directory, or redirecting. - InvalidProjectTempRoot, - /// The version/set directory does not have the exact canonical shape. - InvalidArtifactSetLayout, - /// One expected artifact is absent, redirected, non-regular, or unreadable. - InvalidArtifactFile, - /// File length does not match the path-free artifact contract. - FileSizeMismatch, - /// The file is not the canonical mono PCM16 RIFF/WAVE described by metadata. - WaveHeaderMismatch, - /// Complete-file SHA-256 differs from the path-free contract. - ContentHashMismatch, -} - -impl std::fmt::Display for PlayableStemAdmissionError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let message = match self { - Self::InvalidProjectTempRoot => "playable stem project temp root is invalid", - Self::InvalidArtifactSetLayout => "playable stem artifact-set layout is invalid", - Self::InvalidArtifactFile => "playable stem artifact file is invalid", - Self::FileSizeMismatch => "playable stem artifact size does not match metadata", - Self::WaveHeaderMismatch => "playable stem WAV header does not match metadata", - Self::ContentHashMismatch => "playable stem content hash does not match metadata", - }; - formatter.write_str(message) - } -} - -impl std::error::Error for PlayableStemAdmissionError {} - -/// One actual file that passed native path/layout/byte/header/hash preflight. -/// -/// This value intentionally does not implement `Serialize`; its path remains a -/// native-process detail and cannot become renderer authority by accident. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PreflightPlayableStemFile { - stem_kind: PlaybackStemKind, - native_path: PathBuf, - file_size_bytes: u64, - content_hash_sha256: String, -} - -impl PreflightPlayableStemFile { - /// Return which canonical stem this actual file represents. - pub const fn stem_kind(&self) -> PlaybackStemKind { - self.stem_kind - } - - /// Return the native-only canonical file path for later authority admission. - pub fn native_path(&self) -> &Path { - &self.native_path - } - - /// Return the byte length verified on the opened file. - pub const fn file_size_bytes(&self) -> u64 { - self.file_size_bytes - } - - /// Return the SHA-256 digest recomputed over the complete opened file. - pub fn content_hash_sha256(&self) -> &str { - &self.content_hash_sha256 - } -} - -/// Complete actual-file preflight result. Partial stem sets are never returned. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PreflightPlayableStemSet { - artifact_set_id: String, - files: Vec, -} - -impl PreflightPlayableStemSet { - /// Return the path-free artifact-set identity that was verified on disk. - pub fn artifact_set_id(&self) -> &str { - &self.artifact_set_id - } - - /// Return all four verified files in canonical vocals/bass/drums/other order. - pub fn files(&self) -> &[PreflightPlayableStemFile] { - &self.files - } -} - -/// Verify the complete generated stem set under a native-owned project temp root. -/// -/// This function grants no playback authority. Callers must still bind the -/// returned files to the current project using #971's revocable native file -/// identity/serving boundary before exposing any opaque renderer handle. -pub fn preflight_playable_stem_set( - project_temp_root: &Path, - artifact_set: &PlayableStemArtifactSetReference, -) -> Result { - validate_directory(project_temp_root) - .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; - let canonical_temp_root = project_temp_root - .canonicalize() - .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; - if canonical_temp_root != project_temp_root { - return Err(PlayableStemAdmissionError::InvalidProjectTempRoot); - } - - let version_root = canonical_temp_root.join("playable-stems-v1"); - validate_directory(&version_root) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; - let canonical_version_root = version_root - .canonicalize() - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; - if canonical_version_root != version_root || !canonical_version_root.starts_with(&canonical_temp_root) - { - return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); - } - - let artifact_set_root = canonical_version_root.join(artifact_set.artifact_set_id()); - validate_directory(&artifact_set_root) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; - let canonical_artifact_set_root = artifact_set_root - .canonicalize() - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; - if canonical_artifact_set_root != artifact_set_root - || canonical_artifact_set_root.parent() != Some(canonical_version_root.as_path()) - { - return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); - } - - validate_exact_artifact_members(&canonical_artifact_set_root)?; - - let expected_stems = PlaybackStemKind::canonical_order(); - let mut verified_files = Vec::with_capacity(expected_stems.len()); - for (artifact, expected_stem) in artifact_set - .stem_artifacts() - .iter() - .zip(expected_stems) - { - if artifact.stem_kind() != expected_stem { - return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); - } - verified_files.push(preflight_artifact( - &canonical_artifact_set_root, - artifact, - artifact_set, - )?); - } - - if verified_files.len() != expected_stems.len() { - return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); - } - - Ok(PreflightPlayableStemSet { - artifact_set_id: artifact_set.artifact_set_id().to_string(), - files: verified_files, - }) -} - -fn validate_exact_artifact_members( - artifact_set_root: &Path, -) -> Result<(), PlayableStemAdmissionError> { - let actual_members = fs::read_dir(artifact_set_root) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)? - .map(|entry| { - entry - .map(|entry| entry.file_name()) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout) - }) - .collect::, _>>()?; - let expected_members = PlaybackStemKind::canonical_order() - .into_iter() - .map(|stem_kind| OsString::from(stem_kind.file_name())) - .collect::>(); - - if actual_members != expected_members { - return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); - } - Ok(()) -} - -fn preflight_artifact( - artifact_set_root: &Path, - artifact: &PlayableStemArtifactReference, - artifact_set: &PlayableStemArtifactSetReference, -) -> Result { - let native_path = artifact_set.derive_artifact_path( - artifact_set_root - .parent() - .and_then(Path::parent) - .ok_or(PlayableStemAdmissionError::InvalidArtifactSetLayout)?, - artifact.stem_kind(), - ); - let expected_path = artifact_set_root.join(artifact.stem_kind().file_name()); - if native_path != expected_path { - return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); - } - - let link_metadata = fs::symlink_metadata(&native_path) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - if link_metadata.file_type().is_symlink() - || is_reparse_point(&link_metadata) - || !link_metadata.is_file() - { - return Err(PlayableStemAdmissionError::InvalidArtifactFile); - } - if link_metadata.len() != artifact.file_size_bytes() { - return Err(PlayableStemAdmissionError::FileSizeMismatch); - } - - let canonical_path = native_path - .canonicalize() - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - if canonical_path != native_path || canonical_path.parent() != Some(artifact_set_root) { - return Err(PlayableStemAdmissionError::InvalidArtifactFile); - } - - let mut file = File::open(&canonical_path) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - let opened_metadata = file - .metadata() - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - if !opened_metadata.is_file() || opened_metadata.len() != artifact.file_size_bytes() { - return Err(PlayableStemAdmissionError::FileSizeMismatch); - } - - validate_wave_header(&mut file, artifact, artifact_set)?; - file.seek(SeekFrom::Start(0)) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - let content_hash_sha256 = sha256_hex_reader(&mut file) - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - if content_hash_sha256 != artifact.content_hash_sha256() { - return Err(PlayableStemAdmissionError::ContentHashMismatch); - } - - let final_metadata = file - .metadata() - .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; - if !final_metadata.is_file() || final_metadata.len() != opened_metadata.len() { - return Err(PlayableStemAdmissionError::InvalidArtifactFile); - } - - Ok(PreflightPlayableStemFile { - stem_kind: artifact.stem_kind(), - native_path: canonical_path, - file_size_bytes: final_metadata.len(), - content_hash_sha256, - }) -} - -fn validate_wave_header( - file: &mut File, - artifact: &PlayableStemArtifactReference, - artifact_set: &PlayableStemArtifactSetReference, -) -> Result<(), PlayableStemAdmissionError> { - let mut header = [0u8; CANONICAL_WAVE_HEADER_BYTES]; - file.seek(SeekFrom::Start(0)) - .and_then(|_| file.read_exact(&mut header)) - .map_err(|_| PlayableStemAdmissionError::WaveHeaderMismatch)?; - - let file_size = artifact.file_size_bytes(); - let expected_data_size = artifact_set - .sample_count() - .checked_mul(PCM16_BYTES_PER_SAMPLE) - .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; - let expected_riff_size = file_size - .checked_sub(8) - .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; - let expected_byte_rate = artifact_set - .sample_rate() - .checked_mul(PCM16_BYTES_PER_SAMPLE as u32) - .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; - - if &header[0..4] != b"RIFF" - || read_u32_le(&header[4..8]) != Some(expected_riff_size as u32) - || &header[8..12] != b"WAVE" - || &header[12..16] != b"fmt " - || read_u32_le(&header[16..20]) != Some(16) - || read_u16_le(&header[20..22]) != Some(1) - || read_u16_le(&header[22..24]) != Some(1) - || read_u32_le(&header[24..28]) != Some(artifact_set.sample_rate()) - || read_u32_le(&header[28..32]) != Some(expected_byte_rate) - || read_u16_le(&header[32..34]) != Some(2) - || read_u16_le(&header[34..36]) != Some(16) - || &header[36..40] != b"data" - || read_u32_le(&header[40..44]) != Some(expected_data_size as u32) - || artifact.sample_rate() != artifact_set.sample_rate() - || artifact.channel_count() != 1 - || artifact.sample_count() != artifact_set.sample_count() - { - return Err(PlayableStemAdmissionError::WaveHeaderMismatch); - } - - Ok(()) -} - -fn read_u16_le(bytes: &[u8]) -> Option { - Some(u16::from_le_bytes(bytes.try_into().ok()?)) -} - -fn read_u32_le(bytes: &[u8]) -> Option { - Some(u32::from_le_bytes(bytes.try_into().ok()?)) -} - -fn validate_directory(path: &Path) -> Result<(), ()> { - let metadata = fs::symlink_metadata(path).map_err(|_| ())?; - if metadata.file_type().is_symlink() || is_reparse_point(&metadata) || !metadata.is_dir() { - return Err(()); - } - Ok(()) -} - -#[cfg(windows)] -fn is_reparse_point(metadata: &Metadata) -> bool { - use std::os::windows::fs::MetadataExt; - metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 -} - -#[cfg(not(windows))] -fn is_reparse_point(_metadata: &Metadata) -> bool { - false -} - -const SHA256_BLOCK_BYTES: usize = 64; -const SHA256_DIGEST_BYTES: usize = 32; -const SHA256_INITIAL_STATE: [u32; 8] = [ - 0x6a09_e667, - 0xbb67_ae85, - 0x3c6e_f372, - 0xa54f_f53a, - 0x510e_527f, - 0x9b05_688c, - 0x1f83_d9ab, - 0x5be0_cd19, -]; -const SHA256_ROUND_CONSTANTS: [u32; 64] = [ - 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, - 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, - 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, - 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, - 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, - 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, - 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, - 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, - 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, - 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, - 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, -]; - -#[derive(Clone)] -struct Sha256State { - state: [u32; 8], - buffer: [u8; SHA256_BLOCK_BYTES], - buffer_len: usize, - message_len_bytes: u64, -} - -impl Default for Sha256State { - fn default() -> Self { - Self { - state: SHA256_INITIAL_STATE, - buffer: [0; SHA256_BLOCK_BYTES], - buffer_len: 0, - message_len_bytes: 0, - } - } -} - -impl Sha256State { - fn update(&mut self, mut bytes: &[u8]) -> io::Result<()> { - self.message_len_bytes = self - .message_len_bytes - .checked_add(bytes.len() as u64) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input is too large"))?; - - if self.buffer_len != 0 { - let copied = (SHA256_BLOCK_BYTES - self.buffer_len).min(bytes.len()); - self.buffer[self.buffer_len..self.buffer_len + copied] - .copy_from_slice(&bytes[..copied]); - self.buffer_len += copied; - bytes = &bytes[copied..]; - if self.buffer_len == SHA256_BLOCK_BYTES { - let block = self.buffer; - self.compress(&block); - self.buffer_len = 0; - } - } - - while bytes.len() >= SHA256_BLOCK_BYTES { - let block: &[u8; SHA256_BLOCK_BYTES] = bytes[..SHA256_BLOCK_BYTES] - .try_into() - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid SHA-256 block"))?; - self.compress(block); - bytes = &bytes[SHA256_BLOCK_BYTES..]; - } - - if !bytes.is_empty() { - self.buffer[..bytes.len()].copy_from_slice(bytes); - self.buffer_len = bytes.len(); - } - Ok(()) - } - - fn finalize(mut self) -> io::Result<[u8; SHA256_DIGEST_BYTES]> { - let message_len_bits = self - .message_len_bytes - .checked_mul(8) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input is too large"))?; - - self.buffer[self.buffer_len] = 0x80; - self.buffer_len += 1; - if self.buffer_len > 56 { - self.buffer[self.buffer_len..].fill(0); - let block = self.buffer; - self.compress(&block); - self.buffer = [0; SHA256_BLOCK_BYTES]; - self.buffer_len = 0; - } - self.buffer[self.buffer_len..56].fill(0); - self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); - let final_block = self.buffer; - self.compress(&final_block); - - let mut digest = [0u8; SHA256_DIGEST_BYTES]; - for (index, word) in self.state.into_iter().enumerate() { - digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); - } - Ok(digest) - } - - fn compress(&mut self, block: &[u8; SHA256_BLOCK_BYTES]) { - let mut schedule = [0u32; 64]; - for (index, chunk) in block.chunks_exact(4).enumerate() { - schedule[index] = u32::from_be_bytes( - chunk.try_into().expect("SHA-256 message word is four bytes"), - ); - } - for index in 16..64 { - let sigma0 = schedule[index - 15].rotate_right(7) - ^ schedule[index - 15].rotate_right(18) - ^ (schedule[index - 15] >> 3); - let sigma1 = schedule[index - 2].rotate_right(17) - ^ schedule[index - 2].rotate_right(19) - ^ (schedule[index - 2] >> 10); - schedule[index] = schedule[index - 16] - .wrapping_add(sigma0) - .wrapping_add(schedule[index - 7]) - .wrapping_add(sigma1); - } - - let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; - for index in 0..64 { - let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); - let choose = (e & f) ^ ((!e) & g); - let temp1 = h - .wrapping_add(big_sigma1) - .wrapping_add(choose) - .wrapping_add(SHA256_ROUND_CONSTANTS[index]) - .wrapping_add(schedule[index]); - let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); - let majority = (a & b) ^ (a & c) ^ (b & c); - let temp2 = big_sigma0.wrapping_add(majority); - h = g; - g = f; - f = e; - e = d.wrapping_add(temp1); - d = c; - c = b; - b = a; - a = temp1.wrapping_add(temp2); - } - - self.state[0] = self.state[0].wrapping_add(a); - self.state[1] = self.state[1].wrapping_add(b); - self.state[2] = self.state[2].wrapping_add(c); - self.state[3] = self.state[3].wrapping_add(d); - self.state[4] = self.state[4].wrapping_add(e); - self.state[5] = self.state[5].wrapping_add(f); - self.state[6] = self.state[6].wrapping_add(g); - self.state[7] = self.state[7].wrapping_add(h); - } -} - -fn sha256_hex_reader(mut reader: impl Read) -> io::Result { - let mut state = Sha256State::default(); - let mut chunk = [0u8; 64 * 1024]; - loop { - match reader.read(&mut chunk) { - Ok(0) => break, - Ok(read_bytes) => state.update(&chunk[..read_bytes])?, - Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, - Err(error) => return Err(error), - } - } - - let digest = state.finalize()?; - let mut hex = String::with_capacity(SHA256_DIGEST_BYTES * 2); - const HEX: &[u8; 16] = b"0123456789abcdef"; - for byte in digest { - hex.push(HEX[(byte >> 4) as usize] as char); - hex.push(HEX[(byte & 0x0f) as usize] as char); - } - Ok(hex) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::{io::Cursor, time::{SystemTime, UNIX_EPOCH}}; - - const ARTIFACT_SET_ID: &str = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn unique_temp_root(label: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("test clock should be after the Unix epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "bandscope-stem-admission-{}-{label}-{unique}", - std::process::id() - )); - fs::create_dir_all(&root).expect("test temp root should be created"); - root.canonicalize().expect("test temp root should canonicalize") - } - - fn pcm16_wave(sample_rate: u32, samples: &[i16]) -> Vec { - let data_size = u32::try_from(samples.len() * 2).expect("test PCM data should fit RIFF"); - let mut bytes = Vec::with_capacity(CANONICAL_WAVE_HEADER_BYTES + data_size as usize); - bytes.extend_from_slice(b"RIFF"); - bytes.extend_from_slice(&(36 + data_size).to_le_bytes()); - bytes.extend_from_slice(b"WAVEfmt "); - bytes.extend_from_slice(&16u32.to_le_bytes()); - bytes.extend_from_slice(&1u16.to_le_bytes()); - bytes.extend_from_slice(&1u16.to_le_bytes()); - bytes.extend_from_slice(&sample_rate.to_le_bytes()); - bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); - bytes.extend_from_slice(&2u16.to_le_bytes()); - bytes.extend_from_slice(&16u16.to_le_bytes()); - bytes.extend_from_slice(b"data"); - bytes.extend_from_slice(&data_size.to_le_bytes()); - for sample in samples { - bytes.extend_from_slice(&sample.to_le_bytes()); - } - bytes - } - - fn artifact_set_for_bytes(sample_rate: u32, stem_bytes: &[Vec]) -> PlayableStemArtifactSetReference { - let sample_count = ((stem_bytes[0].len() - CANONICAL_WAVE_HEADER_BYTES) / 2) as u64; - let duration_seconds = sample_count as f64 / sample_rate as f64; - let artifacts = PlaybackStemKind::canonical_order() - .into_iter() - .zip(stem_bytes) - .map(|(stem_kind, bytes)| { - json!({ - "artifactId": stem_kind.artifact_id(), - "stemKind": match stem_kind { - PlaybackStemKind::Vocals => "vocals", - PlaybackStemKind::Bass => "bass", - PlaybackStemKind::Drums => "drums", - PlaybackStemKind::Other => "other", - }, - "fileSizeBytes": bytes.len(), - "contentHashSha256": sha256_hex_reader(Cursor::new(bytes)).expect("fixture should hash"), - "mediaType": "audio/wav", - "sampleRate": sample_rate, - "channelCount": 1, - "sampleCount": sample_count, - "durationSeconds": duration_seconds - }) - }) - .collect::>(); - - serde_json::from_value(json!({ - "artifactSetId": ARTIFACT_SET_ID, - "formatVersion": 1, - "sampleRate": sample_rate, - "channelCount": 1, - "sampleCount": sample_count, - "durationSeconds": duration_seconds, - "appliedGain": 1.0, - "stemArtifacts": artifacts - })) - .expect("artifact-set fixture should satisfy the core contract") - } - - fn write_set(root: &Path, stem_bytes: &[Vec]) -> PathBuf { - let set_root = root.join("playable-stems-v1").join(ARTIFACT_SET_ID); - fs::create_dir_all(&set_root).expect("artifact-set fixture directory should be created"); - for (stem_kind, bytes) in PlaybackStemKind::canonical_order().into_iter().zip(stem_bytes) { - fs::write(set_root.join(stem_kind.file_name()), bytes) - .expect("artifact fixture should be written"); - } - set_root - } - - fn canonical_fixture() -> (PathBuf, PlayableStemArtifactSetReference, Vec>) { - let root = unique_temp_root("valid"); - let samples = [0i16, 1, -1, i16::MAX, i16::MIN, 120, -120, 7]; - let stem_bytes = (0..4) - .map(|_| pcm16_wave(8_000, &samples)) - .collect::>(); - let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); - write_set(&root, &stem_bytes); - (root, artifact_set, stem_bytes) - } - - #[test] - fn accepts_only_the_complete_canonical_pcm16_set() { - let (root, artifact_set, _) = canonical_fixture(); - let admitted = preflight_playable_stem_set(&root, &artifact_set) - .expect("complete canonical set should pass native preflight"); - - assert_eq!(admitted.artifact_set_id(), ARTIFACT_SET_ID); - assert_eq!( - admitted.files().iter().map(PreflightPlayableStemFile::stem_kind).collect::>(), - PlaybackStemKind::canonical_order() - ); - assert!(admitted.files().iter().all(|file| file.native_path().starts_with(&root))); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_same_size_content_mutation_by_complete_file_hash() { - let (root, artifact_set, _) = canonical_fixture(); - let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); - let mut mutated = fs::read(&vocals).expect("fixture should read"); - let last = mutated.len() - 1; - mutated[last] ^= 0x01; - fs::write(&vocals, mutated).expect("same-size mutation should write"); - - assert_eq!( - preflight_playable_stem_set(&root, &artifact_set), - Err(PlayableStemAdmissionError::ContentHashMismatch) - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_noncanonical_wave_header_even_when_hash_metadata_matches() { - let root = unique_temp_root("header"); - let samples = [0i16; 8]; - let mut stem_bytes = (0..4) - .map(|_| pcm16_wave(8_000, &samples)) - .collect::>(); - stem_bytes[0][34] = 24; - let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); - write_set(&root, &stem_bytes); - - assert_eq!( - preflight_playable_stem_set(&root, &artifact_set), - Err(PlayableStemAdmissionError::WaveHeaderMismatch) - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_unexpected_artifact_set_members() { - let (root, artifact_set, _) = canonical_fixture(); - let unexpected = root - .join("playable-stems-v1") - .join(ARTIFACT_SET_ID) - .join("notes.txt"); - fs::write(unexpected, b"not media").expect("unexpected member should write"); - - assert_eq!( - preflight_playable_stem_set(&root, &artifact_set), - Err(PlayableStemAdmissionError::InvalidArtifactSetLayout) - ); - let _ = fs::remove_dir_all(root); - } - - #[cfg(unix)] - #[test] - fn rejects_symlinked_stem_even_when_target_bytes_match() { - use std::os::unix::fs::symlink; - - let (root, artifact_set, stem_bytes) = canonical_fixture(); - let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); - let replacement = root.join("replacement.wav"); - fs::write(&replacement, &stem_bytes[0]).expect("replacement fixture should write"); - fs::remove_file(&vocals).expect("original vocals fixture should be removed"); - symlink(&replacement, &vocals).expect("symlink fixture should be created"); - - assert_eq!( - preflight_playable_stem_set(&root, &artifact_set), - Err(PlayableStemAdmissionError::InvalidArtifactFile) - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn sha256_matches_nist_style_known_answer_vectors() { - for (message, expected) in [ - ( - &b""[..], - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ), - ( - &b"abc"[..], - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", - ), - ( - &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..], - "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", - ), - ] { - assert_eq!(sha256_hex_reader(Cursor::new(message)).as_deref(), Ok(expected)); - } - } -} From 20c4a120b94b3175f608d2fe3c8f7dac760080c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:22:33 +0900 Subject: [PATCH 023/160] docs(traceability): map native stem admission evidence --- .../playable-stem-native-admission.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/traceability/playable-stem-native-admission.md diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md new file mode 100644 index 000000000..504b74eb4 --- /dev/null +++ b/docs/traceability/playable-stem-native-admission.md @@ -0,0 +1,94 @@ +# Playable stem native admission traceability + +- **Status:** Draft implementation evidence; not a release or acceptance claim +- **Date:** 2026-09-04 +- **Bounded contexts:** Source Separation → Native Resource Admission → Active Player +- **Implementation ancestry before this note:** `feat/playable-stem-native-contract-961@f2b73fedbc8fb1cd9a520998886c8893282b521e` +- **Parent publication owner:** PR #1159, `dbafdd7a60d849960e620d071f2657088fa292da` +- **Canonical playback owner:** PR #971, `d1ca68d5dee882db0a7442ebf425c87e5cb618f4` +- **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. + +## Problem and trust boundary + +The analysis process can publish four real mono PCM16 WAV files and return a path-free artifact reference. That reference proves only that the producer reported an artifact identity, byte length, media metadata, and SHA-256 value. It is not authority to read or serve a native path. + +The native boundary therefore derives the only permitted locations from the already-authorized project temporary root: + +```text +{project_temp_root}/playable-stems-v1/{artifact_set_id}/vocals.wav +{project_temp_root}/playable-stems-v1/{artifact_set_id}/bass.wav +{project_temp_root}/playable-stems-v1/{artifact_set_id}/drums.wav +{project_temp_root}/playable-stems-v1/{artifact_set_id}/other.wav +``` + +No path returned by the Python subprocess is accepted. No preflight result is serializable to the renderer. The existing `bandscope-playback` authority in PR #971 remains the only media-serving authority. + +## Implemented native preflight + +`apps/desktop/src-tauri/src/playable_stem_admission/` performs a fail-closed preflight before any future playback registration. The current implementation checks: + +- an absolute project temp root and plain, non-symlink/non-reparse owned directory chain; +- exact artifact-set directory membership: `vocals.wav`, `bass.wav`, `drums.wav`, `other.wav`, with no additional entry; +- regular-file and canonical containment checks for every derived artifact path; +- expected byte length on path metadata and the opened file; +- canonical BandScope producer layout: RIFF/WAVE, a 16-byte PCM `fmt ` chunk, PCM format tag 1, one channel, expected sample rate, byte rate, block alignment, 16 bits per sample, immediate `data` chunk, and the contract-derived sample/data length; +- streaming SHA-256 over the complete opened file; +- unchanged file length after hashing; +- complete four-stem success before a set value is returned. + +The 44-byte layout is deliberately a **BandScope producer contract**, not a claim that every valid WAVE file has a 44-byte header. RIFF is chunk-based and permits other chunk arrangements. BandScope can be stricter here because PR #1159 owns the producer and Python's `wave` writer emits the canonical PCM layout consumed by this internal contract. + +## SHA-256 decision + +The Tauri lock graph already contains `sha2` transitively, but `bandscope-desktop` does not declare it as a direct dependency. Depending on an undeclared transitive crate or hand-editing `Cargo.lock` would make dependency ownership and reproducibility ambiguous. + +For this preflight increment, the native admission module therefore owns a small private streaming SHA-256 implementation whose operations and constants follow FIPS 180-4. Tests include the empty message, `abc`, the longer standard message, the million-`a` vector, multi-block interrupted short reads, and reader-error propagation. These are implementation-correctness checks only. NIST explicitly states that use of CAVP test vectors does **not** replace CAVP validation; BandScope therefore makes no CAVP, FIPS 140, or validated-cryptographic-module claim from these tests. + +**Removal condition:** replace the private implementation if BandScope adopts a reviewed direct Rust SHA-256 dependency through normal Cargo resolution and exact-head parity tests show identical complete-file digests without weakening the admission boundary. The lockfile must be generated by Cargo, not edited manually. + +## Standards and evidence mapping + +| Evidence | BandScope decision | Current executable evidence | +| --- | --- | --- | +| NIST FIPS 180-4 SHA-256 | Complete-file content identity uses SHA-256; implementation follows the specified SHA-256 operations/constants. | Native known-answer/unit tests in `playable_stem_admission/sha256.rs`; hosted exact-head receipt still absent. | +| NIST CAVP Secure Hashing | Test vectors may informally check correctness but do not confer validation. | Documentation and module rustdoc explicitly prohibit a CAVP/FIPS validation claim. | +| RIFF/WAVE chunk model | Verify `RIFF`/`WAVE`, `fmt ` and `data` semantics and RIFF size relationships. | `validate_wave_header` plus malformed-header unit test. | +| BandScope path-free contract | Python metadata cannot choose a path; native derives fixed locations. | `PlayableStemArtifactSetReference::derive_artifact_path` plus native exact-membership/containment checks. | +| BandScope single playback authority | Preflight must not become a second transport or renderer filesystem capability. | `PreflightPlayableStemSet` is native-only and non-serializable; no playback registration exists yet. | + +## Threat cases and current result + +| Threat / defect | Current result | Remaining risk | +| --- | --- | --- | +| Extra file in artifact-set directory | Rejected before any set result is returned. | Directory may still change after preflight; authority binding must use native file identity. | +| Symlinked stem | Rejected on Unix test path; Windows reparse attribute is rejected in production code. | Windows exact-head executable evidence is still required. | +| Same-size content replacement before preflight | Complete-file SHA-256 mismatch rejects it. | Replacement after preflight remains a TOCTOU risk until #971 binds native file identity. | +| Malformed PCM header with matching hash metadata | Header contract rejects it before authority. | Future producer-format changes require a versioned contract, not silent widening. | +| Partial four-stem publication | Exact directory membership and all-four iteration prevent a partial set result. | Atomic playback-authority registration is not implemented yet. | +| Renderer path disclosure | Preflight types do not serialize and process parsing strips native metadata from renderer status. | Future selector must continue to receive opaque handles only. | + +## Current RED and acceptance boundary + +The Tauri JSONL reader validates `playableStemArtifactSet` but currently projects only `renderer_status()` into job state and discards the retained native artifact reference. Consequently, the new preflight service is production source but not yet invoked by the job lifecycle, and PR #971 still registers only the full mix. + +The next causal repair is: + +```text +successful terminal AnalysisProcessStatus +→ retain native artifact-set reference +→ preflight against the request-owned project temp root +→ bind all four opened-file identities atomically into #971 PlaybackAuthority +→ mint opaque renderer source handles +``` + +A preflight or binding failure must register **zero** stems and must not invalidate an otherwise valid full-mix source or successful rehearsal analysis. Project/source replacement must revoke the stem set together with the full-mix authority. + +This document does not mark that path GREEN. The stacked PR currently has no hosted exact-head workflow run, and local Rust compilation is not acceptance evidence. ADR-0001 stays Proposed until current-head build/test/coverage/security/review evidence and rights-cleared audible macOS/Windows behavior satisfy its acceptance criteria. + +## References + +Microsoft. (2021, January 7). *Resource Interchange File Format (RIFF).* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff- + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS) (FIPS PUB 180-4).* U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2026, August 12). *Cryptographic Algorithm Validation Program: Secure hashing.* https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/secure-hashing From 0f7c7749eef648374077f927415caed980a31479 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:14:13 +0900 Subject: [PATCH 024/160] feat(player): bind preflighted stems to native authority --- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/main.rs | 38 +- .../src-tauri/src/native_file_identity.rs | 105 ++++ .../src/playable_stem_admission/mod.rs | 27 +- .../src-tauri/src/playback_protocol.rs | 470 +++++++++++++----- 5 files changed, 511 insertions(+), 130 deletions(-) create mode 100644 apps/desktop/src-tauri/src/native_file_identity.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 22b68996e..e44dd13e9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,4 +3,5 @@ //! Keeping filesystem admission logic here lets BandScope test hostile local media //! without granting the renderer path or playback authority. +pub mod native_file_identity; pub mod playable_stem_admission; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 68f682197..7d9e31927 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -2,6 +2,7 @@ mod playback_protocol; +use bandscope_desktop::playable_stem_admission::preflight_playable_stem_set; use bandscope_desktop_core::*; use playback_protocol::{playback_authority_uri, PlaybackAuthority, PLAYBACK_SCHEME}; use rfd::FileDialog; @@ -344,6 +345,7 @@ fn drain_analysis_status_updates( fn run_analysis_engine( state: AppState, app: tauri::AppHandle, + playback_authority: Arc, job_id: String, request: AnalysisJobRequest, requested_at: String, @@ -379,6 +381,9 @@ fn run_analysis_engine( } }; + let playback_project_id = request.project_id.clone(); + let playback_temp_root = request.temp_root.clone(); + let playback_job_id = job_id.clone(); let payload = json!({ "jobId": job_id.clone(), "request": request, @@ -404,6 +409,8 @@ fn run_analysis_engine( ); }; let (status_tx, status_rx) = mpsc::channel::(); + let (stem_tx, stem_rx) = + mpsc::channel::(); let stdout_reader = thread::spawn(move || { let reader = BufReader::new(stdout); let mut last_status = None; @@ -418,8 +425,11 @@ fn run_analysis_engine( if let Ok(process_status) = analysis_process_status::parse_analysis_process_status(trimmed) { - // Native stem metadata is validated here but remains outside renderer state - // until playback admission verifies the actual WAV files. + if let Some(playable_stem_artifact_set) = + process_status.playable_stem_artifact_set().cloned() + { + let _ = stem_tx.send(playable_stem_artifact_set); + } let status = process_status.renderer_status().clone(); last_status = Some(status.clone()); if status_tx.send(status).is_err() { @@ -518,7 +528,7 @@ fn run_analysis_engine( ); } - last_status.unwrap_or_else(|| { + let finished = last_status.unwrap_or_else(|| { failed_status( payload["jobId"] .as_str() @@ -528,7 +538,21 @@ fn run_analysis_engine( AnalysisJobErrorCode::EngineUnavailable, "Analysis engine returned an invalid response.", ) - }) + }); + if matches!(finished.state, AnalysisJobState::Succeeded) { + if let (Some(project_id), Some(temp_root), Some(artifact_set)) = ( + playback_project_id.as_deref(), + playback_temp_root.as_deref(), + stem_rx.try_iter().last(), + ) { + if let Ok(preflight) = + preflight_playable_stem_set(Path::new(temp_root), &artifact_set) + { + let _ = playback_authority.activate_stems(project_id, &playback_job_id, &preflight); + } + } + } + finished } #[tauri::command] @@ -536,6 +560,7 @@ fn start_analysis_job( request: Value, app: tauri::AppHandle, state: tauri::State<'_, AppState>, + playback_authority: tauri::State<'_, Arc>, ) -> AnalysisJobStatus { let requested_at = iso_timestamp_now(); let mut parsed_request = match parse_request_payload(request) { @@ -585,6 +610,9 @@ fn start_analysis_job( "Analysis queue is full. Please wait for a running job to finish.", ); } + if let Some(project_id) = parsed_request.project_id.as_deref() { + let _ = playback_authority.begin_stem_analysis(project_id, &job_id); + } let queued = AnalysisJobStatus { job_id: job_id.clone(), state: AnalysisJobState::Queued, @@ -601,6 +629,7 @@ fn start_analysis_job( let app_state = state.inner().clone(); let worker_app_handle = app.clone(); + let worker_playback_authority = playback_authority.inner().clone(); std::thread::spawn(move || { store_status_and_emit( &app_state, @@ -621,6 +650,7 @@ fn start_analysis_job( let finished = run_analysis_engine( app_state.clone(), worker_app_handle.clone(), + worker_playback_authority, job_id, parsed_request, requested_at, diff --git a/apps/desktop/src-tauri/src/native_file_identity.rs b/apps/desktop/src-tauri/src/native_file_identity.rs new file mode 100644 index 000000000..6ec26a3b5 --- /dev/null +++ b/apps/desktop/src-tauri/src/native_file_identity.rs @@ -0,0 +1,105 @@ +//! Canonical native file identity used by desktop media admission and playback revocation. +//! +//! Identity is captured from an already-open file descriptor/handle. Callers compare +//! it again when reopening a path so same-size replacement or in-place mutation cannot +//! silently inherit previously granted playback authority. + +use std::fs::File; + +#[cfg(unix)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NativeFileIdentity { + device: u64, + inode: u64, + change_time_seconds: i64, + change_time_nanoseconds: i64, +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(C)] +struct WindowsFileTime { + low_date_time: u32, + high_date_time: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsByHandleFileInformation { + file_attributes: u32, + creation_time: WindowsFileTime, + last_access_time: WindowsFileTime, + last_write_time: WindowsFileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, +} + +#[cfg(windows)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NativeFileIdentity { + volume_serial_number: u32, + file_index: u64, + last_write_time: WindowsFileTime, +} + +#[cfg(not(any(unix, windows)))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NativeFileIdentity; + +/// Capture stable identity from an already-open native file. +/// +/// Unix uses device/inode plus ctime; Windows uses volume/file index plus last-write +/// time. Platforms without an equivalent supported primitive fail closed. +#[cfg(unix)] +pub fn native_file_identity(file: &File) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(NativeFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + change_time_seconds: metadata.ctime(), + change_time_nanoseconds: metadata.ctime_nsec(), + }) +} + +#[cfg(windows)] +pub fn native_file_identity(file: &File) -> std::io::Result { + use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "GetFileInformationByHandle"] + fn get_file_information_by_handle( + file: std::os::windows::io::RawHandle, + information: *mut WindowsByHandleFileInformation, + ) -> i32; + } + + let mut information = MaybeUninit::::uninit(); + let result = unsafe { + get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(NativeFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: ((information.file_index_high as u64) << 32) + | information.file_index_low as u64, + last_write_time: information.last_write_time, + }) +} + +#[cfg(not(any(unix, windows)))] +pub fn native_file_identity(_file: &File) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "native playback file identity is unsupported on this platform", + )) +} diff --git a/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs b/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs index 54993ef75..c5c9be910 100644 --- a/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs +++ b/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs @@ -8,6 +8,7 @@ mod sha256; +use crate::native_file_identity::{native_file_identity, NativeFileIdentity}; use bandscope_desktop_core::playable_stem_contract::{ PlaybackStemKind, PlayableStemArtifactReference, PlayableStemArtifactSetReference, }; @@ -60,14 +61,16 @@ impl std::error::Error for PlayableStemAdmissionError {} /// One actual file that passed native path/layout/byte/header/hash preflight. /// -/// The type intentionally does not implement `Serialize`; the canonical path is -/// trusted-process state, not a renderer contract or playback handle. +/// The type intentionally does not implement `Serialize`; the canonical path and +/// native file identity are trusted-process state, not a renderer contract or +/// playback handle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PreflightPlayableStemFile { stem_kind: PlaybackStemKind, native_path: PathBuf, file_size_bytes: u64, content_hash_sha256: String, + file_identity: NativeFileIdentity, } impl PreflightPlayableStemFile { @@ -90,6 +93,11 @@ impl PreflightPlayableStemFile { pub fn content_hash_sha256(&self) -> &str { &self.content_hash_sha256 } + + /// Return the identity captured from the same opened file after hash validation. + pub fn file_identity(&self) -> &NativeFileIdentity { + &self.file_identity + } } /// Complete preflight result. Partial stem sets are never returned. @@ -113,9 +121,9 @@ impl PreflightPlayableStemSet { /// Verify actual generated stem bytes without granting playback authority. /// -/// The caller must still bind the returned files to the current project using -/// #971's revocable native file-identity/serving boundary before exposing an -/// opaque source handle. +/// The native identity is captured from the same file handle whose complete bytes +/// were hashed. A later playback open must match that identity, closing the +/// preflight-to-authority replacement gap without accepting a producer path. pub fn preflight_playable_stem_set( project_temp_root: &Path, artifact_set: &PlayableStemArtifactSetReference, @@ -229,7 +237,8 @@ fn preflight_artifact( .canonicalize() .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; if canonical_path.parent() != Some(artifact_set_root) - || canonical_path.file_name() != Some(OsString::from(artifact.stem_kind().file_name()).as_os_str()) + || canonical_path.file_name() + != Some(OsString::from(artifact.stem_kind().file_name()).as_os_str()) { return Err(PlayableStemAdmissionError::InvalidArtifactFile); } @@ -258,12 +267,15 @@ fn preflight_artifact( if !final_metadata.is_file() || final_metadata.len() != opened_metadata.len() { return Err(PlayableStemAdmissionError::InvalidArtifactFile); } + let file_identity = native_file_identity(&file) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; Ok(PreflightPlayableStemFile { stem_kind: artifact.stem_kind(), native_path: canonical_path, file_size_bytes: final_metadata.len(), content_hash_sha256, + file_identity, }) } @@ -469,6 +481,9 @@ mod tests { .files() .iter() .all(|file| file.native_path().starts_with(&root))); + for file in preflight.files() { + assert_eq!(file.file_identity(), file.file_identity()); + } let _ = fs::remove_dir_all(root); } diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs index 136e47e2d..7b6db0360 100644 --- a/apps/desktop/src-tauri/src/playback_protocol.rs +++ b/apps/desktop/src-tauri/src/playback_protocol.rs @@ -1,11 +1,18 @@ //! Revocable native media authority for the mounted rehearsal player. //! -//! The WebView receives only an app-minted project id. Native source paths stay -//! behind this protocol boundary and every request is checked against the one -//! currently active project before BandScope opens any file. +//! The WebView receives only app-minted opaque identifiers. Native source paths +//! stay behind this protocol boundary and every request is checked against the +//! one currently active project before BandScope opens any file. -use bandscope_desktop_core::{is_valid_project_id, LocalAudioSourcePayload}; +use bandscope_desktop::{ + native_file_identity::{native_file_identity, NativeFileIdentity}, + playable_stem_admission::PreflightPlayableStemSet, +}; +use bandscope_desktop_core::{ + is_valid_project_id, playable_stem_contract::PlaybackStemKind, LocalAudioSourcePayload, +}; use std::{ + collections::BTreeMap, fs::File, io::{Read, Seek, SeekFrom}, path::{Path, PathBuf}, @@ -27,117 +34,37 @@ pub const PLAYBACK_AUTHORITY_PREFIX: &str = "bandscope-project://"; /// an arbitrarily large buffer from an untrusted Range header. const MAX_RANGE_BYTES: u64 = 1_000 * 1024; -#[cfg(unix)] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlaybackFileIdentity { - device: u64, - inode: u64, - change_time_seconds: i64, - change_time_nanoseconds: i64, -} - -#[cfg(windows)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(C)] -struct WindowsFileTime { - low_date_time: u32, - high_date_time: u32, -} - -#[cfg(windows)] -#[repr(C)] -struct WindowsByHandleFileInformation { - file_attributes: u32, - creation_time: WindowsFileTime, - last_access_time: WindowsFileTime, - last_write_time: WindowsFileTime, - volume_serial_number: u32, - file_size_high: u32, - file_size_low: u32, - number_of_links: u32, - file_index_high: u32, - file_index_low: u32, -} - -#[cfg(windows)] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlaybackFileIdentity { - volume_serial_number: u32, - file_index: u64, - last_write_time: WindowsFileTime, -} - -#[cfg(not(any(unix, windows)))] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlaybackFileIdentity; - -#[cfg(unix)] -fn playback_file_identity(file: &File) -> std::io::Result { - use std::os::unix::fs::MetadataExt; - - let metadata = file.metadata()?; - Ok(PlaybackFileIdentity { - device: metadata.dev(), - inode: metadata.ino(), - change_time_seconds: metadata.ctime(), - change_time_nanoseconds: metadata.ctime_nsec(), - }) -} - -#[cfg(windows)] -fn playback_file_identity(file: &File) -> std::io::Result { - use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; - - #[link(name = "kernel32")] - extern "system" { - #[link_name = "GetFileInformationByHandle"] - fn get_file_information_by_handle( - file: std::os::windows::io::RawHandle, - information: *mut WindowsByHandleFileInformation, - ) -> i32; - } - - let mut information = MaybeUninit::::uninit(); - let result = unsafe { - get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) - }; - if result == 0 { - return Err(std::io::Error::last_os_error()); - } - let information = unsafe { information.assume_init() }; - Ok(PlaybackFileIdentity { - volume_serial_number: information.volume_serial_number, - file_index: ((information.file_index_high as u64) << 32) - | information.file_index_low as u64, - last_write_time: information.last_write_time, - }) -} - -#[cfg(not(any(unix, windows)))] -fn playback_file_identity(_file: &File) -> std::io::Result { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "native playback file identity is unsupported on this platform", - )) +#[derive(Clone, Debug)] +struct PlaybackFileAuthority { + source_path: PathBuf, + extension: String, + expected_size: u64, + source_identity: NativeFileIdentity, } #[derive(Clone, Debug)] struct PlaybackSourceAuthority { project_id: String, - source_path: PathBuf, - extension: String, - expected_size: u64, - source_identity: PlaybackFileIdentity, + full_mix: PlaybackFileAuthority, + stem_analysis_job_id: Option, + playable_stems: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlaybackRequestSource { + FullMix, + Stem(PlaybackStemKind), } /// Process-local authority for the one audio source currently admitted to the -/// rehearsal player. Replacing it revokes every previously selected source. +/// rehearsal player. Replacing it revokes the full mix, any in-flight stem +/// generation token, and every stem registered for the previous source. #[derive(Default)] pub struct PlaybackAuthority { current: Mutex>, } -/// Return the only renderer-visible handle for an app-minted playback project. +/// Return the renderer-visible full-mix handle for an app-minted playback project. pub fn playback_authority_uri(project_id: &str) -> Result { if !is_valid_project_id(project_id) { return Err("Could not prepare the selected audio for playback.".to_string()); @@ -145,6 +72,20 @@ pub fn playback_authority_uri(project_id: &str) -> Result { Ok(format!("{PLAYBACK_AUTHORITY_PREFIX}{project_id}")) } +/// Return an opaque generated-stem handle without exposing a native path. +pub fn playback_stem_authority_uri( + project_id: &str, + stem_kind: PlaybackStemKind, +) -> Result { + if !is_valid_project_id(project_id) { + return Err("Could not prepare generated stems for playback.".to_string()); + } + Ok(format!( + "{PLAYBACK_AUTHORITY_PREFIX}{project_id}/stem/{}", + stem_slug(stem_kind) + )) +} + impl PlaybackAuthority { /// Replace the current playback source with an already validated native /// source. The project id is app-minted and never derived from a path. @@ -155,7 +96,7 @@ impl PlaybackAuthority { let source_path = PathBuf::from(&source.source_path); let (file, _) = open_validated_source(&source_path, source.file_size_bytes) .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; - let source_identity = playback_file_identity(&file) + let source_identity = native_file_identity(&file) .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; let mut current = self .current @@ -163,26 +104,108 @@ impl PlaybackAuthority { .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; *current = Some(PlaybackSourceAuthority { project_id: project_id.to_string(), - source_path, - extension: source.extension.clone(), - expected_size: source.file_size_bytes, - source_identity, + full_mix: PlaybackFileAuthority { + source_path, + extension: source.extension.clone(), + expected_size: source.file_size_bytes, + source_identity, + }, + stem_analysis_job_id: None, + playable_stems: None, }); Ok(()) } - /// Serve GET/HEAD media requests only when their opaque project id still - /// names the current authority. Stale project ids fail closed immediately. + /// Mark a newly queued analysis as the only job allowed to register stems + /// for the current project and revoke any older generated stem set. + pub fn begin_stem_analysis(&self, project_id: &str, job_id: &str) -> Result<(), String> { + if !is_valid_project_id(project_id) || job_id.is_empty() { + return Err("Could not prepare generated stems for playback.".to_string()); + } + let mut current = self + .current + .lock() + .map_err(|_| "Could not prepare generated stems for playback.".to_string())?; + let authority = current + .as_mut() + .filter(|entry| entry.project_id == project_id) + .ok_or_else(|| "Could not prepare generated stems for playback.".to_string())?; + authority.stem_analysis_job_id = Some(job_id.to_string()); + authority.playable_stems = None; + Ok(()) + } + + /// Atomically bind a complete native-preflighted stem set to the current + /// project, but only if the same analysis job is still the latest owner. + /// + /// File identities come from the exact handles whose bytes passed hash/header + /// preflight. No producer path is accepted and partial registration is impossible. + pub fn activate_stems( + &self, + project_id: &str, + job_id: &str, + preflight: &PreflightPlayableStemSet, + ) -> Result<(), String> { + if !is_valid_project_id(project_id) || job_id.is_empty() { + return Err("Could not prepare generated stems for playback.".to_string()); + } + let mut sources = BTreeMap::new(); + for file in preflight.files() { + if sources + .insert( + file.stem_kind(), + PlaybackFileAuthority { + source_path: file.native_path().to_path_buf(), + extension: "wav".to_string(), + expected_size: file.file_size_bytes(), + source_identity: file.file_identity().clone(), + }, + ) + .is_some() + { + return Err("Could not prepare generated stems for playback.".to_string()); + } + } + if sources.len() != PlaybackStemKind::canonical_order().len() + || PlaybackStemKind::canonical_order() + .into_iter() + .any(|stem_kind| !sources.contains_key(&stem_kind)) + { + return Err("Could not prepare generated stems for playback.".to_string()); + } + + let mut current = self + .current + .lock() + .map_err(|_| "Could not prepare generated stems for playback.".to_string())?; + let authority = current + .as_mut() + .filter(|entry| entry.project_id == project_id) + .filter(|entry| entry.stem_analysis_job_id.as_deref() == Some(job_id)) + .ok_or_else(|| "Could not prepare generated stems for playback.".to_string())?; + authority.playable_stems = Some(sources); + Ok(()) + } + + /// Serve GET/HEAD media requests only when their opaque project/source token + /// still belongs to the current authority. Missing or revoked stems return 404. pub fn respond(&self, request: Request>) -> Response> { - let Some(project_id) = project_id_from_path(request.uri().path()) else { + let Some((project_id, requested_source)) = playback_request_source(request.uri().path()) else { return empty_response(StatusCode::NOT_FOUND); }; self.with_current_authority(project_id, |authority| { if request.method() != Method::GET && request.method() != Method::HEAD { - return empty_response(StatusCode::METHOD_NOT_ALLOWED); + return Some(empty_response(StatusCode::METHOD_NOT_ALLOWED)); } - serve_authorized_source(authority, &request) + let source = match requested_source { + PlaybackRequestSource::FullMix => &authority.full_mix, + PlaybackRequestSource::Stem(stem_kind) => { + authority.playable_stems.as_ref()?.get(&stem_kind)? + } + }; + Some(serve_authorized_source(source, &request)) }) + .flatten() .unwrap_or_else(|| empty_response(StatusCode::NOT_FOUND)) } @@ -202,12 +225,43 @@ impl PlaybackAuthority { } } -fn project_id_from_path(path: &str) -> Option<&str> { - let project_id = path.strip_prefix('/')?; - if project_id.is_empty() || project_id.contains('/') || project_id.contains('%') { +fn stem_slug(stem_kind: PlaybackStemKind) -> &'static str { + match stem_kind { + PlaybackStemKind::Vocals => "vocals", + PlaybackStemKind::Bass => "bass", + PlaybackStemKind::Drums => "drums", + PlaybackStemKind::Other => "other", + } +} + +fn stem_kind_from_slug(value: &str) -> Option { + match value { + "vocals" => Some(PlaybackStemKind::Vocals), + "bass" => Some(PlaybackStemKind::Bass), + "drums" => Some(PlaybackStemKind::Drums), + "other" => Some(PlaybackStemKind::Other), + _ => None, + } +} + +fn playback_request_source(path: &str) -> Option<(&str, PlaybackRequestSource)> { + let relative = path.strip_prefix('/')?; + if relative.is_empty() || relative.contains('%') { + return None; + } + let mut parts = relative.split('/'); + let project_id = parts.next()?; + if !is_valid_project_id(project_id) { return None; } - is_valid_project_id(project_id).then_some(project_id) + match (parts.next(), parts.next(), parts.next()) { + (None, None, None) => Some((project_id, PlaybackRequestSource::FullMix)), + (Some("stem"), Some(stem_slug), None) => Some(( + project_id, + PlaybackRequestSource::Stem(stem_kind_from_slug(stem_slug)?), + )), + _ => None, + } } fn content_type(extension: &str) -> Option<&'static str> { @@ -240,9 +294,9 @@ fn open_validated_source(source_path: &Path, expected_size: u64) -> Result<(File Ok((file, metadata.len())) } -fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), StatusCode> { +fn validated_file(authority: &PlaybackFileAuthority) -> Result<(File, u64), StatusCode> { let (file, len) = open_validated_source(&authority.source_path, authority.expected_size)?; - let current_identity = playback_file_identity(&file).map_err(|_| StatusCode::GONE)?; + let current_identity = native_file_identity(&file).map_err(|_| StatusCode::GONE)?; if current_identity != authority.source_identity { return Err(StatusCode::GONE); } @@ -250,7 +304,7 @@ fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), St } fn serve_authorized_source( - authority: &PlaybackSourceAuthority, + authority: &PlaybackFileAuthority, request: &Request>, ) -> Response> { let Some(media_type) = content_type(&authority.extension) else { @@ -371,8 +425,16 @@ fn empty_response(status: StatusCode) -> Response> { #[cfg(test)] mod tests { use super::*; + use bandscope_desktop::playable_stem_admission::preflight_playable_stem_set; + use bandscope_desktop_core::playable_stem_contract::PlayableStemArtifactSetReference; + use serde_json::json; use std::time::{SystemTime, UNIX_EPOCH}; + const STEM_ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SINGLE_SAMPLE_WAVE_SHA256: &str = + "4aebda3a657a0d8f532d11ceacb1679081d7bdf7d7d301a53f1096af3580be91"; + fn test_source(label: &str, bytes: &[u8]) -> (PathBuf, LocalAudioSourcePayload) { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -395,6 +457,58 @@ mod tests { (root, source) } + fn single_sample_wave() -> Vec { + vec![ + 0x52, 0x49, 0x46, 0x46, 0x26, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, + 0x6d, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x40, 0x1f, + 0x00, 0x00, 0x80, 0x3e, 0x00, 0x00, 0x02, 0x00, 0x10, 0x00, 0x64, 0x61, 0x74, + 0x61, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + ] + } + + fn preflight_stem_fixture(label: &str) -> (PathBuf, PreflightPlayableStemSet) { + let (root, _) = test_source(label, b"full-mix"); + let set_root = root + .join("playable-stems-v1") + .join(STEM_ARTIFACT_SET_ID); + std::fs::create_dir_all(&set_root).expect("test stem set root should be created"); + let wave = single_sample_wave(); + for stem_kind in PlaybackStemKind::canonical_order() { + std::fs::write(set_root.join(stem_kind.file_name()), &wave) + .expect("test stem should be written"); + } + let stem_artifacts = PlaybackStemKind::canonical_order() + .into_iter() + .map(|stem_kind| { + json!({ + "artifactId": stem_kind.artifact_id(), + "stemKind": stem_slug(stem_kind), + "fileSizeBytes": wave.len(), + "contentHashSha256": SINGLE_SAMPLE_WAVE_SHA256, + "mediaType": "audio/wav", + "sampleRate": 8_000, + "channelCount": 1, + "sampleCount": 1, + "durationSeconds": 0.000125 + }) + }) + .collect::>(); + let reference = serde_json::from_value::(json!({ + "artifactSetId": STEM_ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": 8_000, + "channelCount": 1, + "sampleCount": 1, + "durationSeconds": 0.000125, + "appliedGain": 1.0, + "stemArtifacts": stem_artifacts + })) + .expect("test stem reference should satisfy the contract"); + let preflight = preflight_playable_stem_set(&root, &reference) + .expect("test stem files should pass native preflight"); + (root, preflight) + } + fn request(project_id: &str) -> Request> { Request::builder() .uri(format!("{PLAYBACK_SCHEME}://localhost/{project_id}")) @@ -402,12 +516,26 @@ mod tests { .expect("test request should build") } + fn stem_request(project_id: &str, stem_kind: PlaybackStemKind) -> Request> { + Request::builder() + .uri(format!( + "{PLAYBACK_SCHEME}://localhost/{project_id}/stem/{}", + stem_slug(stem_kind) + )) + .body(Vec::new()) + .expect("test stem request should build") + } + #[test] - fn renderer_handle_contains_only_the_app_minted_project_id() { + fn renderer_handles_contain_only_app_minted_ids_and_canonical_stem_tokens() { assert_eq!( playback_authority_uri("project-100-1").as_deref(), Ok("bandscope-project://project-100-1") ); + assert_eq!( + playback_stem_authority_uri("project-100-1", PlaybackStemKind::Vocals).as_deref(), + Ok("bandscope-project://project-100-1/stem/vocals") + ); assert!(playback_authority_uri("../../private.wav").is_err()); } @@ -440,6 +568,101 @@ mod tests { let _ = std::fs::remove_dir_all(second_root); } + #[test] + fn preflighted_stems_bind_and_serve_all_four_files_atomically() { + let (root, source) = test_source("stem-bind-full", b"full-mix"); + let (stem_root, preflight) = preflight_stem_fixture("stem-bind"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-125-1", &source) + .expect("full mix should activate"); + authority + .begin_stem_analysis("project-125-1", "job-10") + .expect("current analysis should own stem registration"); + authority + .activate_stems("project-125-1", "job-10", &preflight) + .expect("complete preflighted stems should bind"); + + for stem_kind in PlaybackStemKind::canonical_order() { + assert_eq!( + authority.respond(stem_request("project-125-1", stem_kind)).body(), + &single_sample_wave() + ); + } + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(stem_root); + } + + #[test] + fn older_same_project_job_cannot_overwrite_a_newer_stem_generation() { + let (root, source) = test_source("stem-race-full", b"full-mix"); + let (stem_root, preflight) = preflight_stem_fixture("stem-race"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-130-1", &source) + .expect("full mix should activate"); + authority + .begin_stem_analysis("project-130-1", "job-10") + .expect("first analysis should begin"); + authority + .begin_stem_analysis("project-130-1", "job-11") + .expect("newer analysis should supersede the first"); + + assert!(authority + .activate_stems("project-130-1", "job-10", &preflight) + .is_err()); + assert_eq!( + authority + .respond(stem_request("project-130-1", PlaybackStemKind::Vocals)) + .status(), + StatusCode::NOT_FOUND + ); + authority + .activate_stems("project-130-1", "job-11", &preflight) + .expect("latest analysis should bind stems"); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(stem_root); + } + + #[test] + fn stem_file_identity_rejects_replacement_after_preflight() { + let (root, source) = test_source("stem-identity-full", b"full-mix"); + let (stem_root, preflight) = preflight_stem_fixture("stem-identity"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-140-1", &source) + .expect("full mix should activate"); + authority + .begin_stem_analysis("project-140-1", "job-20") + .expect("analysis should begin"); + authority + .activate_stems("project-140-1", "job-20", &preflight) + .expect("preflighted stems should bind"); + + let vocals_path = preflight + .files() + .iter() + .find(|file| file.stem_kind() == PlaybackStemKind::Vocals) + .expect("vocals preflight should exist") + .native_path() + .to_path_buf(); + let replacement = vocals_path.with_extension("replacement"); + std::fs::write(&replacement, single_sample_wave()) + .expect("same-size replacement should be written"); + std::fs::remove_file(&vocals_path).expect("preflighted vocals should be removed"); + std::fs::rename(&replacement, &vocals_path) + .expect("replacement should occupy the preflighted path"); + + assert_eq!( + authority + .respond(stem_request("project-140-1", PlaybackStemKind::Vocals)) + .status(), + StatusCode::GONE + ); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(stem_root); + } + #[test] fn authorized_response_keeps_revocation_authority_until_use_finishes() { let (root, source) = test_source("linearizable-revocation", b"audio"); @@ -477,6 +700,13 @@ mod tests { .body(Vec::new()) .expect("test traversal request should build"); assert_eq!(authority.respond(traversal).status(), StatusCode::NOT_FOUND); + let unknown_stem = Request::builder() + .uri(format!( + "{PLAYBACK_SCHEME}://localhost/project-200-3/stem/private.wav" + )) + .body(Vec::new()) + .expect("unknown stem request should build"); + assert_eq!(authority.respond(unknown_stem).status(), StatusCode::NOT_FOUND); let _ = std::fs::remove_dir_all(root); } From b6f6173afdb7e924567b7b6f42e2a7af5d0e997e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:17:48 +0900 Subject: [PATCH 025/160] docs(traceability): record stem authority binding boundary --- .../playable-stem-native-admission.md | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md index 504b74eb4..d14130346 100644 --- a/docs/traceability/playable-stem-native-admission.md +++ b/docs/traceability/playable-stem-native-admission.md @@ -3,7 +3,7 @@ - **Status:** Draft implementation evidence; not a release or acceptance claim - **Date:** 2026-09-04 - **Bounded contexts:** Source Separation → Native Resource Admission → Active Player -- **Implementation ancestry before this note:** `feat/playable-stem-native-contract-961@f2b73fedbc8fb1cd9a520998886c8893282b521e` +- **Source implementation head before this documentation update:** `feat/playable-stem-native-contract-961@0f7c7749eef648374077f927415caed980a31479` - **Parent publication owner:** PR #1159, `dbafdd7a60d849960e620d071f2657088fa292da` - **Canonical playback owner:** PR #971, `d1ca68d5dee882db0a7442ebf425c87e5cb618f4` - **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. @@ -12,7 +12,7 @@ The analysis process can publish four real mono PCM16 WAV files and return a path-free artifact reference. That reference proves only that the producer reported an artifact identity, byte length, media metadata, and SHA-256 value. It is not authority to read or serve a native path. -The native boundary therefore derives the only permitted locations from the already-authorized project temporary root: +The native boundary derives the only permitted locations from the already-authorized project temporary root: ```text {project_temp_root}/playable-stems-v1/{artifact_set_id}/vocals.wav @@ -21,11 +21,11 @@ The native boundary therefore derives the only permitted locations from the alre {project_temp_root}/playable-stems-v1/{artifact_set_id}/other.wav ``` -No path returned by the Python subprocess is accepted. No preflight result is serializable to the renderer. The existing `bandscope-playback` authority in PR #971 remains the only media-serving authority. +No path returned by the Python subprocess is accepted. Native paths, hashes and file identities do not cross into renderer analysis status. The existing `bandscope-playback` authority remains the only media-serving authority; stem support extends that authority rather than creating a second transport store. -## Implemented native preflight +## Implemented native admission and authority binding -`apps/desktop/src-tauri/src/playable_stem_admission/` performs a fail-closed preflight before any future playback registration. The current implementation checks: +`apps/desktop/src-tauri/src/playable_stem_admission/` performs a fail-closed preflight. The current implementation checks: - an absolute project temp root and plain, non-symlink/non-reparse owned directory chain; - exact artifact-set directory membership: `vocals.wav`, `bass.wav`, `drums.wav`, `other.wav`, with no additional entry; @@ -34,9 +34,30 @@ No path returned by the Python subprocess is accepted. No preflight result is se - canonical BandScope producer layout: RIFF/WAVE, a 16-byte PCM `fmt ` chunk, PCM format tag 1, one channel, expected sample rate, byte rate, block alignment, 16 bits per sample, immediate `data` chunk, and the contract-derived sample/data length; - streaming SHA-256 over the complete opened file; - unchanged file length after hashing; -- complete four-stem success before a set value is returned. +- complete four-stem success before a set value is returned; +- native file identity captured from the same opened file after header/hash verification. -The 44-byte layout is deliberately a **BandScope producer contract**, not a claim that every valid WAVE file has a 44-byte header. RIFF is chunk-based and permits other chunk arrangements. BandScope can be stricter here because PR #1159 owns the producer and Python's `wave` writer emits the canonical PCM layout consumed by this internal contract. +The 44-byte layout is deliberately a **BandScope producer contract**, not a claim that every valid WAVE file has a 44-byte header. RIFF is chunk-based and permits other chunk arrangements. BandScope can be stricter because PR #1159 owns the producer and Python's `wave` writer emits the canonical PCM layout consumed by this internal contract. + +Native file identity is now one shared desktop primitive in `native_file_identity.rs`. Unix uses device/inode plus ctime; Windows uses volume serial, file index and last-write time from the opened handle. `playable_stem_admission` and the pre-existing full-mix playback authority both consume that primitive. This avoids a second, divergent identity implementation. + +`PlaybackAuthority` now keeps the full mix and an optional complete four-stem map under the same revocation mutex. When a local-audio analysis is queued, `begin_stem_analysis(project_id, job_id)` makes that job the only generation allowed to register stems and clears any older generated set. A successful terminal analysis can retain its native artifact reference, preflight it against the request-owned temp root, and call `activate_stems(project_id, job_id, preflight)`. + +`activate_stems` first constructs all four canonical source authorities from preflighted path/size/identity values, then takes the existing playback-authority lock and installs the complete set only if the current project and latest-analysis job token still match. A partial set cannot be registered. A stale same-project analysis cannot overwrite a newer generation. Project/source replacement revokes the full mix, generation token and prior stems together. + +The custom protocol accepts only opaque native tokens: + +```text +/{project_id} +/{project_id}/stem/vocals +/{project_id}/stem/bass +/{project_id}/stem/drums +/{project_id}/stem/other +``` + +Every serve reopens the canonical path and compares current native file identity with the identity captured during admission. Same-size replacement after preflight therefore fails closed rather than inheriting the old authority. No native path is embedded in the renderer-visible handle. + +A stem preflight or authority-binding failure deliberately does **not** turn an otherwise valid rehearsal analysis/full mix into a failed analysis. The buyer still has the full-mix rehearsal result; unavailable stems remain unavailable. ## SHA-256 decision @@ -50,40 +71,34 @@ For this preflight increment, the native admission module therefore owns a small | Evidence | BandScope decision | Current executable evidence | | --- | --- | --- | -| NIST FIPS 180-4 SHA-256 | Complete-file content identity uses SHA-256; implementation follows the specified SHA-256 operations/constants. | Native known-answer/unit tests in `playable_stem_admission/sha256.rs`; hosted exact-head receipt still absent. | +| NIST FIPS 180-4 SHA-256 | Complete-file content identity uses SHA-256; implementation follows the specified SHA-256 operations/constants. | Native known-answer/unit tests in `playable_stem_admission/sha256.rs`; hosted exact-head receipt is still absent. | | NIST CAVP Secure Hashing | Test vectors may informally check correctness but do not confer validation. | Documentation and module rustdoc explicitly prohibit a CAVP/FIPS validation claim. | | RIFF/WAVE chunk model | Verify `RIFF`/`WAVE`, `fmt ` and `data` semantics and RIFF size relationships. | `validate_wave_header` plus malformed-header unit test. | | BandScope path-free contract | Python metadata cannot choose a path; native derives fixed locations. | `PlayableStemArtifactSetReference::derive_artifact_path` plus native exact-membership/containment checks. | -| BandScope single playback authority | Preflight must not become a second transport or renderer filesystem capability. | `PreflightPlayableStemSet` is native-only and non-serializable; no playback registration exists yet. | +| BandScope single playback authority | Stem files extend the current revocable authority instead of creating another transport store. | Shared native identity primitive, latest-job token, atomic four-stem map, opaque protocol routes, replacement-revocation tests. | ## Threat cases and current result | Threat / defect | Current result | Remaining risk | | --- | --- | --- | -| Extra file in artifact-set directory | Rejected before any set result is returned. | Directory may still change after preflight; authority binding must use native file identity. | +| Extra file in artifact-set directory | Rejected before any set result is returned. | Hosted Windows/macOS exact-head evidence is still required. | | Symlinked stem | Rejected on Unix test path; Windows reparse attribute is rejected in production code. | Windows exact-head executable evidence is still required. | -| Same-size content replacement before preflight | Complete-file SHA-256 mismatch rejects it. | Replacement after preflight remains a TOCTOU risk until #971 binds native file identity. | -| Malformed PCM header with matching hash metadata | Header contract rejects it before authority. | Future producer-format changes require a versioned contract, not silent widening. | -| Partial four-stem publication | Exact directory membership and all-four iteration prevent a partial set result. | Atomic playback-authority registration is not implemented yet. | -| Renderer path disclosure | Preflight types do not serialize and process parsing strips native metadata from renderer status. | Future selector must continue to receive opaque handles only. | +| Same-size content replacement before preflight | Complete-file SHA-256 mismatch rejects it. | None at the metadata/hash boundary; exact-head execution is still required. | +| Same-size path replacement after preflight | Reopened file identity differs and serving fails with `GONE`. | In-place mutation semantics still depend on the platform identity primitive and need exact-head platform evidence. | +| Older same-project analysis finishes after a newer job | `job_id` generation token prevents the older result from replacing the newer stem set. | The terminal status and native artifact reference still travel through separate in-process bookkeeping paths; see current RED. | +| Partial four-stem publication | Exact directory membership, all-four preflight and atomic authority installation prevent partial registration. | Renderer selector has not shipped. | +| Project/source replacement | One authority replacement revokes full mix, pending stem token and generated stems together. | Reopened-project persistence/recovery remains the #962 boundary. | +| Renderer path disclosure | Analysis status strips native metadata and playback protocol uses project/stem tokens only. | The UI contract must continue to consume opaque handles only. | ## Current RED and acceptance boundary -The Tauri JSONL reader validates `playableStemArtifactSet` but currently projects only `renderer_status()` into job state and discards the retained native artifact reference. Consequently, the new preflight service is production source but not yet invoked by the job lifecycle, and PR #971 still registers only the full mix. +The source-to-native-authority path is now connected, but it is not GREEN. -The next causal repair is: - -```text -successful terminal AnalysisProcessStatus -→ retain native artifact-set reference -→ preflight against the request-owned project temp root -→ bind all four opened-file identities atomically into #971 PlaybackAuthority -→ mint opaque renderer source handles -``` +First, the current JSONL worker forwards renderer status through one channel while retaining native stem metadata separately. Binding currently chooses the last retained native stem reference after process completion. A malformed or future producer sequence could emit a succeeded status with stems and then a later succeeded status without stems, leaving the earlier native reference eligible for binding. The next causal repair is to retain the **final `AnalysisProcessStatus` envelope as one unit** so the artifact reference used for authority binding is the one attached to the exact final terminal status, while still emitting only renderer-safe `AnalysisJobStatus` updates. -A preflight or binding failure must register **zero** stems and must not invalidate an otherwise valid full-mix source or successful rehearsal analysis. Project/source replacement must revoke the stem set together with the full-mix authority. +Second, no shipped source selector yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The next buyer-visible slice must use opaque handles from the existing protocol and verify source changes without resetting rehearsal position/range semantics, then cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, and rights-cleared audible macOS/Windows behavior. -This document does not mark that path GREEN. The stacked PR currently has no hosted exact-head workflow run, and local Rust compilation is not acceptance evidence. ADR-0001 stays Proposed until current-head build/test/coverage/security/review evidence and rights-cleared audible macOS/Windows behavior satisfy its acceptance criteria. +Third, the stacked exact head has no hosted pull-request workflow run. Local/unit source evidence cannot substitute for current-head Rust format/test/Clippy/coverage, repository/central CI/security/SAST/dependency/SBOM/package/release/review evidence. ADR-0001 therefore stays Proposed and this PR stays Draft. ## References From e80061d5afbb4b68ebfe7262e52a3a27eb061f13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:34:06 +0900 Subject: [PATCH 026/160] test(player): bind stems only from final process status --- .../core/tests/analysis_process_status.rs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/analysis_process_status.rs b/apps/desktop/core/tests/analysis_process_status.rs index 19c4c375a..ad80cb5a5 100644 --- a/apps/desktop/core/tests/analysis_process_status.rs +++ b/apps/desktop/core/tests/analysis_process_status.rs @@ -1,7 +1,9 @@ //! Process-boundary tests for native-only playable-stem status metadata. use bandscope_desktop_core::{ - analysis_process_status::{parse_analysis_process_status, AnalysisProcessStatus}, + analysis_process_status::{ + parse_analysis_process_status, retain_latest_process_status, AnalysisProcessStatus, + }, AnalysisJobState, }; use serde_json::{json, Value}; @@ -147,6 +149,46 @@ fn isolates_native_artifact_reference_from_renderer_status() { ); } +#[test] +fn final_process_status_replaces_an_earlier_stem_reference() { + let mut succeeded_with_stems = succeeded_status(); + succeeded_with_stems + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + let first_process_status = parse_status_value(succeeded_with_stems) + .expect("succeeded status with a complete stem set should parse"); + let final_process_status = parse_status_value(succeeded_status()) + .expect("a later succeeded status without stems should parse"); + + let mut latest_process_status = None; + let first_renderer_status = + retain_latest_process_status(&mut latest_process_status, first_process_status); + assert!(matches!( + first_renderer_status.state, + AnalysisJobState::Succeeded + )); + assert!(latest_process_status + .as_ref() + .and_then(AnalysisProcessStatus::playable_stem_artifact_set) + .is_some()); + + let final_renderer_status = + retain_latest_process_status(&mut latest_process_status, final_process_status); + assert!(matches!( + final_renderer_status.state, + AnalysisJobState::Succeeded + )); + assert!(latest_process_status + .as_ref() + .expect("the final process envelope should be retained") + .playable_stem_artifact_set() + .is_none()); +} + #[test] fn preserves_legacy_status_without_playable_stem_metadata() { let process_status = From e545e1915d3f946cda5973f01028d496b35629e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:34:20 +0900 Subject: [PATCH 027/160] fix(player): retain exact final process status --- apps/desktop/core/src/analysis_process_status.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs index cab7f154e..d1815d223 100644 --- a/apps/desktop/core/src/analysis_process_status.rs +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -44,6 +44,22 @@ impl AnalysisProcessStatus { } } +/// Replace the retained native process envelope with the newest validated status. +/// +/// Renderer delivery receives a clone containing only public status fields while +/// native callers keep the complete envelope. Replacing the whole envelope is +/// intentional: a later terminal status without a stem reference must revoke an +/// earlier status's native reference instead of leaving stale metadata eligible +/// for playback-authority binding. +pub fn retain_latest_process_status( + latest_process_status: &mut Option, + process_status: AnalysisProcessStatus, +) -> AnalysisJobStatus { + let renderer_status = process_status.renderer_status.clone(); + *latest_process_status = Some(process_status); + renderer_status +} + /// Parse one JSONL status and isolate its optional native-only artifact reference. pub fn parse_analysis_process_status( process_status_json: &str, From a9bc5a87694cf8236ae39e4d4f253cfaf23a0fe8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:35:56 +0900 Subject: [PATCH 028/160] fix(player): couple stem binding to final process envelope --- apps/desktop/src-tauri/src/main.rs | 94 +++++++++++++++++------------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 7d9e31927..4edc9f74d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -330,15 +330,18 @@ fn renderer_bootstrap_summary( Ok(summary) } -fn drain_analysis_status_updates( +fn drain_analysis_process_status_updates( state: &AppState, app: &tauri::AppHandle, - status_rx: &mpsc::Receiver, - last_status: &mut Option, + process_status_rx: &mpsc::Receiver, + latest_process_status: &mut Option, ) { - while let Ok(status) = status_rx.try_recv() { - store_status_and_emit(state, app, &status); - *last_status = Some(status); + while let Ok(process_status) = process_status_rx.try_recv() { + let renderer_status = analysis_process_status::retain_latest_process_status( + latest_process_status, + process_status, + ); + store_status_and_emit(state, app, &renderer_status); } } @@ -408,12 +411,11 @@ fn run_analysis_engine( "Analysis engine is unavailable.", ); }; - let (status_tx, status_rx) = mpsc::channel::(); - let (stem_tx, stem_rx) = - mpsc::channel::(); + let (process_status_tx, process_status_rx) = + mpsc::channel::(); let stdout_reader = thread::spawn(move || { let reader = BufReader::new(stdout); - let mut last_status = None; + let mut latest_process_status = None; for line in reader.lines() { let Ok(line) = line else { break; @@ -425,19 +427,13 @@ fn run_analysis_engine( if let Ok(process_status) = analysis_process_status::parse_analysis_process_status(trimmed) { - if let Some(playable_stem_artifact_set) = - process_status.playable_stem_artifact_set().cloned() - { - let _ = stem_tx.send(playable_stem_artifact_set); - } - let status = process_status.renderer_status().clone(); - last_status = Some(status.clone()); - if status_tx.send(status).is_err() { + latest_process_status = Some(process_status.clone()); + if process_status_tx.send(process_status).is_err() { break; } } } - last_status + latest_process_status }); let stderr_reader = thread::spawn(move || { let mut reader = stderr; @@ -465,10 +461,15 @@ fn run_analysis_engine( } let deadline = Instant::now() + ANALYSIS_PROCESS_TIMEOUT; - let mut last_status = None; + let mut latest_process_status = None; let exit_status; loop { - drain_analysis_status_updates(&state, &app, &status_rx, &mut last_status); + drain_analysis_process_status_updates( + &state, + &app, + &process_status_rx, + &mut latest_process_status, + ); match process.try_wait() { Ok(Some(status)) => { exit_status = status; @@ -509,11 +510,16 @@ fn run_analysis_engine( } } } - let reader_last_status = stdout_reader.join().unwrap_or(None); + let reader_latest_process_status = stdout_reader.join().unwrap_or(None); let _ = stderr_reader.join(); - drain_analysis_status_updates(&state, &app, &status_rx, &mut last_status); - if last_status.is_none() { - last_status = reader_last_status; + drain_analysis_process_status_updates( + &state, + &app, + &process_status_rx, + &mut latest_process_status, + ); + if latest_process_status.is_none() { + latest_process_status = reader_latest_process_status; } if !exit_status.success() { @@ -528,26 +534,30 @@ fn run_analysis_engine( ); } - let finished = last_status.unwrap_or_else(|| { - failed_status( - payload["jobId"] - .as_str() - .unwrap_or("unknown-job") - .to_string(), - requested_at, - AnalysisJobErrorCode::EngineUnavailable, - "Analysis engine returned an invalid response.", - ) - }); + let finished = latest_process_status + .as_ref() + .map(|process_status| process_status.renderer_status().clone()) + .unwrap_or_else(|| { + failed_status( + payload["jobId"] + .as_str() + .unwrap_or("unknown-job") + .to_string(), + requested_at, + AnalysisJobErrorCode::EngineUnavailable, + "Analysis engine returned an invalid response.", + ) + }); if matches!(finished.state, AnalysisJobState::Succeeded) { + let final_artifact_set = latest_process_status + .as_ref() + .and_then(|process_status| process_status.playable_stem_artifact_set()); if let (Some(project_id), Some(temp_root), Some(artifact_set)) = ( playback_project_id.as_deref(), playback_temp_root.as_deref(), - stem_rx.try_iter().last(), + final_artifact_set, ) { - if let Ok(preflight) = - preflight_playable_stem_set(Path::new(temp_root), &artifact_set) - { + if let Ok(preflight) = preflight_playable_stem_set(Path::new(temp_root), artifact_set) { let _ = playback_authority.activate_stems(project_id, &playback_job_id, &preflight); } } @@ -887,7 +897,7 @@ fn read_score_pdf( if !is_valid_project_id(&project_id) { return Err("Invalid project id.".to_string()); } - let scores_root = scores_root_for_project(&app, &project_id)?; + let scores_root = scores_root_for_project(&app, "projects", &project_id)?; let path = resolve_existing_score_pdf(&scores_root, &score_id)?; std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string()) } @@ -907,7 +917,7 @@ fn remove_score_pdf( if !is_valid_score_id(&score_id) { return Err("Invalid score id.".to_string()); } - let scores_root = scores_root_for_project(&app, &project_id)?; + let scores_root = scores_root_for_project(&app, "projects", &project_id)?; let path = match resolve_existing_score_pdf(&scores_root, &score_id) { Ok(path) => path, Err(_) => return Ok(false), From 85c474c177306b13fc54eda76fecb517cdcd7801 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:37:34 +0900 Subject: [PATCH 029/160] fix(player): preserve score workspace calls --- apps/desktop/src-tauri/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4edc9f74d..b6d3fb90f 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -897,7 +897,7 @@ fn read_score_pdf( if !is_valid_project_id(&project_id) { return Err("Invalid project id.".to_string()); } - let scores_root = scores_root_for_project(&app, "projects", &project_id)?; + let scores_root = scores_root_for_project(&app, &project_id)?; let path = resolve_existing_score_pdf(&scores_root, &score_id)?; std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string()) } @@ -917,7 +917,7 @@ fn remove_score_pdf( if !is_valid_score_id(&score_id) { return Err("Invalid score id.".to_string()); } - let scores_root = scores_root_for_project(&app, "projects", &project_id)?; + let scores_root = scores_root_for_project(&app, &project_id)?; let path = match resolve_existing_score_pdf(&scores_root, &score_id) { Ok(path) => path, Err(_) => return Ok(false), From df86bac1de3a0ad5eb2e493f094f09daef19c689 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:39:00 +0900 Subject: [PATCH 030/160] docs(player): trace final-envelope authority coupling --- .../playable-stem-native-admission.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md index d14130346..5106f1a75 100644 --- a/docs/traceability/playable-stem-native-admission.md +++ b/docs/traceability/playable-stem-native-admission.md @@ -3,9 +3,9 @@ - **Status:** Draft implementation evidence; not a release or acceptance claim - **Date:** 2026-09-04 - **Bounded contexts:** Source Separation → Native Resource Admission → Active Player -- **Source implementation head before this documentation update:** `feat/playable-stem-native-contract-961@0f7c7749eef648374077f927415caed980a31479` -- **Parent publication owner:** PR #1159, `dbafdd7a60d849960e620d071f2657088fa292da` -- **Canonical playback owner:** PR #971, `d1ca68d5dee882db0a7442ebf425c87e5cb618f4` +- **Source implementation head before this documentation update:** `feat/playable-stem-native-contract-961@85c474c177306b13fc54eda76fecb517cdcd7801` +- **Parent publication owner:** PR #1159, `22a9f18d960cc7df93db890b2a5aa9594428c2b4` +- **Canonical playback owner:** PR #971, `9c1b20e6df778e303fada3e170c93418c496394b` - **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. ## Problem and trust boundary @@ -45,6 +45,8 @@ Native file identity is now one shared desktop primitive in `native_file_identit `activate_stems` first constructs all four canonical source authorities from preflighted path/size/identity values, then takes the existing playback-authority lock and installs the complete set only if the current project and latest-analysis job token still match. A partial set cannot be registered. A stale same-project analysis cannot overwrite a newer generation. Project/source replacement revokes the full mix, generation token and prior stems together. +The JSONL worker now transports one validated `AnalysisProcessStatus` envelope per update instead of splitting renderer status and stem metadata into independent channels. The native worker retains the whole newest envelope while emitting only its renderer-safe `AnalysisJobStatus`. Authority binding consults `playableStemArtifactSet` only on that exact final retained envelope. A later succeeded status without stem metadata therefore replaces an earlier succeeded-with-stems envelope and cannot leave the earlier native reference eligible for binding. + The custom protocol accepts only opaque native tokens: ```text @@ -76,6 +78,7 @@ For this preflight increment, the native admission module therefore owns a small | RIFF/WAVE chunk model | Verify `RIFF`/`WAVE`, `fmt ` and `data` semantics and RIFF size relationships. | `validate_wave_header` plus malformed-header unit test. | | BandScope path-free contract | Python metadata cannot choose a path; native derives fixed locations. | `PlayableStemArtifactSetReference::derive_artifact_path` plus native exact-membership/containment checks. | | BandScope single playback authority | Stem files extend the current revocable authority instead of creating another transport store. | Shared native identity primitive, latest-job token, atomic four-stem map, opaque protocol routes, replacement-revocation tests. | +| Exact final process envelope | Native artifact metadata must belong to the same final status returned to the renderer. | `retain_latest_process_status` regression covers succeeded-with-stems followed by succeeded-without-stems; `run_analysis_engine` channels whole envelopes. Hosted exact-head receipt is still absent. | ## Threat cases and current result @@ -85,20 +88,22 @@ For this preflight increment, the native admission module therefore owns a small | Symlinked stem | Rejected on Unix test path; Windows reparse attribute is rejected in production code. | Windows exact-head executable evidence is still required. | | Same-size content replacement before preflight | Complete-file SHA-256 mismatch rejects it. | None at the metadata/hash boundary; exact-head execution is still required. | | Same-size path replacement after preflight | Reopened file identity differs and serving fails with `GONE`. | In-place mutation semantics still depend on the platform identity primitive and need exact-head platform evidence. | -| Older same-project analysis finishes after a newer job | `job_id` generation token prevents the older result from replacing the newer stem set. | The terminal status and native artifact reference still travel through separate in-process bookkeeping paths; see current RED. | +| Older same-project analysis finishes after a newer job | `job_id` generation token prevents the older result from replacing the newer stem set. | Exact-head executable evidence is still required. | +| Earlier succeeded status has stems, later succeeded status does not | Whole-envelope replacement means only the final status can contribute a stem reference. | Exact-head executable evidence is still required. | +| Non-empty malformed/unknown JSONL status after a valid status | `parse_analysis_process_status` rejects that line, but the worker currently skips parser failures and can retain the previous valid envelope. | Native worker must propagate parser failure so malformed producer output cannot fall back to earlier status. | | Partial four-stem publication | Exact directory membership, all-four preflight and atomic authority installation prevent partial registration. | Renderer selector has not shipped. | | Project/source replacement | One authority replacement revokes full mix, pending stem token and generated stems together. | Reopened-project persistence/recovery remains the #962 boundary. | | Renderer path disclosure | Analysis status strips native metadata and playback protocol uses project/stem tokens only. | The UI contract must continue to consume opaque handles only. | ## Current RED and acceptance boundary -The source-to-native-authority path is now connected, but it is not GREEN. +The exact-final-status stale-reference defect is repaired in production source, but the source-to-native-authority path is not GREEN. -First, the current JSONL worker forwards renderer status through one channel while retaining native stem metadata separately. Binding currently chooses the last retained native stem reference after process completion. A malformed or future producer sequence could emit a succeeded status with stems and then a later succeeded status without stems, leaving the earlier native reference eligible for binding. The next causal repair is to retain the **final `AnalysisProcessStatus` envelope as one unit** so the artifact reference used for authority binding is the one attached to the exact final terminal status, while still emitting only renderer-safe `AnalysisJobStatus` updates. +First, `run_analysis_engine` still uses `if let Ok(process_status) = parse_analysis_process_status(...)` and silently skips a non-empty JSONL line that fails strict parsing. If a malformed or future producer emits a valid succeeded envelope and then an invalid status before exiting successfully, the worker can still retain and return the earlier valid envelope. The next minimal causal repair is to propagate parser failure through the stdout reader and fail the native analysis response rather than falling back to an earlier envelope. Empty lines can remain ignorable; invalid non-empty JSONL must not be. Second, no shipped source selector yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The next buyer-visible slice must use opaque handles from the existing protocol and verify source changes without resetting rehearsal position/range semantics, then cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, and rights-cleared audible macOS/Windows behavior. -Third, the stacked exact head has no hosted pull-request workflow run. Local/unit source evidence cannot substitute for current-head Rust format/test/Clippy/coverage, repository/central CI/security/SAST/dependency/SBOM/package/release/review evidence. ADR-0001 therefore stays Proposed and this PR stays Draft. +Third, the stacked exact head has no hosted pull-request workflow run. Source-level RED/GREEN commits and unit contracts cannot substitute for current-head Rust format/test/Clippy/coverage, repository/central CI/security/SAST/dependency/SBOM/package/release/review evidence. ADR-0001 therefore stays Proposed and this PR stays Draft. ## References From b3da1487d6d2586732c87cb6adbc31f299b6ce62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:40:19 +0900 Subject: [PATCH 031/160] test(player): fail closed on malformed JSONL status --- .../core/tests/analysis_process_status.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/analysis_process_status.rs b/apps/desktop/core/tests/analysis_process_status.rs index ad80cb5a5..dcb6caad2 100644 --- a/apps/desktop/core/tests/analysis_process_status.rs +++ b/apps/desktop/core/tests/analysis_process_status.rs @@ -2,7 +2,8 @@ use bandscope_desktop_core::{ analysis_process_status::{ - parse_analysis_process_status, retain_latest_process_status, AnalysisProcessStatus, + parse_analysis_process_status, parse_analysis_process_status_line, + retain_latest_process_status, AnalysisProcessStatus, }, AnalysisJobState, }; @@ -189,6 +190,27 @@ fn final_process_status_replaces_an_earlier_stem_reference() { .is_none()); } +#[test] +fn process_status_line_parser_ignores_only_blank_lines() { + assert!(parse_analysis_process_status_line(" \n\t") + .expect("blank JSONL line should be ignorable") + .is_none()); + + let malformed_error = parse_analysis_process_status_line("not-json") + .expect_err("non-empty malformed JSONL must fail closed"); + assert_eq!(malformed_error, PROCESS_STATUS_ERROR); + + let valid_status_json = serde_json::to_string(&succeeded_status()) + .expect("process status fixture should serialize"); + let valid_status = parse_analysis_process_status_line(&valid_status_json) + .expect("valid non-empty JSONL should parse") + .expect("valid non-empty JSONL should produce an envelope"); + assert!(matches!( + valid_status.renderer_status().state, + AnalysisJobState::Succeeded + )); +} + #[test] fn preserves_legacy_status_without_playable_stem_metadata() { let process_status = From a4242438c487fa9939a47909f403a2b764c862a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:41:53 +0900 Subject: [PATCH 032/160] fix(player): fail closed on invalid process status lines --- apps/desktop/core/src/analysis_process_status.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs index d1815d223..86b78dedd 100644 --- a/apps/desktop/core/src/analysis_process_status.rs +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -60,6 +60,22 @@ pub fn retain_latest_process_status( renderer_status } +/// Parse one stdout JSONL line, ignoring only whitespace-only separators. +/// +/// A non-empty malformed line is a process-contract failure. Returning an error +/// instead of skipping it prevents an earlier valid envelope from becoming the +/// apparent final result after corrupted or future producer output. +pub fn parse_analysis_process_status_line( + process_status_line: &str, +) -> Result, &'static str> { + let trimmed = process_status_line.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + parse_analysis_process_status(trimmed).map(Some) + } +} + /// Parse one JSONL status and isolate its optional native-only artifact reference. pub fn parse_analysis_process_status( process_status_json: &str, From ee5c2086da5d9a37503dcd1c8a09bc72db63202a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:44:14 +0900 Subject: [PATCH 033/160] fix(player): reject malformed analysis JSONL --- apps/desktop/src-tauri/src/main.rs | 40 +++++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index b6d3fb90f..7523df505 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -417,23 +417,19 @@ fn run_analysis_engine( let reader = BufReader::new(stdout); let mut latest_process_status = None; for line in reader.lines() { - let Ok(line) = line else { - break; - }; - let trimmed = line.trim(); - if trimmed.is_empty() { + let line = line.map_err(|_| ())?; + let Some(process_status) = + analysis_process_status::parse_analysis_process_status_line(&line) + .map_err(|_| ())? + else { continue; - } - if let Ok(process_status) = - analysis_process_status::parse_analysis_process_status(trimmed) - { - latest_process_status = Some(process_status.clone()); - if process_status_tx.send(process_status).is_err() { - break; - } + }; + latest_process_status = Some(process_status.clone()); + if process_status_tx.send(process_status).is_err() { + break; } } - latest_process_status + Ok::<_, ()>(latest_process_status) }); let stderr_reader = thread::spawn(move || { let mut reader = stderr; @@ -510,7 +506,21 @@ fn run_analysis_engine( } } } - let reader_latest_process_status = stdout_reader.join().unwrap_or(None); + let reader_latest_process_status = match stdout_reader.join() { + Ok(Ok(latest_process_status)) => latest_process_status, + _ => { + let _ = stderr_reader.join(); + return failed_status( + payload["jobId"] + .as_str() + .unwrap_or("unknown-job") + .to_string(), + requested_at, + AnalysisJobErrorCode::EngineUnavailable, + "Analysis engine returned an invalid response.", + ); + } + }; let _ = stderr_reader.join(); drain_analysis_process_status_updates( &state, From fe9bb2158c18ad8d588b3d2a31080a3f08d5b170 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:46:38 +0900 Subject: [PATCH 034/160] test(player): enforce analysis process identity and terminal state --- .../core/tests/analysis_process_contract.rs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 apps/desktop/core/tests/analysis_process_contract.rs diff --git a/apps/desktop/core/tests/analysis_process_contract.rs b/apps/desktop/core/tests/analysis_process_contract.rs new file mode 100644 index 000000000..9f0f70d18 --- /dev/null +++ b/apps/desktop/core/tests/analysis_process_contract.rs @@ -0,0 +1,156 @@ +//! Process-contract invariants for the native analysis JSONL boundary. + +use bandscope_desktop_core::{ + analysis_process_status::{ + parse_analysis_process_status, validate_analysis_process_status_for_job, + validate_final_analysis_process_status, + }, + AnalysisJobState, +}; +use serde_json::{json, Value}; + +const PROCESS_STATUS_ERROR: &str = "Analysis engine returned an invalid response."; +const JOB_ID: &str = "job-process-contract"; + +fn status(state: &str) -> Value { + let mut value = json!({ + "jobId": JOB_ID, + "state": state, + "requestedAt": "2026-09-04T00:00:00Z", + "updatedAt": "2026-09-04T00:00:01Z" + }); + let object = value + .as_object_mut() + .expect("analysis status fixture must remain an object"); + match state { + "succeeded" => { + object.insert("progressStage".into(), json!("ready")); + object.insert("progressPercent".into(), json!(100)); + object.insert( + "result".into(), + json!({ + "id": "rights-cleared-song", + "title": "Rights-cleared fixture", + "sections": [], + "exportSummary": { + "format": "cue-sheet", + "headline": "Check the first section.", + "focusSections": [] + } + }), + ); + } + "failed" => { + object.insert( + "error".into(), + json!({ + "code": "engine_unavailable", + "message": "Analysis failed." + }), + ); + } + _ => {} + } + value +} + +fn parse(value: Value) -> Result { + parse_analysis_process_status( + &serde_json::to_string(&value).expect("analysis status fixture should serialize"), + ) +} + +#[test] +fn process_status_job_identity_must_match_the_requested_job() { + let process_status = parse(status("succeeded")).expect("valid succeeded status should parse"); + + validate_analysis_process_status_for_job(&process_status, JOB_ID) + .expect("matching process job identity should be accepted"); + let error = validate_analysis_process_status_for_job(&process_status, "job-other") + .expect_err("mismatched process job identity must fail closed"); + assert_eq!(error, PROCESS_STATUS_ERROR); +} + +#[test] +fn final_process_status_must_be_terminal() { + for state in ["queued", "running"] { + let process_status = parse(status(state)).expect("nonterminal status shape should parse"); + let error = validate_final_analysis_process_status(Some(&process_status), JOB_ID) + .expect_err("process exit with nonterminal status must fail closed"); + assert_eq!(error, PROCESS_STATUS_ERROR); + } + + let succeeded = parse(status("succeeded")).expect("succeeded status should parse"); + assert!(matches!( + validate_final_analysis_process_status(Some(&succeeded), JOB_ID) + .expect("succeeded status is terminal") + .renderer_status() + .state, + AnalysisJobState::Succeeded + )); + + let failed = parse(status("failed")).expect("failed status should parse"); + assert!(matches!( + validate_final_analysis_process_status(Some(&failed), JOB_ID) + .expect("failed status is terminal") + .renderer_status() + .state, + AnalysisJobState::Failed + )); + + let missing = validate_final_analysis_process_status(None, JOB_ID) + .expect_err("successful process exit without a status must fail closed"); + assert_eq!(missing, PROCESS_STATUS_ERROR); +} + +#[test] +fn rejects_contradictory_state_payloads_without_stem_metadata() { + let mut succeeded_without_result = status("succeeded"); + succeeded_without_result + .as_object_mut() + .expect("status fixture must remain an object") + .remove("result"); + + let mut succeeded_with_error = status("succeeded"); + succeeded_with_error + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "error".into(), + json!({"code": "engine_unavailable", "message": "Contradictory status."}), + ); + + let failed_without_error = status("failed") + .as_object_mut() + .map(|object| { + object.remove("error"); + }); + assert!(failed_without_error.is_some()); + let mut failed_without_error = status("failed"); + failed_without_error + .as_object_mut() + .expect("status fixture must remain an object") + .remove("error"); + + let mut queued_with_result = status("queued"); + queued_with_result + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "result".into(), + status("succeeded") + .get("result") + .expect("succeeded fixture should include result") + .clone(), + ); + + for invalid in [ + succeeded_without_result, + succeeded_with_error, + failed_without_error, + queued_with_result, + ] { + let error = parse(invalid).expect_err("contradictory state payload must fail closed"); + assert_eq!(error, PROCESS_STATUS_ERROR); + } +} From 00d0abf89a418a13ffecd66e6515d2d7cdca4509 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:47:10 +0900 Subject: [PATCH 035/160] test(player): keep process contract RED focused --- apps/desktop/core/tests/analysis_process_contract.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/desktop/core/tests/analysis_process_contract.rs b/apps/desktop/core/tests/analysis_process_contract.rs index 9f0f70d18..f94d783c6 100644 --- a/apps/desktop/core/tests/analysis_process_contract.rs +++ b/apps/desktop/core/tests/analysis_process_contract.rs @@ -54,7 +54,9 @@ fn status(state: &str) -> Value { value } -fn parse(value: Value) -> Result { +fn parse( + value: Value, +) -> Result { parse_analysis_process_status( &serde_json::to_string(&value).expect("analysis status fixture should serialize"), ) @@ -120,12 +122,6 @@ fn rejects_contradictory_state_payloads_without_stem_metadata() { json!({"code": "engine_unavailable", "message": "Contradictory status."}), ); - let failed_without_error = status("failed") - .as_object_mut() - .map(|object| { - object.remove("error"); - }); - assert!(failed_without_error.is_some()); let mut failed_without_error = status("failed"); failed_without_error .as_object_mut() From d18ede4eef44e7032a82a6085e6c99fa964cbda8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:47:47 +0900 Subject: [PATCH 036/160] fix(player): validate process identity and terminal state --- .../core/src/analysis_process_status.rs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs index 86b78dedd..e2d03720c 100644 --- a/apps/desktop/core/src/analysis_process_status.rs +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -60,6 +60,35 @@ pub fn retain_latest_process_status( renderer_status } +/// Require a process envelope to belong to the native job that requested it. +pub fn validate_analysis_process_status_for_job( + process_status: &AnalysisProcessStatus, + expected_job_id: &str, +) -> Result<(), &'static str> { + if process_status.renderer_status.job_id == expected_job_id { + Ok(()) + } else { + Err(PROCESS_STATUS_ERROR) + } +} + +/// Require the process's final retained envelope to be terminal and job-local. +pub fn validate_final_analysis_process_status<'a>( + process_status: Option<&'a AnalysisProcessStatus>, + expected_job_id: &str, +) -> Result<&'a AnalysisProcessStatus, &'static str> { + let process_status = process_status.ok_or(PROCESS_STATUS_ERROR)?; + validate_analysis_process_status_for_job(process_status, expected_job_id)?; + if matches!( + &process_status.renderer_status.state, + AnalysisJobState::Succeeded | AnalysisJobState::Failed + ) { + Ok(process_status) + } else { + Err(PROCESS_STATUS_ERROR) + } +} + /// Parse one stdout JSONL line, ignoring only whitespace-only separators. /// /// A non-empty malformed line is a process-contract failure. Returning an error @@ -95,10 +124,19 @@ pub fn parse_analysis_process_status( .transpose() .map_err(|_| PROCESS_STATUS_ERROR)?; + let state_payload_is_valid = match &renderer_status.state { + AnalysisJobState::Succeeded => renderer_status.result.is_some() && renderer_status.error.is_none(), + AnalysisJobState::Failed => renderer_status.result.is_none() && renderer_status.error.is_some(), + AnalysisJobState::Queued | AnalysisJobState::Running => { + renderer_status.result.is_none() && renderer_status.error.is_none() + } + }; + if !state_payload_is_valid { + return Err(PROCESS_STATUS_ERROR); + } + if playable_stem_artifact_set.is_some() - && (!matches!(&renderer_status.state, AnalysisJobState::Succeeded) - || renderer_status.result.is_none() - || renderer_status.error.is_some()) + && !matches!(&renderer_status.state, AnalysisJobState::Succeeded) { return Err(PROCESS_STATUS_ERROR); } From ceea2787a3d23ae3b8ecf705508a0eb7e049077f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:49:26 +0900 Subject: [PATCH 037/160] fix(player): bind analysis output to requested job --- apps/desktop/src-tauri/src/main.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 7523df505..3ed94c74d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -387,6 +387,7 @@ fn run_analysis_engine( let playback_project_id = request.project_id.clone(); let playback_temp_root = request.temp_root.clone(); let playback_job_id = job_id.clone(); + let process_job_id = job_id.clone(); let payload = json!({ "jobId": job_id.clone(), "request": request, @@ -424,6 +425,11 @@ fn run_analysis_engine( else { continue; }; + analysis_process_status::validate_analysis_process_status_for_job( + &process_status, + &process_job_id, + ) + .map_err(|_| ())?; latest_process_status = Some(process_status.clone()); if process_status_tx.send(process_status).is_err() { break; @@ -544,11 +550,13 @@ fn run_analysis_engine( ); } - let finished = latest_process_status - .as_ref() - .map(|process_status| process_status.renderer_status().clone()) - .unwrap_or_else(|| { - failed_status( + let final_process_status = match analysis_process_status::validate_final_analysis_process_status( + latest_process_status.as_ref(), + &playback_job_id, + ) { + Ok(process_status) => process_status, + Err(_) => { + return failed_status( payload["jobId"] .as_str() .unwrap_or("unknown-job") @@ -557,11 +565,11 @@ fn run_analysis_engine( AnalysisJobErrorCode::EngineUnavailable, "Analysis engine returned an invalid response.", ) - }); + } + }; + let finished = final_process_status.renderer_status().clone(); if matches!(finished.state, AnalysisJobState::Succeeded) { - let final_artifact_set = latest_process_status - .as_ref() - .and_then(|process_status| process_status.playable_stem_artifact_set()); + let final_artifact_set = final_process_status.playable_stem_artifact_set(); if let (Some(project_id), Some(temp_root), Some(artifact_set)) = ( playback_project_id.as_deref(), playback_temp_root.as_deref(), From b92291f4e8b25bdac0e8d209bf7c45b38142f2f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:50:05 +0900 Subject: [PATCH 038/160] test(player): avoid moving terminal state from shared status --- apps/desktop/core/tests/analysis_process_contract.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/tests/analysis_process_contract.rs b/apps/desktop/core/tests/analysis_process_contract.rs index f94d783c6..33f9fc58f 100644 --- a/apps/desktop/core/tests/analysis_process_contract.rs +++ b/apps/desktop/core/tests/analysis_process_contract.rs @@ -84,7 +84,7 @@ fn final_process_status_must_be_terminal() { let succeeded = parse(status("succeeded")).expect("succeeded status should parse"); assert!(matches!( - validate_final_analysis_process_status(Some(&succeeded), JOB_ID) + &validate_final_analysis_process_status(Some(&succeeded), JOB_ID) .expect("succeeded status is terminal") .renderer_status() .state, @@ -93,7 +93,7 @@ fn final_process_status_must_be_terminal() { let failed = parse(status("failed")).expect("failed status should parse"); assert!(matches!( - validate_final_analysis_process_status(Some(&failed), JOB_ID) + &validate_final_analysis_process_status(Some(&failed), JOB_ID) .expect("failed status is terminal") .renderer_status() .state, From 68b308302f90865d0f596526c917cb60c615c7d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:50:28 +0900 Subject: [PATCH 039/160] style(player): keep process contract rustfmt-ready --- apps/desktop/core/src/analysis_process_status.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs index e2d03720c..9b584fcb2 100644 --- a/apps/desktop/core/src/analysis_process_status.rs +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -125,8 +125,12 @@ pub fn parse_analysis_process_status( .map_err(|_| PROCESS_STATUS_ERROR)?; let state_payload_is_valid = match &renderer_status.state { - AnalysisJobState::Succeeded => renderer_status.result.is_some() && renderer_status.error.is_none(), - AnalysisJobState::Failed => renderer_status.result.is_none() && renderer_status.error.is_some(), + AnalysisJobState::Succeeded => { + renderer_status.result.is_some() && renderer_status.error.is_none() + } + AnalysisJobState::Failed => { + renderer_status.result.is_none() && renderer_status.error.is_some() + } AnalysisJobState::Queued | AnalysisJobState::Running => { renderer_status.result.is_none() && renderer_status.error.is_none() } From 17f0e8826609278fec0371c1eb94a48695dce943 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:51:23 +0900 Subject: [PATCH 040/160] docs(player): trace fail-closed process contract --- .../playable-stem-native-admission.md | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md index 5106f1a75..c2130d80a 100644 --- a/docs/traceability/playable-stem-native-admission.md +++ b/docs/traceability/playable-stem-native-admission.md @@ -3,7 +3,7 @@ - **Status:** Draft implementation evidence; not a release or acceptance claim - **Date:** 2026-09-04 - **Bounded contexts:** Source Separation → Native Resource Admission → Active Player -- **Source implementation head before this documentation update:** `feat/playable-stem-native-contract-961@85c474c177306b13fc54eda76fecb517cdcd7801` +- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@68b308302f90865d0f596526c917cb60c615c7d0` - **Parent publication owner:** PR #1159, `22a9f18d960cc7df93db890b2a5aa9594428c2b4` - **Canonical playback owner:** PR #971, `9c1b20e6df778e303fada3e170c93418c496394b` - **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. @@ -39,13 +39,13 @@ No path returned by the Python subprocess is accepted. Native paths, hashes and The 44-byte layout is deliberately a **BandScope producer contract**, not a claim that every valid WAVE file has a 44-byte header. RIFF is chunk-based and permits other chunk arrangements. BandScope can be stricter because PR #1159 owns the producer and Python's `wave` writer emits the canonical PCM layout consumed by this internal contract. -Native file identity is now one shared desktop primitive in `native_file_identity.rs`. Unix uses device/inode plus ctime; Windows uses volume serial, file index and last-write time from the opened handle. `playable_stem_admission` and the pre-existing full-mix playback authority both consume that primitive. This avoids a second, divergent identity implementation. +Native file identity is one shared desktop primitive in `native_file_identity.rs`. Unix uses device/inode plus ctime; Windows uses volume serial, file index and last-write time from the opened handle. `playable_stem_admission` and the pre-existing full-mix playback authority both consume that primitive rather than maintaining divergent identity implementations. -`PlaybackAuthority` now keeps the full mix and an optional complete four-stem map under the same revocation mutex. When a local-audio analysis is queued, `begin_stem_analysis(project_id, job_id)` makes that job the only generation allowed to register stems and clears any older generated set. A successful terminal analysis can retain its native artifact reference, preflight it against the request-owned temp root, and call `activate_stems(project_id, job_id, preflight)`. +`PlaybackAuthority` keeps the full mix and an optional complete four-stem map under the same revocation mutex. When local-audio analysis is queued, `begin_stem_analysis(project_id, job_id)` makes that job the only generation allowed to register stems and clears an older generated set. `activate_stems(project_id, job_id, preflight)` constructs all four canonical source authorities before taking the authority lock, then installs the complete set only when the current project and latest-analysis job token still match. Project/source replacement revokes full mix, generation token and generated stems together. -`activate_stems` first constructs all four canonical source authorities from preflighted path/size/identity values, then takes the existing playback-authority lock and installs the complete set only if the current project and latest-analysis job token still match. A partial set cannot be registered. A stale same-project analysis cannot overwrite a newer generation. Project/source replacement revokes the full mix, generation token and prior stems together. +The JSONL worker transports one validated `AnalysisProcessStatus` envelope per update. It emits only renderer-safe `AnalysisJobStatus`, retains the complete newest native envelope, and reads `playableStemArtifactSet` only from the exact final retained envelope. A later succeeded status without stem metadata therefore replaces an earlier succeeded-with-stems envelope. -The JSONL worker now transports one validated `AnalysisProcessStatus` envelope per update instead of splitting renderer status and stem metadata into independent channels. The native worker retains the whole newest envelope while emitting only its renderer-safe `AnalysisJobStatus`. Authority binding consults `playableStemArtifactSet` only on that exact final retained envelope. A later succeeded status without stem metadata therefore replaces an earlier succeeded-with-stems envelope and cannot leave the earlier native reference eligible for binding. +The process contract now also fails closed on malformed non-empty JSONL, reader errors, producer job-ID mismatch, contradictory state payloads, absence of a final status, and successful process exit whose final state is still `queued` or `running`. Whitespace-only JSONL separators remain ignorable. A `succeeded` status requires a result and no error; a `failed` status requires an error and no result; `queued`/`running` may carry neither. These invariants are checked before producer status can be accepted for the requested native job. The custom protocol accepts only opaque native tokens: @@ -78,7 +78,8 @@ For this preflight increment, the native admission module therefore owns a small | RIFF/WAVE chunk model | Verify `RIFF`/`WAVE`, `fmt ` and `data` semantics and RIFF size relationships. | `validate_wave_header` plus malformed-header unit test. | | BandScope path-free contract | Python metadata cannot choose a path; native derives fixed locations. | `PlayableStemArtifactSetReference::derive_artifact_path` plus native exact-membership/containment checks. | | BandScope single playback authority | Stem files extend the current revocable authority instead of creating another transport store. | Shared native identity primitive, latest-job token, atomic four-stem map, opaque protocol routes, replacement-revocation tests. | -| Exact final process envelope | Native artifact metadata must belong to the same final status returned to the renderer. | `retain_latest_process_status` regression covers succeeded-with-stems followed by succeeded-without-stems; `run_analysis_engine` channels whole envelopes. Hosted exact-head receipt is still absent. | +| Exact final process envelope | Native artifact metadata must belong to the same final status returned to the renderer. | Whole-envelope retention regression plus production `run_analysis_engine` coupling. Hosted exact-head receipt is still absent. | +| Fail-closed JSONL/job contract | Invalid non-empty JSONL, mismatched job identity, contradictory state payloads and nonterminal final output are not accepted as a successful native process result. | `analysis_process_status` and `analysis_process_contract` Rust regressions; production stdout reader propagates parse/identity failure. Hosted exact-head receipt is still absent. | ## Threat cases and current result @@ -90,20 +91,23 @@ For this preflight increment, the native admission module therefore owns a small | Same-size path replacement after preflight | Reopened file identity differs and serving fails with `GONE`. | In-place mutation semantics still depend on the platform identity primitive and need exact-head platform evidence. | | Older same-project analysis finishes after a newer job | `job_id` generation token prevents the older result from replacing the newer stem set. | Exact-head executable evidence is still required. | | Earlier succeeded status has stems, later succeeded status does not | Whole-envelope replacement means only the final status can contribute a stem reference. | Exact-head executable evidence is still required. | -| Non-empty malformed/unknown JSONL status after a valid status | `parse_analysis_process_status` rejects that line, but the worker currently skips parser failures and can retain the previous valid envelope. | Native worker must propagate parser failure so malformed producer output cannot fall back to earlier status. | +| Non-empty malformed/unknown JSONL status after a valid status | Reader returns a process-contract error; the native result becomes failed instead of falling back to the previous envelope. | An earlier renderer event may have been emitted before a later malformed line is discovered; no stem authority is bound until final process validation. | +| Status for another job ID | Rejected before storage/emission by the native stdout reader. | Exact-head executable evidence is still required. | +| Process exits successfully with queued/running final status | Rejected as an invalid native response. | Exact-head executable evidence is still required. | +| Contradictory succeeded/failed/result/error payload | Rejected by strict process-status semantics even without stem metadata. | Exact-head executable evidence is still required. | | Partial four-stem publication | Exact directory membership, all-four preflight and atomic authority installation prevent partial registration. | Renderer selector has not shipped. | | Project/source replacement | One authority replacement revokes full mix, pending stem token and generated stems together. | Reopened-project persistence/recovery remains the #962 boundary. | | Renderer path disclosure | Analysis status strips native metadata and playback protocol uses project/stem tokens only. | The UI contract must continue to consume opaque handles only. | ## Current RED and acceptance boundary -The exact-final-status stale-reference defect is repaired in production source, but the source-to-native-authority path is not GREEN. +The native process and file-admission source contracts are substantially connected, but the source-to-audible vertical is not GREEN. -First, `run_analysis_engine` still uses `if let Ok(process_status) = parse_analysis_process_status(...)` and silently skips a non-empty JSONL line that fails strict parsing. If a malformed or future producer emits a valid succeeded envelope and then an invalid status before exiting successfully, the worker can still retain and return the earlier valid envelope. The next minimal causal repair is to propagate parser failure through the stdout reader and fail the native analysis response rather than falling back to an earlier envelope. Empty lines can remain ignorable; invalid non-empty JSONL must not be. +One remaining process-ordering edge is buyer-visible rather than authority-level: an otherwise valid status can be emitted to the renderer before a later malformed JSONL line causes the process result to fail. No stem authority is installed before final validation, but a terminal renderer state should not flash as accepted before the subprocess stream itself is known to be valid. The next process-boundary increment should buffer terminal producer status until subprocess completion while continuing to emit genuine queued/running progress, then emit only the validated final terminal envelope. -Second, no shipped source selector yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The next buyer-visible slice must use opaque handles from the existing protocol and verify source changes without resetting rehearsal position/range semantics, then cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, and rights-cleared audible macOS/Windows behavior. +No shipped source selector yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The buyer-visible slice must use opaque handles from the existing protocol and verify source changes without resetting rehearsal position/range semantics, then cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, locale expansion, and rights-cleared audible macOS/Windows behavior. -Third, the stacked exact head has no hosted pull-request workflow run. Source-level RED/GREEN commits and unit contracts cannot substitute for current-head Rust format/test/Clippy/coverage, repository/central CI/security/SAST/dependency/SBOM/package/release/review evidence. ADR-0001 therefore stays Proposed and this PR stays Draft. +The stacked exact head has no hosted pull-request workflow run. Source-level RED/fix commits and unit contracts cannot substitute for current-head Rust format/test/Clippy/coverage, repository/central CI/security/SAST/dependency/SBOM/package/release/review evidence. ADR-0001 therefore stays Proposed and this PR stays Draft. ## References From d4f6a8e53edb81686a84387b4ed892b03879c035 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:52:45 +0900 Subject: [PATCH 041/160] test(player): buffer terminal process status --- .../core/tests/analysis_process_contract.rs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/desktop/core/tests/analysis_process_contract.rs b/apps/desktop/core/tests/analysis_process_contract.rs index 33f9fc58f..1ed52b015 100644 --- a/apps/desktop/core/tests/analysis_process_contract.rs +++ b/apps/desktop/core/tests/analysis_process_contract.rs @@ -2,8 +2,9 @@ use bandscope_desktop_core::{ analysis_process_status::{ - parse_analysis_process_status, validate_analysis_process_status_for_job, - validate_final_analysis_process_status, + is_analysis_process_progress_status, parse_analysis_process_status, + validate_analysis_process_status_for_job, validate_final_analysis_process_status, + AnalysisProcessStatus, }, AnalysisJobState, }; @@ -54,9 +55,7 @@ fn status(state: &str) -> Value { value } -fn parse( - value: Value, -) -> Result { +fn parse(value: Value) -> Result { parse_analysis_process_status( &serde_json::to_string(&value).expect("analysis status fixture should serialize"), ) @@ -105,6 +104,19 @@ fn final_process_status_must_be_terminal() { assert_eq!(missing, PROCESS_STATUS_ERROR); } +#[test] +fn only_nonterminal_status_is_renderer_progress() { + for state in ["queued", "running"] { + let process_status = parse(status(state)).expect("progress status should parse"); + assert!(is_analysis_process_progress_status(&process_status)); + } + + for state in ["succeeded", "failed"] { + let process_status = parse(status(state)).expect("terminal status should parse"); + assert!(!is_analysis_process_progress_status(&process_status)); + } +} + #[test] fn rejects_contradictory_state_payloads_without_stem_metadata() { let mut succeeded_without_result = status("succeeded"); From 15769719901aeb391b6df83543f0408412ad9254 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:53:03 +0900 Subject: [PATCH 042/160] fix(player): classify renderer progress separately --- apps/desktop/core/src/analysis_process_status.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs index 9b584fcb2..beb0935fd 100644 --- a/apps/desktop/core/src/analysis_process_status.rs +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -72,6 +72,18 @@ pub fn validate_analysis_process_status_for_job( } } +/// Return whether a validated status is safe to expose as in-flight progress. +/// +/// Producer terminal states are withheld until the subprocess exits and the +/// complete JSONL stream is known to be valid. This prevents an early succeeded +/// event from being observed before a later malformed line fails the process. +pub fn is_analysis_process_progress_status(process_status: &AnalysisProcessStatus) -> bool { + matches!( + &process_status.renderer_status.state, + AnalysisJobState::Queued | AnalysisJobState::Running + ) +} + /// Require the process's final retained envelope to be terminal and job-local. pub fn validate_final_analysis_process_status<'a>( process_status: Option<&'a AnalysisProcessStatus>, From 810c4b88d8186c083363e068dc7b67acd0ce1c60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 20:05:16 +0900 Subject: [PATCH 043/160] fix(player): buffer producer terminal status until exit --- apps/desktop/src-tauri/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 3ed94c74d..612106fd3 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -337,11 +337,15 @@ fn drain_analysis_process_status_updates( latest_process_status: &mut Option, ) { while let Ok(process_status) = process_status_rx.try_recv() { + let emit_as_progress = + analysis_process_status::is_analysis_process_progress_status(&process_status); let renderer_status = analysis_process_status::retain_latest_process_status( latest_process_status, process_status, ); - store_status_and_emit(state, app, &renderer_status); + if emit_as_progress { + store_status_and_emit(state, app, &renderer_status); + } } } From 91cc5ddecc4d59855811f9d170c1fa55065ed85a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 20:06:04 +0900 Subject: [PATCH 044/160] docs(player): trace terminal-event buffering --- .../playable-stem-native-admission.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md index c2130d80a..49b567c68 100644 --- a/docs/traceability/playable-stem-native-admission.md +++ b/docs/traceability/playable-stem-native-admission.md @@ -3,7 +3,7 @@ - **Status:** Draft implementation evidence; not a release or acceptance claim - **Date:** 2026-09-04 - **Bounded contexts:** Source Separation → Native Resource Admission → Active Player -- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@68b308302f90865d0f596526c917cb60c615c7d0` +- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@810c4b88d8186c083363e068dc7b67acd0ce1c60` - **Parent publication owner:** PR #1159, `22a9f18d960cc7df93db890b2a5aa9594428c2b4` - **Canonical playback owner:** PR #971, `9c1b20e6df778e303fada3e170c93418c496394b` - **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. @@ -43,9 +43,9 @@ Native file identity is one shared desktop primitive in `native_file_identity.rs `PlaybackAuthority` keeps the full mix and an optional complete four-stem map under the same revocation mutex. When local-audio analysis is queued, `begin_stem_analysis(project_id, job_id)` makes that job the only generation allowed to register stems and clears an older generated set. `activate_stems(project_id, job_id, preflight)` constructs all four canonical source authorities before taking the authority lock, then installs the complete set only when the current project and latest-analysis job token still match. Project/source replacement revokes full mix, generation token and generated stems together. -The JSONL worker transports one validated `AnalysisProcessStatus` envelope per update. It emits only renderer-safe `AnalysisJobStatus`, retains the complete newest native envelope, and reads `playableStemArtifactSet` only from the exact final retained envelope. A later succeeded status without stem metadata therefore replaces an earlier succeeded-with-stems envelope. +The JSONL worker transports one validated `AnalysisProcessStatus` envelope per update. It retains the complete newest native envelope and exposes only renderer-safe `AnalysisJobStatus`. During subprocess execution, only genuine `queued` and `running` producer updates are stored/emitted as progress. Producer `succeeded` and `failed` envelopes are retained natively but withheld from the renderer until process exit, complete stdout validation, final-job/state validation, and—when applicable—stem preflight/authority binding have finished. The outer analysis worker remains the single terminal `store_status_and_emit` owner. -The process contract now also fails closed on malformed non-empty JSONL, reader errors, producer job-ID mismatch, contradictory state payloads, absence of a final status, and successful process exit whose final state is still `queued` or `running`. Whitespace-only JSONL separators remain ignorable. A `succeeded` status requires a result and no error; a `failed` status requires an error and no result; `queued`/`running` may carry neither. These invariants are checked before producer status can be accepted for the requested native job. +The process stream reads `playableStemArtifactSet` only from the exact final retained envelope. A later succeeded status without stem metadata therefore replaces an earlier succeeded-with-stems envelope. The process contract also fails closed on malformed non-empty JSONL, reader errors, producer job-ID mismatch, contradictory state payloads, absence of a final status, and successful process exit whose final state is still `queued` or `running`. Whitespace-only JSONL separators remain ignorable. A `succeeded` status requires a result and no error; a `failed` status requires an error and no result; `queued`/`running` may carry neither. These invariants are checked before producer status can be accepted for the requested native job. The custom protocol accepts only opaque native tokens: @@ -80,6 +80,7 @@ For this preflight increment, the native admission module therefore owns a small | BandScope single playback authority | Stem files extend the current revocable authority instead of creating another transport store. | Shared native identity primitive, latest-job token, atomic four-stem map, opaque protocol routes, replacement-revocation tests. | | Exact final process envelope | Native artifact metadata must belong to the same final status returned to the renderer. | Whole-envelope retention regression plus production `run_analysis_engine` coupling. Hosted exact-head receipt is still absent. | | Fail-closed JSONL/job contract | Invalid non-empty JSONL, mismatched job identity, contradictory state payloads and nonterminal final output are not accepted as a successful native process result. | `analysis_process_status` and `analysis_process_contract` Rust regressions; production stdout reader propagates parse/identity failure. Hosted exact-head receipt is still absent. | +| Terminal-event ordering | Producer terminal states are not buyer-visible until subprocess exit and complete stream validation. | `only_nonterminal_status_is_renderer_progress` plus production `drain_analysis_process_status_updates` gating; hosted exact-head receipt is still absent. | ## Threat cases and current result @@ -91,7 +92,7 @@ For this preflight increment, the native admission module therefore owns a small | Same-size path replacement after preflight | Reopened file identity differs and serving fails with `GONE`. | In-place mutation semantics still depend on the platform identity primitive and need exact-head platform evidence. | | Older same-project analysis finishes after a newer job | `job_id` generation token prevents the older result from replacing the newer stem set. | Exact-head executable evidence is still required. | | Earlier succeeded status has stems, later succeeded status does not | Whole-envelope replacement means only the final status can contribute a stem reference. | Exact-head executable evidence is still required. | -| Non-empty malformed/unknown JSONL status after a valid status | Reader returns a process-contract error; the native result becomes failed instead of falling back to the previous envelope. | An earlier renderer event may have been emitted before a later malformed line is discovered; no stem authority is bound until final process validation. | +| Non-empty malformed/unknown JSONL status after a valid terminal status | Reader returns a process-contract error; the native result becomes failed, and the earlier producer terminal state was never emitted to the renderer. | Exact-head executable evidence is still required. | | Status for another job ID | Rejected before storage/emission by the native stdout reader. | Exact-head executable evidence is still required. | | Process exits successfully with queued/running final status | Rejected as an invalid native response. | Exact-head executable evidence is still required. | | Contradictory succeeded/failed/result/error payload | Rejected by strict process-status semantics even without stem metadata. | Exact-head executable evidence is still required. | @@ -101,13 +102,11 @@ For this preflight increment, the native admission module therefore owns a small ## Current RED and acceptance boundary -The native process and file-admission source contracts are substantially connected, but the source-to-audible vertical is not GREEN. +The native process/file-admission and terminal-ordering source contracts are now connected, but the source-to-audible vertical is not GREEN. Hosted exact-head Rust format/test/Clippy/coverage and repository/central gates have not yet established current-head executable evidence for this stacked branch, so source-level regressions and source inspection are non-passing evidence. -One remaining process-ordering edge is buyer-visible rather than authority-level: an otherwise valid status can be emitted to the renderer before a later malformed JSONL line causes the process result to fail. No stem authority is installed before final validation, but a terminal renderer state should not flash as accepted before the subprocess stream itself is known to be valid. The next process-boundary increment should buffer terminal producer status until subprocess completion while continuing to emit genuine queued/running progress, then emit only the validated final terminal envelope. +The next buyer-visible gap is the source selector: no shipped control yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The slice must use only opaque handles from the existing playback authority, preserve position/loop/range semantics across source changes, and cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, KO/EN/JA/ZH/VI/ES/DE/FR expansion, and rights-cleared audible macOS/Windows behavior. It must not claim guitar/keyboard identity for the `other` stem. -No shipped source selector yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The buyer-visible slice must use opaque handles from the existing protocol and verify source changes without resetting rehearsal position/range semantics, then cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, locale expansion, and rights-cleared audible macOS/Windows behavior. - -The stacked exact head has no hosted pull-request workflow run. Source-level RED/fix commits and unit contracts cannot substitute for current-head Rust format/test/Clippy/coverage, repository/central CI/security/SAST/dependency/SBOM/package/release/review evidence. ADR-0001 therefore stays Proposed and this PR stays Draft. +The stacked exact head has no qualifying hosted pull-request workflow receipt at the time of this documentation update. ADR-0001 therefore stays Proposed and this PR stays Draft. ## References From 25cbd3baaa83da7af653d20c54ceee7422560d2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:09:26 +0900 Subject: [PATCH 045/160] test(player): require canonical stem authority handles --- .../RehearsalPlayer.audioAuthority.test.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx index 5b8e6bb2b..765da69df 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx @@ -1,7 +1,10 @@ import { render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { RehearsalPlayer } from "./RehearsalPlayer"; +import { + isPlayableAudioSource, + RehearsalPlayer, +} from "./RehearsalPlayer"; const originalTauriInternals = Object.getOwnPropertyDescriptor( window, @@ -93,6 +96,38 @@ describe("RehearsalPlayer audio authority", () => { ); }); + it("converts only canonical generated-stem authority handles", () => { + const convertFileSrc = vi.fn( + (source: string) => `bandscope-playback://localhost/${source}`, + ); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + + for (const stem of ["vocals", "bass", "drums", "other"]) { + expect( + isPlayableAudioSource( + `bandscope-project://project-100-1/stem/${stem}`, + ), + ).toBe(true); + } + expect(convertFileSrc).toHaveBeenCalledWith( + "project-100-1/stem/vocals", + "bandscope-playback", + ); + expect( + isPlayableAudioSource( + "bandscope-project://project-100-1/stem/guitar", + ), + ).toBe(false); + expect( + isPlayableAudioSource( + "bandscope-project://project-100-1/stem/vocals/../private.wav", + ), + ).toBe(false); + }); + it("does not expose a native path when no current playback project authority exists", () => { const convertFileSrc = vi.fn(() => "asset://localhost/private-rehearsal.wav"); Object.defineProperty(window, "__TAURI_INTERNALS__", { From 83ebdfded5732882cd8ad264619c46438dc6aefd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:11:23 +0900 Subject: [PATCH 046/160] fix(player): admit canonical opaque stem handles --- .../features/workspace/RehearsalPlayer.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 14d582265..fe12f227d 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -56,6 +56,7 @@ interface RehearsalPlayerProps { const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://"; const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/; +const PLAYBACK_SOURCE_SUFFIX = /^(?:\/stem\/(?:vocals|bass|drums|other))?$/; /** Convert an opaque current-project authority into BandScope's native media URL. */ function resolveAudioSourceUrl( @@ -64,12 +65,22 @@ function resolveAudioSourceUrl( if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) { return null; } - const projectId = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); - if (!PLAYBACK_PROJECT_ID.test(projectId)) { + const authorityToken = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); + const separatorIndex = authorityToken.indexOf("/"); + const projectId = + separatorIndex === -1 + ? authorityToken + : authorityToken.slice(0, separatorIndex); + const sourceSuffix = + separatorIndex === -1 ? "" : authorityToken.slice(separatorIndex); + if ( + !PLAYBACK_PROJECT_ID.test(projectId) || + !PLAYBACK_SOURCE_SUFFIX.test(sourceSuffix) + ) { return null; } try { - return convertFileSrc(projectId, "bandscope-playback"); + return convertFileSrc(`${projectId}${sourceSuffix}`, "bandscope-playback"); } catch { return null; } @@ -1185,4 +1196,4 @@ export function RehearsalPlayer({ /> ); -} \ No newline at end of file +} From 8093c536e1be4e07cf8f3fa5c185c8afdfb49648 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:06:04 +0900 Subject: [PATCH 047/160] test(player): define fail-closed stem source availability contract --- .../workspace/playbackSourceSelection.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSelection.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts new file mode 100644 index 000000000..9eee5fb42 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { derivePlaybackSourceOptions } from "./playbackSourceSelection"; + +const fullMix = "bandscope-project://project-100-1"; +const stems = { + vocals: `${fullMix}/stem/vocals`, + bass: `${fullMix}/stem/bass`, + drums: `${fullMix}/stem/drums`, + other: `${fullMix}/stem/other`, +} as const; + +describe("playback source selection authority", () => { + it("projects one complete native authority set into canonical rehearsal order", () => { + expect( + derivePlaybackSourceOptions(fullMix, [ + stems.other, + stems.drums, + fullMix, + stems.vocals, + stems.bass, + ]), + ).toEqual([ + { kind: "full_mix", authority: fullMix }, + { kind: "vocals", authority: stems.vocals }, + { kind: "bass", authority: stems.bass }, + { kind: "drums", authority: stems.drums }, + { kind: "other", authority: stems.other }, + ]); + }); + + it("keeps full mix usable while no generated stems are registered", () => { + expect(derivePlaybackSourceOptions(fullMix, [fullMix])).toEqual([ + { kind: "full_mix", authority: fullMix }, + ]); + }); + + it("fails closed when native availability claims only part of the atomic four-stem set", () => { + expect( + derivePlaybackSourceOptions(fullMix, [fullMix, stems.vocals, stems.bass]), + ).toBeNull(); + }); + + it.each([ + ["duplicate authority", [fullMix, fullMix]], + [ + "mismatched project", + [fullMix, "bandscope-project://project-101-2/stem/vocals"], + ], + ["unknown stem", [fullMix, `${fullMix}/stem/guitar`]], + ["path-shaped suffix", [fullMix, `${fullMix}/stem/vocals/../private.wav`]], + ["non-string entry", [fullMix, 42]], + ["non-array payload", { fullMix }], + ])("rejects %s instead of inventing a buyer-visible source", (_label, payload) => { + expect(derivePlaybackSourceOptions(fullMix, payload)).toBeNull(); + }); + + it("rejects stale availability after the current project authority rotates", () => { + expect( + derivePlaybackSourceOptions("bandscope-project://project-101-2", [ + fullMix, + stems.vocals, + stems.bass, + stems.drums, + stems.other, + ]), + ).toBeNull(); + }); +}); From 7929c74330684cab9c83b16ff3e037d6168773ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:06:31 +0900 Subject: [PATCH 048/160] feat(player): validate native stem availability before selector projection --- .../workspace/playbackSourceSelection.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSelection.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.ts new file mode 100644 index 000000000..5b74b4b3e --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.ts @@ -0,0 +1,84 @@ +export type PlaybackSourceKind = + | "full_mix" + | "vocals" + | "bass" + | "drums" + | "other"; + +export interface PlaybackSourceOption { + kind: PlaybackSourceKind; + authority: string; +} + +const FULL_MIX_AUTHORITY = /^bandscope-project:\/\/(project-[0-9]+-[0-9]+)$/; +const SOURCE_AUTHORITY = + /^bandscope-project:\/\/(project-[0-9]+-[0-9]+)(?:\/stem\/(vocals|bass|drums|other))?$/; +const STEM_ORDER = ["vocals", "bass", "drums", "other"] as const; + +/** + * Project native availability into renderer options without creating authority. + * A generated set is all-or-nothing because native admission binds four stems atomically. + */ +export function derivePlaybackSourceOptions( + currentFullMixAuthority: string | null | undefined, + availableAuthorities: unknown, +): PlaybackSourceOption[] | null { + const currentMatch = currentFullMixAuthority?.match(FULL_MIX_AUTHORITY); + if (!currentMatch || !Array.isArray(availableAuthorities)) { + return null; + } + + const projectId = currentMatch[1]; + const seen = new Set(); + let hasFullMix = false; + const stems = new Map<(typeof STEM_ORDER)[number], string>(); + + for (const candidate of availableAuthorities) { + if (typeof candidate !== "string" || seen.has(candidate)) { + return null; + } + seen.add(candidate); + + const match = candidate.match(SOURCE_AUTHORITY); + if (!match || match[1] !== projectId) { + return null; + } + + const stemKind = match[2] as (typeof STEM_ORDER)[number] | undefined; + if (stemKind === undefined) { + if (candidate !== currentFullMixAuthority || hasFullMix) { + return null; + } + hasFullMix = true; + continue; + } + + if (stems.has(stemKind)) { + return null; + } + stems.set(stemKind, candidate); + } + + if (!hasFullMix) { + return null; + } + + if (stems.size === 0) { + return [{ kind: "full_mix", authority: currentFullMixAuthority }]; + } + + if ( + stems.size !== STEM_ORDER.length || + STEM_ORDER.some((stemKind) => !stems.has(stemKind)) + ) { + return null; + } + + return [ + { kind: "full_mix", authority: currentFullMixAuthority }, + ...STEM_ORDER.map((stemKind) => ({ + kind: stemKind, + authority: stems.get(stemKind)!, + })), + ]; +} From 0701d99262f18e58483d05e92764247e27a098ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:07:14 +0900 Subject: [PATCH 049/160] fix(player): keep playback source projection type-safe and documented --- .../workspace/playbackSourceSelection.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.ts index 5b74b4b3e..290c1d2f9 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSelection.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.ts @@ -1,3 +1,4 @@ +/** Renderer-visible source kinds backed by the current native playback authority. */ export type PlaybackSourceKind = | "full_mix" | "vocals" @@ -5,6 +6,7 @@ export type PlaybackSourceKind = | "drums" | "other"; +/** One opaque, project-scoped source that the rehearsal player may select. */ export interface PlaybackSourceOption { kind: PlaybackSourceKind; authority: string; @@ -23,7 +25,10 @@ export function derivePlaybackSourceOptions( currentFullMixAuthority: string | null | undefined, availableAuthorities: unknown, ): PlaybackSourceOption[] | null { - const currentMatch = currentFullMixAuthority?.match(FULL_MIX_AUTHORITY); + if (typeof currentFullMixAuthority !== "string") { + return null; + } + const currentMatch = currentFullMixAuthority.match(FULL_MIX_AUTHORITY); if (!currentMatch || !Array.isArray(availableAuthorities)) { return null; } @@ -74,11 +79,17 @@ export function derivePlaybackSourceOptions( return null; } + const stemOptions: PlaybackSourceOption[] = []; + for (const stemKind of STEM_ORDER) { + const authority = stems.get(stemKind); + if (authority === undefined) { + return null; + } + stemOptions.push({ kind: stemKind, authority }); + } + return [ { kind: "full_mix", authority: currentFullMixAuthority }, - ...STEM_ORDER.map((stemKind) => ({ - kind: stemKind, - authority: stems.get(stemKind)!, - })), + ...stemOptions, ]; } From 7839a599ecf132658562ed11b41a3ed3a8345e89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:09:31 +0900 Subject: [PATCH 050/160] test(player): require atomic native source availability --- apps/desktop/src-tauri/src/lib.rs | 1 + .../src/playback_source_availability.rs | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 apps/desktop/src-tauri/src/playback_source_availability.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e44dd13e9..fef9da865 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5,3 +5,4 @@ pub mod native_file_identity; pub mod playable_stem_admission; +pub mod playback_source_availability; diff --git a/apps/desktop/src-tauri/src/playback_source_availability.rs b/apps/desktop/src-tauri/src/playback_source_availability.rs new file mode 100644 index 000000000..a2c900179 --- /dev/null +++ b/apps/desktop/src-tauri/src/playback_source_availability.rs @@ -0,0 +1,105 @@ +//! Renderer-safe source availability resolution for the current rehearsal project. +//! +//! Native playback authority remains the only owner of filesystem paths and file +//! identity. This module only decides whether an already-minted full mix and its +//! canonical four stems may be exposed together to the renderer. + +/// Generic buyer-safe error returned when native playback availability is not a +/// complete, current source set. +pub const PLAYBACK_SOURCE_AVAILABILITY_ERROR: &str = + "Could not read the current playback source availability."; + +/// Resolve one renderer-visible availability snapshot from native source probes. +/// +/// The full mix must remain available. Generated stems are either all present or +/// all absent; a partial set is rejected so the renderer never invents a mixed +/// generation or displays controls for sources that are not jointly authoritative. +pub fn resolve_playback_source_availability( + _full_mix_authority: String, + _stem_authorities: [String; 4], + _probe: impl FnMut(&str) -> Result, +) -> Result, String> { + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn authorities() -> (String, [String; 4]) { + ( + "bandscope-project://project-100-1".to_string(), + [ + "bandscope-project://project-100-1/stem/vocals".to_string(), + "bandscope-project://project-100-1/stem/bass".to_string(), + "bandscope-project://project-100-1/stem/drums".to_string(), + "bandscope-project://project-100-1/stem/other".to_string(), + ], + ) + } + + #[test] + fn full_mix_only_is_a_complete_availability_snapshot() { + let (full_mix, stems) = authorities(); + let full_mix_expected = full_mix.clone(); + let resolved = resolve_playback_source_availability(full_mix, stems, |authority| { + Ok(authority == full_mix_expected) + }) + .expect("full-mix-only authority should be visible"); + + assert_eq!(resolved, vec![full_mix_expected]); + } + + #[test] + fn complete_four_stem_set_is_exposed_in_canonical_order() { + let (full_mix, stems) = authorities(); + let expected = std::iter::once(full_mix.clone()) + .chain(stems.iter().cloned()) + .collect::>(); + let resolved = resolve_playback_source_availability(full_mix, stems, |_| Ok(true)) + .expect("complete authority set should be visible"); + + assert_eq!(resolved, expected); + } + + #[test] + fn partial_stem_availability_fails_closed() { + let (full_mix, stems) = authorities(); + let states = BTreeMap::from([ + (full_mix.clone(), true), + (stems[0].clone(), true), + (stems[1].clone(), false), + (stems[2].clone(), true), + (stems[3].clone(), true), + ]); + let resolved = resolve_playback_source_availability(full_mix, stems, |authority| { + Ok(*states.get(authority).unwrap_or(&false)) + }); + + assert_eq!( + resolved.as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + } + + #[test] + fn revoked_full_mix_and_probe_errors_fail_closed() { + let (full_mix, stems) = authorities(); + let revoked = resolve_playback_source_availability(full_mix.clone(), stems.clone(), |_| { + Ok(false) + }); + assert_eq!( + revoked.as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + + let probe_failure = resolve_playback_source_availability(full_mix, stems, |_| { + Err("native probe failed".to_string()) + }); + assert_eq!( + probe_failure.as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + } +} From 9e36c838eb06db03747307b8cf3bf71fe7d2c9c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:10:28 +0900 Subject: [PATCH 051/160] fix(player): keep native source availability atomic --- .../src/playback_source_availability.rs | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/playback_source_availability.rs b/apps/desktop/src-tauri/src/playback_source_availability.rs index a2c900179..896695fe5 100644 --- a/apps/desktop/src-tauri/src/playback_source_availability.rs +++ b/apps/desktop/src-tauri/src/playback_source_availability.rs @@ -15,11 +15,36 @@ pub const PLAYBACK_SOURCE_AVAILABILITY_ERROR: &str = /// all absent; a partial set is rejected so the renderer never invents a mixed /// generation or displays controls for sources that are not jointly authoritative. pub fn resolve_playback_source_availability( - _full_mix_authority: String, - _stem_authorities: [String; 4], - _probe: impl FnMut(&str) -> Result, + full_mix_authority: String, + stem_authorities: [String; 4], + mut probe: impl FnMut(&str) -> Result, ) -> Result, String> { - Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()) + let full_mix_is_available = probe(&full_mix_authority) + .map_err(|_| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + if !full_mix_is_available { + return Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()); + } + + let mut available_stem_count = 0usize; + for stem_authority in &stem_authorities { + if probe(stem_authority) + .map_err(|_| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())? + { + available_stem_count += 1; + } + } + + if available_stem_count == 0 { + return Ok(vec![full_mix_authority]); + } + if available_stem_count != stem_authorities.len() { + return Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()); + } + + let mut available = Vec::with_capacity(1 + stem_authorities.len()); + available.push(full_mix_authority); + available.extend(stem_authorities); + Ok(available) } #[cfg(test)] From ca4ed24f87dd4ce8994737b75753e68f9d54108c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:25:44 +0900 Subject: [PATCH 052/160] feat(player): expose native playback source availability --- apps/desktop/src-tauri/src/main.rs | 2 + .../playback_source_availability_command.rs | 168 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 apps/desktop/src-tauri/src/playback_source_availability_command.rs diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 612106fd3..eac5929e9 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod playback_protocol; +mod playback_source_availability_command; use bandscope_desktop::playable_stem_admission::preflight_playable_stem_set; use bandscope_desktop_core::*; @@ -963,6 +964,7 @@ fn main() { import_youtube_url, start_analysis_job, get_analysis_job_status, + playback_source_availability_command::get_playback_source_availability, save_project, load_project, attach_score_pdf, diff --git a/apps/desktop/src-tauri/src/playback_source_availability_command.rs b/apps/desktop/src-tauri/src/playback_source_availability_command.rs new file mode 100644 index 000000000..f94e907de --- /dev/null +++ b/apps/desktop/src-tauri/src/playback_source_availability_command.rs @@ -0,0 +1,168 @@ +//! Tauri IPC for renderer-safe rehearsal playback source discovery. +//! +//! The renderer supplies only the opaque full-mix authority it already owns. The +//! native process revalidates that authority against the current playback source +//! and returns only opaque handles that can still answer metadata probes. + +use crate::playback_protocol::{ + playback_authority_uri, playback_stem_authority_uri, PlaybackAuthority, + PLAYBACK_AUTHORITY_PREFIX, PLAYBACK_SCHEME, +}; +use bandscope_desktop::playback_source_availability::{ + resolve_playback_source_availability, PLAYBACK_SOURCE_AVAILABILITY_ERROR, +}; +use bandscope_desktop_core::{is_valid_project_id, playable_stem_contract::PlaybackStemKind}; +use std::sync::Arc; +use tauri::http::{Method, Request, StatusCode}; + +fn project_id_from_full_mix_authority(authority: &str) -> Result<&str, String> { + let project_id = authority + .strip_prefix(PLAYBACK_AUTHORITY_PREFIX) + .filter(|project_id| is_valid_project_id(project_id)) + .ok_or_else(|| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + if playback_authority_uri(project_id).as_deref() != Ok(authority) { + return Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()); + } + Ok(project_id) +} + +fn probe_playback_source( + playback_authority: &PlaybackAuthority, + authority: &str, +) -> Result { + let relative = authority + .strip_prefix(PLAYBACK_AUTHORITY_PREFIX) + .ok_or_else(|| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + let request = Request::builder() + .method(Method::HEAD) + .uri(format!("{PLAYBACK_SCHEME}://localhost/{relative}")) + .body(Vec::new()) + .map_err(|_| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + + match playback_authority.respond(request).status() { + StatusCode::OK => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + _ => Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()), + } +} + +fn current_playback_source_availability( + current_full_mix_authority: String, + playback_authority: &PlaybackAuthority, +) -> Result, String> { + let project_id = project_id_from_full_mix_authority(¤t_full_mix_authority)?; + let stem_authority_results = PlaybackStemKind::canonical_order() + .map(|stem_kind| playback_stem_authority_uri(project_id, stem_kind)); + let [vocals, bass, drums, other] = stem_authority_results; + let stem_authorities = [vocals?, bass?, drums?, other?]; + + resolve_playback_source_availability( + current_full_mix_authority, + stem_authorities, + |authority| probe_playback_source(playback_authority, authority), + ) +} + +/// Return the current full mix and, only when atomically available, all four +/// generated stem authorities. Native paths, hashes and file identities never +/// cross this IPC boundary. +#[tauri::command] +pub fn get_playback_source_availability( + current_full_mix_authority: String, + playback_authority: tauri::State<'_, Arc>, +) -> Result, String> { + current_playback_source_availability( + current_full_mix_authority, + playback_authority.inner().as_ref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use bandscope_desktop_core::LocalAudioSourcePayload; + use std::{path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + + fn test_source() -> (PathBuf, LocalAudioSourcePayload) { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-availability-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("test root should be created"); + let path = root.join("source.wav"); + std::fs::write(&path, b"audio").expect("test source should be written"); + let canonical = path.canonicalize().expect("test source should canonicalize"); + ( + root, + LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: 5, + }, + ) + } + + #[test] + fn current_full_mix_is_discoverable_without_exposing_native_source_data() { + let (root, source) = test_source(); + let authority = PlaybackAuthority::default(); + authority + .activate("project-700-1", &source) + .expect("test source should activate"); + let handle = playback_authority_uri("project-700-1") + .expect("test project should mint an authority"); + + let available = current_playback_source_availability(handle.clone(), &authority) + .expect("current full mix should be discoverable"); + + assert_eq!(available, vec![handle]); + assert!(available.iter().all(|value| !value.contains(root.to_string_lossy().as_ref()))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stale_or_path_shaped_full_mix_authority_fails_closed() { + let (root, source) = test_source(); + let authority = PlaybackAuthority::default(); + authority + .activate("project-710-1", &source) + .expect("test source should activate"); + + for candidate in [ + "bandscope-project://project-709-1", + "bandscope-project://project-710-1/stem/vocals", + "file:///private/source.wav", + ] { + assert_eq!( + current_playback_source_availability(candidate.to_string(), &authority).as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR), + "{candidate} must not discover current native authority" + ); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn mutated_full_mix_is_not_reported_as_available() { + let (root, source) = test_source(); + let authority = PlaybackAuthority::default(); + authority + .activate("project-720-1", &source) + .expect("test source should activate"); + let handle = playback_authority_uri("project-720-1") + .expect("test project should mint an authority"); + std::fs::write(&source.source_path, b"other") + .expect("test source should be mutated in place"); + + assert_eq!( + current_playback_source_availability(handle, &authority).as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + let _ = std::fs::remove_dir_all(root); + } +} From 2ac597861c82baa6c31a125fa50b958d93849e9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:30:56 +0900 Subject: [PATCH 053/160] docs(player): trace native source availability boundary --- .../playable-stem-native-admission.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md index 49b567c68..4154516fc 100644 --- a/docs/traceability/playable-stem-native-admission.md +++ b/docs/traceability/playable-stem-native-admission.md @@ -1,9 +1,9 @@ # Playable stem native admission traceability - **Status:** Draft implementation evidence; not a release or acceptance claim -- **Date:** 2026-09-04 +- **Date:** 2026-09-05 - **Bounded contexts:** Source Separation → Native Resource Admission → Active Player -- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@810c4b88d8186c083363e068dc7b67acd0ce1c60` +- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@ca4ed24f87dd4ce8994737b75753e68f9d54108c` - **Parent publication owner:** PR #1159, `22a9f18d960cc7df93db890b2a5aa9594428c2b4` - **Canonical playback owner:** PR #971, `9c1b20e6df778e303fada3e170c93418c496394b` - **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. @@ -59,6 +59,10 @@ The custom protocol accepts only opaque native tokens: Every serve reopens the canonical path and compares current native file identity with the identity captured during admission. Same-size replacement after preflight therefore fails closed rather than inheriting the old authority. No native path is embedded in the renderer-visible handle. +The new `get_playback_source_availability` Tauri command does not read `PlaybackAuthority` internals or export native metadata. The renderer must present the opaque full-mix authority it already owns. Native code remints the canonical four stem handles for that same project and probes the existing playback authority with metadata-only `HEAD` requests. The full mix must still be serveable. Stems are exposed only when all four canonical handles are simultaneously serveable; all-four absence yields a full-mix-only snapshot, while partial availability, stale project authority, file-identity failure, malformed/path-shaped authority, and probe errors fail closed. The command returns only opaque `bandscope-project://...` handles. + +`playback_source_availability.rs` owns the all-or-nothing availability decision independently of the Tauri command surface. Its tests require full-mix-only success, canonical all-four success, partial-set rejection, revoked-full-mix rejection and probe-error rejection. `playback_source_availability_command.rs` adds current-authority, stale/path-shaped authority and in-place mutation regressions at the native IPC edge. + A stem preflight or authority-binding failure deliberately does **not** turn an otherwise valid rehearsal analysis/full mix into a failed analysis. The buyer still has the full-mix rehearsal result; unavailable stems remain unavailable. ## SHA-256 decision @@ -78,6 +82,7 @@ For this preflight increment, the native admission module therefore owns a small | RIFF/WAVE chunk model | Verify `RIFF`/`WAVE`, `fmt ` and `data` semantics and RIFF size relationships. | `validate_wave_header` plus malformed-header unit test. | | BandScope path-free contract | Python metadata cannot choose a path; native derives fixed locations. | `PlayableStemArtifactSetReference::derive_artifact_path` plus native exact-membership/containment checks. | | BandScope single playback authority | Stem files extend the current revocable authority instead of creating another transport store. | Shared native identity primitive, latest-job token, atomic four-stem map, opaque protocol routes, replacement-revocation tests. | +| Renderer-safe availability | Availability may disclose only currently serveable opaque full mix plus an all-or-none canonical four-stem set. | `playback_source_availability` decision regressions and native Tauri command edge regressions; hosted exact-head receipt is still absent. | | Exact final process envelope | Native artifact metadata must belong to the same final status returned to the renderer. | Whole-envelope retention regression plus production `run_analysis_engine` coupling. Hosted exact-head receipt is still absent. | | Fail-closed JSONL/job contract | Invalid non-empty JSONL, mismatched job identity, contradictory state payloads and nonterminal final output are not accepted as a successful native process result. | `analysis_process_status` and `analysis_process_contract` Rust regressions; production stdout reader propagates parse/identity failure. Hosted exact-head receipt is still absent. | | Terminal-event ordering | Producer terminal states are not buyer-visible until subprocess exit and complete stream validation. | `only_nonterminal_status_is_renderer_progress` plus production `drain_analysis_process_status_updates` gating; hosted exact-head receipt is still absent. | @@ -97,14 +102,15 @@ For this preflight increment, the native admission module therefore owns a small | Process exits successfully with queued/running final status | Rejected as an invalid native response. | Exact-head executable evidence is still required. | | Contradictory succeeded/failed/result/error payload | Rejected by strict process-status semantics even without stem metadata. | Exact-head executable evidence is still required. | | Partial four-stem publication | Exact directory membership, all-four preflight and atomic authority installation prevent partial registration. | Renderer selector has not shipped. | +| Partial/stale renderer availability | Native availability requires a serveable current full mix and either zero or four serveable canonical stems; partial/stale/mutated cases fail closed. | `RehearsalPlayer` does not yet invoke the command or render a selector. | | Project/source replacement | One authority replacement revokes full mix, pending stem token and generated stems together. | Reopened-project persistence/recovery remains the #962 boundary. | -| Renderer path disclosure | Analysis status strips native metadata and playback protocol uses project/stem tokens only. | The UI contract must continue to consume opaque handles only. | +| Renderer path disclosure | Analysis status strips native metadata and playback/availability protocols use project/stem tokens only. | The UI contract must continue to consume opaque handles only. | ## Current RED and acceptance boundary -The native process/file-admission and terminal-ordering source contracts are now connected, but the source-to-audible vertical is not GREEN. Hosted exact-head Rust format/test/Clippy/coverage and repository/central gates have not yet established current-head executable evidence for this stacked branch, so source-level regressions and source inspection are non-passing evidence. +The native process/file-admission, terminal-ordering and renderer-safe availability source contracts are now connected, but the source-to-audible vertical is not GREEN. Hosted exact-head Rust format/test/Clippy/coverage and repository/central gates have not yet established current-head executable evidence for this stacked branch, so source-level regressions and source inspection are non-passing evidence. -The next buyer-visible gap is the source selector: no shipped control yet exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The slice must use only opaque handles from the existing playback authority, preserve position/loop/range semantics across source changes, and cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, KO/EN/JA/ZH/VI/ES/DE/FR expansion, and rights-cleared audible macOS/Windows behavior. It must not claim guitar/keyboard identity for the `other` stem. +The next buyer-visible gap is the actual source selector: no shipped control yet consumes `get_playback_source_availability` and exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The slice must revalidate the native payload with `derivePlaybackSourceOptions`, preserve position/loop/range/playback-rate semantics across source changes, and cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, KO/EN/JA/ZH/VI/ES/DE/FR expansion, and rights-cleared audible macOS/Windows behavior. It must not claim guitar/keyboard identity for the `other` stem. The stacked exact head has no qualifying hosted pull-request workflow receipt at the time of this documentation update. ADR-0001 therefore stays Proposed and this PR stays Draft. From 6bc0b49001de1df15c5f3755824ea537a681ec95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:34:34 +0900 Subject: [PATCH 054/160] test(player): require renderer-safe playback source discovery --- .../workspace/playbackSourceDiscovery.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts b/apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts new file mode 100644 index 000000000..99a88a11f --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { discoverPlaybackSourceOptions } from "./playbackSourceDiscovery"; + +const fullMix = "bandscope-project://project-100-1"; +const stems = [ + `${fullMix}/stem/vocals`, + `${fullMix}/stem/bass`, + `${fullMix}/stem/drums`, + `${fullMix}/stem/other`, +] as const; + +describe("playback source native discovery", () => { + it("invokes the native availability command with only the current opaque full-mix authority", async () => { + const invokeCommand = vi.fn().mockResolvedValue([ + stems[3], + fullMix, + stems[1], + stems[0], + stems[2], + ]); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toEqual([ + { kind: "full_mix", authority: fullMix }, + { kind: "vocals", authority: stems[0] }, + { kind: "bass", authority: stems[1] }, + { kind: "drums", authority: stems[2] }, + { kind: "other", authority: stems[3] }, + ]); + expect(invokeCommand).toHaveBeenCalledTimes(1); + expect(invokeCommand).toHaveBeenCalledWith( + "get_playback_source_availability", + { currentFullMixAuthority: fullMix }, + ); + }); + + it("keeps full mix usable when native authority has no registered stems", async () => { + const invokeCommand = vi.fn().mockResolvedValue([fullMix]); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toEqual([{ kind: "full_mix", authority: fullMix }]); + }); + + it.each([ + ["partial stem set", [fullMix, stems[0], stems[1]]], + ["stale project", ["bandscope-project://project-101-2"]], + ["native path", [fullMix, "/private/tmp/vocals.wav"]], + ["malformed payload", { fullMix }], + ])("fails closed on %s returned by IPC", async (_label, payload) => { + const invokeCommand = vi.fn().mockResolvedValue(payload); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toBeNull(); + }); + + it("fails closed without echoing native invocation failures", async () => { + const invokeCommand = vi + .fn() + .mockRejectedValue(new Error("/private/tmp/secret-source.wav")); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toBeNull(); + }); + + it.each([null, undefined, "file:///private/tmp/source.wav", `${fullMix}/stem/vocals`])( + "does not invoke native discovery for a non-full-mix authority: %s", + async (candidate) => { + const invokeCommand = vi.fn().mockResolvedValue([fullMix]); + + await expect( + discoverPlaybackSourceOptions(candidate, invokeCommand), + ).resolves.toBeNull(); + expect(invokeCommand).not.toHaveBeenCalled(); + }, + ); +}); From 42fa24999607a515b3eb20225a11aa4cac47279c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:35:06 +0900 Subject: [PATCH 055/160] feat(player): revalidate native playback source discovery in renderer --- .../workspace/playbackSourceDiscovery.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceDiscovery.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceDiscovery.ts b/apps/desktop/src/features/workspace/playbackSourceDiscovery.ts new file mode 100644 index 000000000..840b0377e --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceDiscovery.ts @@ -0,0 +1,42 @@ +import { + derivePlaybackSourceOptions, + type PlaybackSourceOption, +} from "./playbackSourceSelection"; + +/** Minimal invoke boundary used to discover renderer-safe native playback sources. */ +export type PlaybackSourceInvoke = ( + command: string, + args?: Record, +) => Promise; + +/** + * Discover the currently registered playback sources without creating authority. + * + * The caller must already own the current opaque full-mix authority. Native IPC + * may only return opaque authorities; every response is revalidated by the + * renderer projector before it can become a buyer-visible source option. + */ +export async function discoverPlaybackSourceOptions( + currentFullMixAuthority: string | null | undefined, + invokeCommand: PlaybackSourceInvoke, +): Promise { + if ( + derivePlaybackSourceOptions(currentFullMixAuthority, [currentFullMixAuthority]) === + null + ) { + return null; + } + + try { + const availableAuthorities = await invokeCommand( + "get_playback_source_availability", + { currentFullMixAuthority }, + ); + return derivePlaybackSourceOptions( + currentFullMixAuthority, + availableAuthorities, + ); + } catch { + return null; + } +} From 95f9ee4bfe534c9b3c8f9d942dc8bd55319a252e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:39:23 +0900 Subject: [PATCH 056/160] docs(player): trace renderer-safe source discovery --- .../playable-stem-native-admission.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/traceability/playable-stem-native-admission.md b/docs/traceability/playable-stem-native-admission.md index 4154516fc..0fc967cd2 100644 --- a/docs/traceability/playable-stem-native-admission.md +++ b/docs/traceability/playable-stem-native-admission.md @@ -3,7 +3,7 @@ - **Status:** Draft implementation evidence; not a release or acceptance claim - **Date:** 2026-09-05 - **Bounded contexts:** Source Separation → Native Resource Admission → Active Player -- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@ca4ed24f87dd4ce8994737b75753e68f9d54108c` +- **Implementation source head before this documentation update:** `feat/playable-stem-native-contract-961@42fa24999607a515b3eb20225a11aa4cac47279c` - **Parent publication owner:** PR #1159, `22a9f18d960cc7df93db890b2a5aa9594428c2b4` - **Canonical playback owner:** PR #971, `9c1b20e6df778e303fada3e170c93418c496394b` - **Decision:** ADR-0001 remains **Proposed** until the source-to-audible acceptance criteria are executable on one current stack. @@ -59,10 +59,12 @@ The custom protocol accepts only opaque native tokens: Every serve reopens the canonical path and compares current native file identity with the identity captured during admission. Same-size replacement after preflight therefore fails closed rather than inheriting the old authority. No native path is embedded in the renderer-visible handle. -The new `get_playback_source_availability` Tauri command does not read `PlaybackAuthority` internals or export native metadata. The renderer must present the opaque full-mix authority it already owns. Native code remints the canonical four stem handles for that same project and probes the existing playback authority with metadata-only `HEAD` requests. The full mix must still be serveable. Stems are exposed only when all four canonical handles are simultaneously serveable; all-four absence yields a full-mix-only snapshot, while partial availability, stale project authority, file-identity failure, malformed/path-shaped authority, and probe errors fail closed. The command returns only opaque `bandscope-project://...` handles. +The `get_playback_source_availability` Tauri command does not read `PlaybackAuthority` internals or export native metadata. The renderer must present the opaque full-mix authority it already owns. Native code remints the canonical four stem handles for that same project and probes the existing playback authority with metadata-only `HEAD` requests. The full mix must still be serveable. Stems are exposed only when all four canonical handles are simultaneously serveable; all-four absence yields a full-mix-only snapshot, while partial availability, stale project authority, file-identity failure, malformed/path-shaped authority, and probe errors fail closed. The command returns only opaque `bandscope-project://...` handles. `playback_source_availability.rs` owns the all-or-nothing availability decision independently of the Tauri command surface. Its tests require full-mix-only success, canonical all-four success, partial-set rejection, revoked-full-mix rejection and probe-error rejection. `playback_source_availability_command.rs` adds current-authority, stale/path-shaped authority and in-place mutation regressions at the native IPC edge. +The renderer does not trust this IPC result merely because it came from Tauri. `playbackSourceDiscovery.ts` accepts only the already-owned full-mix authority, reuses `derivePlaybackSourceOptions` to reject a non-full-mix/path-shaped authority before invoking native code, calls exactly `get_playback_source_availability`, and treats the returned value as `unknown`. The result must pass the same canonical project/all-or-none source projection before any option can be exposed. Invoke failures collapse to `null`; native error strings, paths and implementation details are not promoted into buyer-visible state. `playbackSourceDiscovery.test.ts` covers the exact command/argument shape, five-source ordering, full-mix-only behavior, partial/stale/native-path/malformed payload rejection, invocation failure and the no-invoke rule for invalid authorities. + A stem preflight or authority-binding failure deliberately does **not** turn an otherwise valid rehearsal analysis/full mix into a failed analysis. The buyer still has the full-mix rehearsal result; unavailable stems remain unavailable. ## SHA-256 decision @@ -82,7 +84,7 @@ For this preflight increment, the native admission module therefore owns a small | RIFF/WAVE chunk model | Verify `RIFF`/`WAVE`, `fmt ` and `data` semantics and RIFF size relationships. | `validate_wave_header` plus malformed-header unit test. | | BandScope path-free contract | Python metadata cannot choose a path; native derives fixed locations. | `PlayableStemArtifactSetReference::derive_artifact_path` plus native exact-membership/containment checks. | | BandScope single playback authority | Stem files extend the current revocable authority instead of creating another transport store. | Shared native identity primitive, latest-job token, atomic four-stem map, opaque protocol routes, replacement-revocation tests. | -| Renderer-safe availability | Availability may disclose only currently serveable opaque full mix plus an all-or-none canonical four-stem set. | `playback_source_availability` decision regressions and native Tauri command edge regressions; hosted exact-head receipt is still absent. | +| Renderer-safe availability | Availability may disclose only currently serveable opaque full mix plus an all-or-none canonical four-stem set, and the renderer independently revalidates the IPC `unknown` payload before it becomes options. | Native availability/Tauri command regressions plus `playbackSourceSelection` and `playbackSourceDiscovery` regressions; focused TypeScript strict verification passed, while hosted exact-head receipt is still absent. | | Exact final process envelope | Native artifact metadata must belong to the same final status returned to the renderer. | Whole-envelope retention regression plus production `run_analysis_engine` coupling. Hosted exact-head receipt is still absent. | | Fail-closed JSONL/job contract | Invalid non-empty JSONL, mismatched job identity, contradictory state payloads and nonterminal final output are not accepted as a successful native process result. | `analysis_process_status` and `analysis_process_contract` Rust regressions; production stdout reader propagates parse/identity failure. Hosted exact-head receipt is still absent. | | Terminal-event ordering | Producer terminal states are not buyer-visible until subprocess exit and complete stream validation. | `only_nonterminal_status_is_renderer_progress` plus production `drain_analysis_process_status_updates` gating; hosted exact-head receipt is still absent. | @@ -102,15 +104,15 @@ For this preflight increment, the native admission module therefore owns a small | Process exits successfully with queued/running final status | Rejected as an invalid native response. | Exact-head executable evidence is still required. | | Contradictory succeeded/failed/result/error payload | Rejected by strict process-status semantics even without stem metadata. | Exact-head executable evidence is still required. | | Partial four-stem publication | Exact directory membership, all-four preflight and atomic authority installation prevent partial registration. | Renderer selector has not shipped. | -| Partial/stale renderer availability | Native availability requires a serveable current full mix and either zero or four serveable canonical stems; partial/stale/mutated cases fail closed. | `RehearsalPlayer` does not yet invoke the command or render a selector. | +| Partial/stale renderer availability | Native availability requires a serveable current full mix and either zero or four serveable canonical stems; renderer discovery then independently rejects partial/stale/path-bearing/malformed payloads. | `RehearsalPlayer` does not yet bind the discovery lifetime or render a selector. | | Project/source replacement | One authority replacement revokes full mix, pending stem token and generated stems together. | Reopened-project persistence/recovery remains the #962 boundary. | -| Renderer path disclosure | Analysis status strips native metadata and playback/availability protocols use project/stem tokens only. | The UI contract must continue to consume opaque handles only. | +| Renderer path disclosure | Analysis status strips native metadata; playback/availability protocols use project/stem tokens only; renderer discovery drops invocation errors instead of echoing native details. | Mounted UI must continue to consume only projector-approved opaque handles. | ## Current RED and acceptance boundary -The native process/file-admission, terminal-ordering and renderer-safe availability source contracts are now connected, but the source-to-audible vertical is not GREEN. Hosted exact-head Rust format/test/Clippy/coverage and repository/central gates have not yet established current-head executable evidence for this stacked branch, so source-level regressions and source inspection are non-passing evidence. +The native process/file-admission, terminal-ordering, renderer-safe availability and renderer IPC revalidation source contracts are connected, but the source-to-audible vertical is not GREEN. Hosted exact-head Rust format/test/Clippy/coverage, TypeScript/Vitest and repository/central gates have not established current-head executable evidence for this stacked branch, so source-level regressions, focused local verification and source inspection remain non-passing for merge/release purposes. -The next buyer-visible gap is the actual source selector: no shipped control yet consumes `get_playback_source_availability` and exposes `Full mix | Vocals | Bass | Drums | Other instruments`. The slice must revalidate the native payload with `derivePlaybackSourceOptions`, preserve position/loop/range/playback-rate semantics across source changes, and cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, KO/EN/JA/ZH/VI/ES/DE/FR expansion, and rights-cleared audible macOS/Windows behavior. It must not claim guitar/keyboard identity for the `other` stem. +The next buyer-visible gap is mounting discovery into the actual source selector. `RehearsalPlayer` still does not call `discoverPlaybackSourceOptions` or expose `Full mix | Vocals | Bass | Drums | Other instruments`. The mounted slice must bind discovery lifetime to the current project authority, clear stale options on authority rotation/revocation, preserve position/loop/range/playback-rate semantics across source changes, and cover pointer, touch, keyboard and screen-reader behavior, selection persistence/reload/stale race, KO/EN/JA/ZH/VI/ES/DE/FR expansion, and rights-cleared audible macOS/Windows behavior. It must not claim guitar/keyboard identity for the `other` stem. The stacked exact head has no qualifying hosted pull-request workflow receipt at the time of this documentation update. ADR-0001 therefore stays Proposed and this PR stays Draft. From d3df37675c109dc192322f282b0154f247bd1f2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:41:10 +0900 Subject: [PATCH 057/160] test(player): require source-switch transport continuity --- .../workspace/playbackSourceSwitch.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts new file mode 100644 index 000000000..250299700 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { + RehearsalLoopWindow, + RehearsalTransportState, +} from "./rehearsalTransport"; +import { + admitPlaybackSourceSwitchTarget, + capturePlaybackSourceSwitch, +} from "./playbackSourceSwitch"; + +const loop: RehearsalLoopWindow = { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, +}; + +function transport( + phase: RehearsalTransportState["phase"], +): RehearsalTransportState { + return { + phase, + loop, + countInRemainingBeats: phase === "counting-in" ? 3 : 0, + playheadSeconds: phase === "armed" ? loop.startSeconds : 37.25, + playbackRate: 0.75, + }; +} + +describe("playback source switch continuity", () => { + it("captures exact looping position and playback rate for resume after target metadata", () => { + expect(capturePlaybackSourceSwitch(transport("looping"), 37.25)).toEqual({ + loopStartSeconds: 30, + loopEndSeconds: 45, + seekSeconds: 37.25, + playbackRate: 0.75, + sourcePhase: "looping", + resumeAfterLoad: true, + }); + }); + + it("preserves a paused position without manufacturing playback intent", () => { + expect(capturePlaybackSourceSwitch(transport("paused"), 36.5)).toEqual({ + loopStartSeconds: 30, + loopEndSeconds: 45, + seekSeconds: 36.5, + playbackRate: 0.75, + sourcePhase: "paused", + resumeAfterLoad: false, + }); + }); + + it("uses the selected loop start when switching an armed transport", () => { + expect(capturePlaybackSourceSwitch(transport("armed"), Number.NaN)).toEqual({ + loopStartSeconds: 30, + loopEndSeconds: 45, + seekSeconds: 30, + playbackRate: 0.75, + sourcePhase: "armed", + resumeAfterLoad: false, + }); + }); + + it.each(["idle", "counting-in"] as const)( + "fails closed instead of changing source during %s", + (phase) => { + expect(capturePlaybackSourceSwitch(transport(phase), 37.25)).toBeNull(); + }, + ); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, 29.99, 45, 90])( + "rejects an out-of-loop media position instead of silently clamping it: %s", + (mediaTime) => { + expect(capturePlaybackSourceSwitch(transport("looping"), mediaTime)).toBeNull(); + expect(capturePlaybackSourceSwitch(transport("paused"), mediaTime)).toBeNull(); + }, + ); + + it("admits a target only when its decoded duration still covers the complete selected loop", () => { + const plan = capturePlaybackSourceSwitch(transport("looping"), 37.25); + expect(plan).not.toBeNull(); + + expect(admitPlaybackSourceSwitchTarget(plan, 45)).toEqual(plan); + expect(admitPlaybackSourceSwitchTarget(plan, 44.999)).toBeNull(); + expect(admitPlaybackSourceSwitchTarget(plan, 37.25)).toBeNull(); + expect(admitPlaybackSourceSwitchTarget(plan, Number.NaN)).toBeNull(); + expect(admitPlaybackSourceSwitchTarget(plan, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); From 4b78eabe030b16d769fc13830ddfc440e26b04f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:41:45 +0900 Subject: [PATCH 058/160] feat(player): define fail-closed source-switch continuity --- .../workspace/playbackSourceSwitch.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSwitch.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts new file mode 100644 index 000000000..679c0a631 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -0,0 +1,80 @@ +import { + isRehearsalPlaybackRate, + type RehearsalPlaybackRate, + type RehearsalTransportPhase, + type RehearsalTransportState, +} from "./rehearsalTransport"; + +/** Transport continuity that must survive one admitted playback-source change. */ +export interface PlaybackSourceSwitchPlan { + loopStartSeconds: number; + loopEndSeconds: number; + seekSeconds: number; + playbackRate: RehearsalPlaybackRate; + sourcePhase: Extract; + resumeAfterLoad: boolean; +} + +/** + * Capture transport continuity before replacing the media source. + * + * Count-in changes are deliberately rejected: changing media while the independent + * count-in clock is running would create a second timing race. Looping/paused + * switches retain the exact admitted media position; armed switches start from the + * selected loop boundary. Invalid positions fail closed rather than being clamped. + */ +export function capturePlaybackSourceSwitch( + transport: RehearsalTransportState, + currentMediaTimeSeconds: number, +): PlaybackSourceSwitchPlan | null { + const loop = transport.loop; + if ( + !loop || + !isRehearsalPlaybackRate(transport.playbackRate) || + (transport.phase !== "armed" && + transport.phase !== "looping" && + transport.phase !== "paused") + ) { + return null; + } + + const seekSeconds = + transport.phase === "armed" ? loop.startSeconds : currentMediaTimeSeconds; + if ( + !Number.isFinite(seekSeconds) || + seekSeconds < loop.startSeconds || + seekSeconds >= loop.endSeconds + ) { + return null; + } + + return { + loopStartSeconds: loop.startSeconds, + loopEndSeconds: loop.endSeconds, + seekSeconds, + playbackRate: transport.playbackRate, + sourcePhase: transport.phase, + resumeAfterLoad: transport.phase === "looping", + }; +} + +/** + * Admit the decoded target only when it can still cover the selected loop and + * captured position. A shorter/malformed source must not silently change rehearsal + * range semantics after the selector changes authority. + */ +export function admitPlaybackSourceSwitchTarget( + plan: PlaybackSourceSwitchPlan | null, + targetDurationSeconds: number, +): PlaybackSourceSwitchPlan | null { + if ( + !plan || + !Number.isFinite(targetDurationSeconds) || + targetDurationSeconds <= 0 || + plan.seekSeconds >= targetDurationSeconds || + plan.loopEndSeconds > targetDurationSeconds + ) { + return null; + } + return plan; +} From 899f122e721cd48f998deea2144beedf558602ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:02:44 +0900 Subject: [PATCH 059/160] test(player): require stale-safe source discovery session --- .../workspace/playbackSourceSession.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSession.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts new file mode 100644 index 000000000..cb0aeb8ba --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + beginPlaybackSourceDiscovery, + completePlaybackSourceDiscovery, + createPlaybackSourceSession, + selectPlaybackSource, +} from "./playbackSourceSession"; + +const projectA = "bandscope-project://project-100-1"; +const projectB = "bandscope-project://project-200-1"; +const projectAOptions = [ + { kind: "full_mix" as const, authority: projectA }, + { kind: "vocals" as const, authority: `${projectA}/stem/vocals` }, + { kind: "bass" as const, authority: `${projectA}/stem/bass` }, + { kind: "drums" as const, authority: `${projectA}/stem/drums` }, + { kind: "other" as const, authority: `${projectA}/stem/other` }, +]; + +describe("playback source discovery session", () => { + it("starts from the current full mix without inventing stem availability", () => { + expect(createPlaybackSourceSession(projectA)).toEqual({ + fullMixAuthority: projectA, + options: [{ kind: "full_mix", authority: projectA }], + pendingRequest: null, + requestSequence: 0, + selectedAuthority: projectA, + }); + }); + + it("clears stale stems before a refresh can observe native revocation", () => { + let state = createPlaybackSourceSession(projectA); + const refresh = beginPlaybackSourceDiscovery(state, projectA); + state = completePlaybackSourceDiscovery(refresh.state, refresh.request, projectAOptions); + state = selectPlaybackSource(state, `${projectA}/stem/vocals`); + + const nextRefresh = beginPlaybackSourceDiscovery(state, projectA); + + expect(nextRefresh.state.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(nextRefresh.state.selectedAuthority).toBe(projectA); + }); + + it("ignores an older discovery after the full-mix authority rotates", () => { + const first = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + const rotated = beginPlaybackSourceDiscovery(first.state, projectB); + + const staleCompletion = completePlaybackSourceDiscovery( + rotated.state, + first.request, + projectAOptions, + ); + + expect(staleCompletion.fullMixAuthority).toBe(projectB); + expect(staleCompletion.options).toEqual([ + { kind: "full_mix", authority: projectB }, + ]); + expect(staleCompletion.pendingRequest).toEqual(rotated.request); + }); + + it("fails closed when completion is partial, malformed, or project-mismatched", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + + for (const invalid of [ + projectAOptions.slice(0, 2), + [...projectAOptions, projectAOptions[1]], + [{ kind: "full_mix", authority: projectB }], + [{ kind: "vocals", authority: `${projectA}/stem/vocals/../private.wav` }], + null, + "not-an-option-list", + ]) { + const completed = completePlaybackSourceDiscovery( + begin.state, + begin.request, + invalid, + ); + expect(completed.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(completed.selectedAuthority).toBe(projectA); + expect(completed.pendingRequest).toBeNull(); + } + }); + + it("admits selection only from the latest canonical option set", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + const completed = completePlaybackSourceDiscovery( + begin.state, + begin.request, + projectAOptions, + ); + + expect( + selectPlaybackSource(completed, `${projectA}/stem/drums`).selectedAuthority, + ).toBe(`${projectA}/stem/drums`); + expect( + selectPlaybackSource(completed, `${projectA}/stem/guitar`).selectedAuthority, + ).toBe(projectA); + }); +}); From d37a7b8890357e08e7691a64f8ac09c0417f2048 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:03:19 +0900 Subject: [PATCH 060/160] feat(player): bind source discovery to current authority --- .../workspace/playbackSourceSession.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSession.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.ts b/apps/desktop/src/features/workspace/playbackSourceSession.ts new file mode 100644 index 000000000..9bdcae490 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSession.ts @@ -0,0 +1,195 @@ +import { + derivePlaybackSourceOptions, + type PlaybackSourceOption, + type PlaybackSourceKind, +} from "./playbackSourceSelection"; + +/** Identity of one in-flight native availability lookup. */ +export interface PlaybackSourceDiscoveryRequest { + fullMixAuthority: string; + sequence: number; +} + +/** Renderer-owned playback-source state for the current native project authority. */ +export interface PlaybackSourceSession { + fullMixAuthority: string | null; + options: PlaybackSourceOption[]; + pendingRequest: PlaybackSourceDiscoveryRequest | null; + requestSequence: number; + selectedAuthority: string | null; +} + +function fullMixOnly(authority: string): PlaybackSourceOption[] { + return [{ kind: "full_mix", authority }]; +} + +function isValidFullMixAuthority(authority: string | null | undefined): authority is string { + return ( + typeof authority === "string" && + derivePlaybackSourceOptions(authority, [authority]) !== null + ); +} + +function normalizeDiscoveredOptions( + fullMixAuthority: string, + discovered: unknown, +): PlaybackSourceOption[] | null { + if (!Array.isArray(discovered)) { + return null; + } + + const declared: Array<{ kind: PlaybackSourceKind; authority: string }> = []; + for (const candidate of discovered) { + if ( + typeof candidate !== "object" || + candidate === null || + !Object.hasOwn(candidate, "kind") || + !Object.hasOwn(candidate, "authority") + ) { + return null; + } + const kind = (candidate as { kind?: unknown }).kind; + const authority = (candidate as { authority?: unknown }).authority; + if ( + (kind !== "full_mix" && + kind !== "vocals" && + kind !== "bass" && + kind !== "drums" && + kind !== "other") || + typeof authority !== "string" + ) { + return null; + } + declared.push({ kind, authority }); + } + + const canonical = derivePlaybackSourceOptions( + fullMixAuthority, + declared.map((option) => option.authority), + ); + if ( + canonical === null || + canonical.length !== declared.length || + canonical.some( + (option, index) => + option.kind !== declared[index]?.kind || + option.authority !== declared[index]?.authority, + ) + ) { + return null; + } + return canonical; +} + +/** Start with only authority already owned by the mounted project. */ +export function createPlaybackSourceSession( + fullMixAuthority: string | null | undefined, +): PlaybackSourceSession { + if (!isValidFullMixAuthority(fullMixAuthority)) { + return { + fullMixAuthority: null, + options: [], + pendingRequest: null, + requestSequence: 0, + selectedAuthority: null, + }; + } + return { + fullMixAuthority, + options: fullMixOnly(fullMixAuthority), + pendingRequest: null, + requestSequence: 0, + selectedAuthority: fullMixAuthority, + }; +} + +/** + * Begin a refresh and immediately discard previously discovered stems. + * + * Native stem authority is revocable. Keeping old options visible while an async + * refresh runs would let a stale button outlive the authority snapshot that created it. + */ +export function beginPlaybackSourceDiscovery( + state: PlaybackSourceSession, + currentFullMixAuthority: string | null | undefined, +): { + state: PlaybackSourceSession; + request: PlaybackSourceDiscoveryRequest | null; +} { + const nextSequence = + state.requestSequence >= Number.MAX_SAFE_INTEGER ? 1 : state.requestSequence + 1; + if (!isValidFullMixAuthority(currentFullMixAuthority)) { + return { + state: { + fullMixAuthority: null, + options: [], + pendingRequest: null, + requestSequence: nextSequence, + selectedAuthority: null, + }, + request: null, + }; + } + + const request = { + fullMixAuthority: currentFullMixAuthority, + sequence: nextSequence, + } satisfies PlaybackSourceDiscoveryRequest; + return { + state: { + fullMixAuthority: currentFullMixAuthority, + options: fullMixOnly(currentFullMixAuthority), + pendingRequest: request, + requestSequence: nextSequence, + selectedAuthority: currentFullMixAuthority, + }, + request, + }; +} + +/** Apply only the latest matching discovery receipt; malformed results stay full-mix only. */ +export function completePlaybackSourceDiscovery( + state: PlaybackSourceSession, + request: PlaybackSourceDiscoveryRequest | null, + discovered: unknown, +): PlaybackSourceSession { + if ( + request === null || + state.pendingRequest === null || + state.pendingRequest.sequence !== request.sequence || + state.pendingRequest.fullMixAuthority !== request.fullMixAuthority || + state.fullMixAuthority !== request.fullMixAuthority + ) { + return state; + } + + const options = + normalizeDiscoveredOptions(request.fullMixAuthority, discovered) ?? + fullMixOnly(request.fullMixAuthority); + const selectedAuthority = options.some( + (option) => option.authority === state.selectedAuthority, + ) + ? state.selectedAuthority + : request.fullMixAuthority; + + return { + ...state, + options, + pendingRequest: null, + selectedAuthority, + }; +} + +/** Select only an authority present in the current canonical option snapshot. */ +export function selectPlaybackSource( + state: PlaybackSourceSession, + authority: string, +): PlaybackSourceSession { + if (state.options.some((option) => option.authority === authority)) { + return { ...state, selectedAuthority: authority }; + } + return { + ...state, + selectedAuthority: state.fullMixAuthority, + }; +} From 1f933ec1998d53eb8f1bc4b96ac933c56b017c24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:05:47 +0900 Subject: [PATCH 061/160] docs(player): trace source discovery lifetime --- docs/traceability/playback-source-session.md | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/traceability/playback-source-session.md diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md new file mode 100644 index 000000000..34b0957a0 --- /dev/null +++ b/docs/traceability/playback-source-session.md @@ -0,0 +1,56 @@ +# Playback source session traceability + +- **Status:** Draft implementation evidence; not shipped or release acceptance +- **Date:** 2026-09-05 +- **Bounded context:** Active Player / renderer playback-source session +- **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` +- **Canonical Active Player owner:** PR #971 `9c1b20e6df778e303fada3e170c93418c496394b` +- **Stem publication parent:** PR #1159 `22a9f18d960cc7df93db890b2a5aa9594428c2b4` +- **Current native/UI child source head:** PR #1160 `d37a7b8890357e08e7691a64f8ac09c0417f2048` + +## Problem + +Native playback authority can revoke a previously admitted generated-stem set when a newer analysis starts, the local project changes, the underlying file identity changes, or source availability otherwise fails closed. The renderer already has a strict availability projector and native discovery adapter, but an asynchronous discovery result also needs a lifetime. Without a request/session identity, a response for an older project or older authority snapshot can repopulate stem controls after the renderer has rotated to a newer full-mix authority. + +The buyer-visible failure mode is not limited to stale text. A stale source control can point at an authority that native playback has already revoked. BandScope therefore treats playback-source availability as a revocable snapshot rather than durable UI state. + +## Test-first contract + +Commit `899f122e721cd48f998deea2144beedf558602ee` adds `playbackSourceSession.test.ts` before the production session implementation exists. The contract requires: + +- initial state exposes only the already-owned full mix and never invents stems; +- beginning a refresh immediately clears previously discovered stems and resets selection to full mix; +- a discovery completion for an older full-mix authority cannot overwrite a newer project's pending request or options; +- partial, duplicate, project-mismatched, path-shaped, malformed and non-array completions fail closed to full-mix-only state; and +- selection is admitted only from the latest canonical option set. + +This commit is source-level RED lineage. No hosted exact-head execution receipt is claimed for it. + +## Causal source repair + +Commit `d37a7b8890357e08e7691a64f8ac09c0417f2048` adds `playbackSourceSession.ts` and reuses `derivePlaybackSourceOptions` as the renderer authority/project/all-or-none source validator rather than introducing a second parsing rule. + +`PlaybackSourceSession` owns the current full-mix authority, current canonical options, current selection, one pending discovery identity and a request sequence. `beginPlaybackSourceDiscovery` immediately retracts all stem options before an asynchronous refresh begins. `completePlaybackSourceDiscovery` applies a result only when the request sequence and full-mix authority still match the current pending request and current project authority. Invalid results remain full-mix-only. `selectPlaybackSource` cannot select an authority outside the current option snapshot. + +The implementation accepts only opaque `bandscope-project://...` authorities already admitted by the existing renderer projector. It does not create native authority, expose a path/hash/file identity, or change the native `PlaybackAuthority` owner. + +## Concurrent source-switch continuity delta adopted + +The branch advanced concurrently before this repair. That movement was reviewed and retained rather than treated as a race: + +- `d3df37675c109dc192322f282b0154f247bd1f2d` adds test-first transport continuity requirements for looping, paused and armed source changes and rejects count-in/idle/out-of-loop transitions. +- `4b78eabe030b16d769fc13830ddfc440e26b04f0` adds `capturePlaybackSourceSwitch` and `admitPlaybackSourceSwitchTarget`. A switch plan preserves the selected loop, exact admitted position and playback rate, resumes only a previously looping transport, and rejects a target whose decoded duration cannot cover the complete selected loop. + +These pure contracts do not themselves change the mounted media source. + +## Current acceptance boundary + +The renderer-side authority/session contracts are not the finished selector. `RehearsalPlayer` still uses `audioSourcePath` directly, pauses and reloads when its resolved media URL changes, and does not yet call `discoverPlaybackSourceOptions`, bind the resulting session, render `Full mix | Vocals | Bass | Drums | Other instruments`, or apply the source-switch plan around the actual media `load()` lifecycle. + +Current locale infrastructure supports English and Korean only. JA/ZH/VI/ES/DE/FR expansion, text expansion/CJK fallback checks, pointer/touch/keyboard/screen-reader interaction, persistence/reload, stale/revocation UI behavior and rights-cleared audible macOS/Windows acceptance therefore remain open. + +Fresh Actions lookup for exact head `d37a7b8890357e08e7691a64f8ac09c0417f2048` returned no pull-request workflow runs. Commit statuses currently contain CodeRabbit and Devin Review success only; these are not substitutes for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft. + +## Delivery gate + +**FAIL.** Stale discovery-session admission and pure source-switch continuity are represented in source, but the mounted selector, actual media-switch continuity, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and real-audio desktop acceptance are not complete. From cf77f570e9ea2356bbeb46f343bda4da4b8967b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:08:35 +0900 Subject: [PATCH 062/160] test(player): reject hostile source discovery objects --- .../workspace/playbackSourceSession.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts index cb0aeb8ba..e9e8ea550 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts @@ -88,6 +88,43 @@ describe("playback source discovery session", () => { } }); + it("fails closed when hostile option inspection throws", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + const throwingGetter = Object.defineProperties({}, { + authority: { enumerable: true, value: projectA }, + kind: { + enumerable: true, + get: () => { + throw new Error("hostile kind getter"); + }, + }, + }); + const throwingProxy = new Proxy({}, { + getOwnPropertyDescriptor: () => { + throw new Error("hostile property trap"); + }, + }); + + for (const invalid of [[throwingGetter], [throwingProxy]]) { + expect(() => + completePlaybackSourceDiscovery(begin.state, begin.request, invalid), + ).not.toThrow(); + const completed = completePlaybackSourceDiscovery( + begin.state, + begin.request, + invalid, + ); + expect(completed.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(completed.selectedAuthority).toBe(projectA); + expect(completed.pendingRequest).toBeNull(); + } + }); + it("admits selection only from the latest canonical option set", () => { const begin = beginPlaybackSourceDiscovery( createPlaybackSourceSession(projectA), From 5770392f9b383895ed35560275ff861ef82c9e8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:09:05 +0900 Subject: [PATCH 063/160] fix(player): fail closed on hostile discovery payloads --- .../src/features/workspace/playbackSourceSession.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.ts b/apps/desktop/src/features/workspace/playbackSourceSession.ts index 9bdcae490..2f0b0a557 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.ts @@ -163,9 +163,15 @@ export function completePlaybackSourceDiscovery( return state; } - const options = - normalizeDiscoveredOptions(request.fullMixAuthority, discovered) ?? - fullMixOnly(request.fullMixAuthority); + let options: PlaybackSourceOption[]; + try { + options = + normalizeDiscoveredOptions(request.fullMixAuthority, discovered) ?? + fullMixOnly(request.fullMixAuthority); + } catch { + // Hostile getters/proxy traps cannot turn revoked availability into renderer state. + options = fullMixOnly(request.fullMixAuthority); + } const selectedAuthority = options.some( (option) => option.authority === state.selectedAuthority, ) From 096108425f194ae1070668cd8233967ece581c61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:09:33 +0900 Subject: [PATCH 064/160] docs(player): record hostile discovery repair --- docs/traceability/playback-source-session.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md index 34b0957a0..5142747e6 100644 --- a/docs/traceability/playback-source-session.md +++ b/docs/traceability/playback-source-session.md @@ -6,7 +6,7 @@ - **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` - **Canonical Active Player owner:** PR #971 `9c1b20e6df778e303fada3e170c93418c496394b` - **Stem publication parent:** PR #1159 `22a9f18d960cc7df93db890b2a5aa9594428c2b4` -- **Current native/UI child source head:** PR #1160 `d37a7b8890357e08e7691a64f8ac09c0417f2048` +- **Current native/UI child source head before this documentation update:** PR #1160 `5770392f9b383895ed35560275ff861ef82c9e8e` ## Problem @@ -34,6 +34,15 @@ Commit `d37a7b8890357e08e7691a64f8ac09c0417f2048` adds `playbackSourceSession.ts The implementation accepts only opaque `bandscope-project://...` authorities already admitted by the existing renderer projector. It does not create native authority, expose a path/hash/file identity, or change the native `PlaybackAuthority` owner. +## Hostile object inspection repair + +The initial normalizer still had one fail-closed hole: an object with an own throwing accessor or a Proxy whose property-descriptor trap throws could make renderer completion throw instead of collapsing to full-mix-only state. + +- RED `cf77f570e9ea2356bbeb46f343bda4da4b8967b9` adds own-accessor and Proxy-trap cases and requires `completePlaybackSourceDiscovery` not to throw. +- Causal fix `5770392f9b383895ed35560275ff861ef82c9e8e` bounds option normalization with a fail-closed catch. Hostile inspection now clears the pending request and leaves only the already-owned full mix; no stem authority is manufactured or retained. + +The catch is deliberately confined to untrusted discovery-payload normalization. It does not swallow unrelated player/media failures or weaken the canonical source projector. + ## Concurrent source-switch continuity delta adopted The branch advanced concurrently before this repair. That movement was reviewed and retained rather than treated as a race: @@ -49,8 +58,8 @@ The renderer-side authority/session contracts are not the finished selector. `Re Current locale infrastructure supports English and Korean only. JA/ZH/VI/ES/DE/FR expansion, text expansion/CJK fallback checks, pointer/touch/keyboard/screen-reader interaction, persistence/reload, stale/revocation UI behavior and rights-cleared audible macOS/Windows acceptance therefore remain open. -Fresh Actions lookup for exact head `d37a7b8890357e08e7691a64f8ac09c0417f2048` returned no pull-request workflow runs. Commit statuses currently contain CodeRabbit and Devin Review success only; these are not substitutes for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft. +Fresh Actions lookup on the preceding exact source heads returned no pull-request workflow runs for this stacked branch. CodeRabbit's latest manual-review request also hit its service rate limit, so there is no new qualifying review receipt. These are not substitutes for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft. ## Delivery gate -**FAIL.** Stale discovery-session admission and pure source-switch continuity are represented in source, but the mounted selector, actual media-switch continuity, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and real-audio desktop acceptance are not complete. +**FAIL.** Stale/hostile discovery-session admission and pure source-switch continuity are represented in source, but the mounted selector, actual media-switch continuity, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and real-audio desktop acceptance are not complete. From 5be9d68adf6612c35ce3fa062b7b2b688819cf80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:02:22 +0900 Subject: [PATCH 065/160] test(player): bind source-switch receipts to target identity --- .../workspace/playbackSourceSwitch.test.ts | 86 ++++++++++++++++--- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts index 250299700..ce1011600 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -20,6 +20,10 @@ const loop: RehearsalLoopWindow = { countInBeats: 4, }; +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const bassAuthority = `${fullMixAuthority}/stem/bass`; + function transport( phase: RehearsalTransportState["phase"], ): RehearsalTransportState { @@ -32,63 +36,117 @@ function transport( }; } +function capture( + phase: RehearsalTransportState["phase"], + mediaTime: number, + targetAuthority = vocalsAuthority, + sequence = 3, +) { + return capturePlaybackSourceSwitch(transport(phase), mediaTime, { + sourceAuthority: fullMixAuthority, + targetAuthority, + sequence, + }); +} + describe("playback source switch continuity", () => { - it("captures exact looping position and playback rate for resume after target metadata", () => { - expect(capturePlaybackSourceSwitch(transport("looping"), 37.25)).toEqual({ + it("captures exact looping position, playback rate, and target identity for resume after target metadata", () => { + expect(capture("looping", 37.25)).toEqual({ loopStartSeconds: 30, loopEndSeconds: 45, seekSeconds: 37.25, playbackRate: 0.75, sourcePhase: "looping", resumeAfterLoad: true, + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, }); }); it("preserves a paused position without manufacturing playback intent", () => { - expect(capturePlaybackSourceSwitch(transport("paused"), 36.5)).toEqual({ + expect(capture("paused", 36.5)).toEqual({ loopStartSeconds: 30, loopEndSeconds: 45, seekSeconds: 36.5, playbackRate: 0.75, sourcePhase: "paused", resumeAfterLoad: false, + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, }); }); it("uses the selected loop start when switching an armed transport", () => { - expect(capturePlaybackSourceSwitch(transport("armed"), Number.NaN)).toEqual({ + expect(capture("armed", Number.NaN)).toEqual({ loopStartSeconds: 30, loopEndSeconds: 45, seekSeconds: 30, playbackRate: 0.75, sourcePhase: "armed", resumeAfterLoad: false, + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, }); }); it.each(["idle", "counting-in"] as const)( "fails closed instead of changing source during %s", (phase) => { - expect(capturePlaybackSourceSwitch(transport(phase), 37.25)).toBeNull(); + expect(capture(phase, 37.25)).toBeNull(); }, ); it.each([Number.NaN, Number.POSITIVE_INFINITY, 29.99, 45, 90])( "rejects an out-of-loop media position instead of silently clamping it: %s", (mediaTime) => { - expect(capturePlaybackSourceSwitch(transport("looping"), mediaTime)).toBeNull(); - expect(capturePlaybackSourceSwitch(transport("paused"), mediaTime)).toBeNull(); + expect(capture("looping", mediaTime)).toBeNull(); + expect(capture("paused", mediaTime)).toBeNull(); }, ); - it("admits a target only when its decoded duration still covers the complete selected loop", () => { - const plan = capturePlaybackSourceSwitch(transport("looping"), 37.25); + it.each([ + { targetAuthority: fullMixAuthority, sequence: 3 }, + { targetAuthority: vocalsAuthority, sequence: 0 }, + { targetAuthority: vocalsAuthority, sequence: Number.MAX_SAFE_INTEGER + 1 }, + ])("rejects a no-op or invalid switch identity: %o", ({ targetAuthority, sequence }) => { + expect(capture("looping", 37.25, targetAuthority, sequence)).toBeNull(); + }); + + it("admits a target only when its decoded duration and switch receipt still match the active target", () => { + const plan = capture("looping", 37.25); expect(plan).not.toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, 45)).toEqual(plan); - expect(admitPlaybackSourceSwitchTarget(plan, 44.999)).toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, 37.25)).toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, Number.NaN)).toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, Number.POSITIVE_INFINITY)).toBeNull(); + expect(admitPlaybackSourceSwitchTarget(plan, 45, vocalsAuthority, 3)).toEqual(plan); + expect(admitPlaybackSourceSwitchTarget(plan, 44.999, vocalsAuthority, 3)).toBeNull(); + expect(admitPlaybackSourceSwitchTarget(plan, 37.25, vocalsAuthority, 3)).toBeNull(); + expect(admitPlaybackSourceSwitchTarget(plan, Number.NaN, vocalsAuthority, 3)).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + plan, + Number.POSITIVE_INFINITY, + vocalsAuthority, + 3, + ), + ).toBeNull(); + }); + + it("rejects stale loadedmetadata receipts after a newer source switch supersedes the target", () => { + const stalePlan = capture("looping", 37.25, vocalsAuthority, 3); + const currentPlan = capture("looping", 37.25, bassAuthority, 4); + expect(stalePlan).not.toBeNull(); + expect(currentPlan).not.toBeNull(); + + expect( + admitPlaybackSourceSwitchTarget(stalePlan, 45, bassAuthority, 4), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget(stalePlan, 45, vocalsAuthority, 4), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget(currentPlan, 45, bassAuthority, 4), + ).toEqual(currentPlan); }); }); From 799c6b6e25a0fa290fba61f7ebdd303350f87888 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:02:39 +0900 Subject: [PATCH 066/160] fix(player): reject stale media-switch receipts --- .../workspace/playbackSourceSwitch.ts | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index 679c0a631..c5704d7e7 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -5,8 +5,15 @@ import { type RehearsalTransportState, } from "./rehearsalTransport"; +/** Identity of one renderer-owned media-source replacement attempt. */ +export interface PlaybackSourceSwitchIdentity { + sourceAuthority: string; + targetAuthority: string; + sequence: number; +} + /** Transport continuity that must survive one admitted playback-source change. */ -export interface PlaybackSourceSwitchPlan { +export interface PlaybackSourceSwitchPlan extends PlaybackSourceSwitchIdentity { loopStartSeconds: number; loopEndSeconds: number; seekSeconds: number; @@ -15,22 +22,37 @@ export interface PlaybackSourceSwitchPlan { resumeAfterLoad: boolean; } +function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean { + return ( + typeof identity.sourceAuthority === "string" && + identity.sourceAuthority.length > 0 && + typeof identity.targetAuthority === "string" && + identity.targetAuthority.length > 0 && + identity.sourceAuthority !== identity.targetAuthority && + Number.isSafeInteger(identity.sequence) && + identity.sequence > 0 + ); +} + /** * Capture transport continuity before replacing the media source. * * Count-in changes are deliberately rejected: changing media while the independent * count-in clock is running would create a second timing race. Looping/paused * switches retain the exact admitted media position; armed switches start from the - * selected loop boundary. Invalid positions fail closed rather than being clamped. + * selected loop boundary. Invalid positions or switch identities fail closed rather + * than being clamped or converted into an ambiguous no-op. */ export function capturePlaybackSourceSwitch( transport: RehearsalTransportState, currentMediaTimeSeconds: number, + identity: PlaybackSourceSwitchIdentity, ): PlaybackSourceSwitchPlan | null { const loop = transport.loop; if ( !loop || !isRehearsalPlaybackRate(transport.playbackRate) || + !hasValidSwitchIdentity(identity) || (transport.phase !== "armed" && transport.phase !== "looping" && transport.phase !== "paused") @@ -49,6 +71,7 @@ export function capturePlaybackSourceSwitch( } return { + ...identity, loopStartSeconds: loop.startSeconds, loopEndSeconds: loop.endSeconds, seekSeconds, @@ -59,18 +82,28 @@ export function capturePlaybackSourceSwitch( } /** - * Admit the decoded target only when it can still cover the selected loop and - * captured position. A shorter/malformed source must not silently change rehearsal - * range semantics after the selector changes authority. + * Admit the decoded target only when it still belongs to the active switch receipt + * and can cover the selected loop and captured position. + * + * `loadedmetadata` belongs to a mutable media element rather than to the source that + * initiated the event. Matching both the target authority and monotonic renderer + * sequence prevents a late receipt from an older load from restoring stale transport + * state after a newer source selection has already superseded it. */ export function admitPlaybackSourceSwitchTarget( plan: PlaybackSourceSwitchPlan | null, targetDurationSeconds: number, + currentTargetAuthority: string, + currentSequence: number, ): PlaybackSourceSwitchPlan | null { if ( !plan || !Number.isFinite(targetDurationSeconds) || targetDurationSeconds <= 0 || + plan.targetAuthority !== currentTargetAuthority || + plan.sequence !== currentSequence || + !Number.isSafeInteger(currentSequence) || + currentSequence <= 0 || plan.seekSeconds >= targetDurationSeconds || plan.loopEndSeconds > targetDurationSeconds ) { From 9adf2cca08fcea565b5e6b4477a0ee4ae9616ca4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:03:13 +0900 Subject: [PATCH 067/160] docs(player): trace stale media-switch receipt repair --- docs/traceability/playback-source-session.md | 33 +++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md index 5142747e6..feeefb940 100644 --- a/docs/traceability/playback-source-session.md +++ b/docs/traceability/playback-source-session.md @@ -2,19 +2,21 @@ - **Status:** Draft implementation evidence; not shipped or release acceptance - **Date:** 2026-09-05 -- **Bounded context:** Active Player / renderer playback-source session +- **Bounded context:** Active Player / renderer playback-source session and media-switch continuity - **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` - **Canonical Active Player owner:** PR #971 `9c1b20e6df778e303fada3e170c93418c496394b` - **Stem publication parent:** PR #1159 `22a9f18d960cc7df93db890b2a5aa9594428c2b4` -- **Current native/UI child source head before this documentation update:** PR #1160 `5770392f9b383895ed35560275ff861ef82c9e8e` +- **Current native/UI child source head before this documentation update:** PR #1160 `799c6b6e25a0fa290fba61f7ebdd303350f87888` ## Problem -Native playback authority can revoke a previously admitted generated-stem set when a newer analysis starts, the local project changes, the underlying file identity changes, or source availability otherwise fails closed. The renderer already has a strict availability projector and native discovery adapter, but an asynchronous discovery result also needs a lifetime. Without a request/session identity, a response for an older project or older authority snapshot can repopulate stem controls after the renderer has rotated to a newer full-mix authority. +Native playback authority can revoke a previously admitted generated-stem set when a newer analysis starts, the local project changes, the underlying file identity changes, or source availability otherwise fails closed. The renderer already has a strict availability projector and native discovery adapter, but asynchronous discovery and media loading both need explicit lifetimes. -The buyer-visible failure mode is not limited to stale text. A stale source control can point at an authority that native playback has already revoked. BandScope therefore treats playback-source availability as a revocable snapshot rather than durable UI state. +Without a discovery request/session identity, a response for an older project or older authority snapshot can repopulate stem controls after the renderer has rotated to a newer full-mix authority. Separately, `HTMLMediaElement.loadedmetadata` is emitted by a mutable media element, not by an immutable source receipt. If two source switches overlap, metadata from the superseded load can arrive after a newer target has already replaced `src` and can incorrectly restore the older playhead/loop/resume plan unless the load is bound to an exact target and renderer sequence. -## Test-first contract +These are buyer-visible authority failures, not cosmetic state drift. A stale source control can point at revoked native authority, and a stale media receipt can resume rehearsal transport against the wrong source. + +## Discovery-session test-first contract Commit `899f122e721cd48f998deea2144beedf558602ee` adds `playbackSourceSession.test.ts` before the production session implementation exists. The contract requires: @@ -26,7 +28,7 @@ Commit `899f122e721cd48f998deea2144beedf558602ee` adds `playbackSourceSession.te This commit is source-level RED lineage. No hosted exact-head execution receipt is claimed for it. -## Causal source repair +## Discovery-session causal source repair Commit `d37a7b8890357e08e7691a64f8ac09c0417f2048` adds `playbackSourceSession.ts` and reuses `derivePlaybackSourceOptions` as the renderer authority/project/all-or-none source validator rather than introducing a second parsing rule. @@ -43,23 +45,30 @@ The initial normalizer still had one fail-closed hole: an object with an own thr The catch is deliberately confined to untrusted discovery-payload normalization. It does not swallow unrelated player/media failures or weaken the canonical source projector. -## Concurrent source-switch continuity delta adopted +## Source-switch continuity and stale media-receipt repair -The branch advanced concurrently before this repair. That movement was reviewed and retained rather than treated as a race: +The branch had already adopted a source-switch continuity contract: - `d3df37675c109dc192322f282b0154f247bd1f2d` adds test-first transport continuity requirements for looping, paused and armed source changes and rejects count-in/idle/out-of-loop transitions. - `4b78eabe030b16d769fc13830ddfc440e26b04f0` adds `capturePlaybackSourceSwitch` and `admitPlaybackSourceSwitchTarget`. A switch plan preserves the selected loop, exact admitted position and playback rate, resumes only a previously looping transport, and rejects a target whose decoded duration cannot cover the complete selected loop. -These pure contracts do not themselves change the mounted media source. +A second race remained because that plan had no immutable identity for the media load that was expected to satisfy it. A late `loadedmetadata` receipt from an older `src` could still be mistaken for the current target after a newer source selection superseded it. + +- RED `5be9d68adf6612c35ce3fa062b7b2b688819cf80` extends `playbackSourceSwitch.test.ts` before the source repair. It requires the plan to capture source authority, target authority and a positive safe renderer sequence; rejects a no-op/invalid switch identity; and proves that an older target/sequence receipt cannot be admitted after a newer switch becomes current. +- Causal fix `799c6b6e25a0fa290fba61f7ebdd303350f87888` extends `PlaybackSourceSwitchPlan` with that identity. `capturePlaybackSourceSwitch` now fails closed on ambiguous/no-op identities, while `admitPlaybackSourceSwitchTarget` requires the decoded target duration, exact target authority and exact renderer sequence to match the active switch receipt before continuity can be restored. + +The sequence is renderer-owned and carries no filesystem or native capability. The fix does not create a second playback authority; it only prevents a mutable media-element event from reactivating stale transport state. ## Current acceptance boundary -The renderer-side authority/session contracts are not the finished selector. `RehearsalPlayer` still uses `audioSourcePath` directly, pauses and reloads when its resolved media URL changes, and does not yet call `discoverPlaybackSourceOptions`, bind the resulting session, render `Full mix | Vocals | Bass | Drums | Other instruments`, or apply the source-switch plan around the actual media `load()` lifecycle. +The renderer-side authority/session and switch-receipt contracts are not the finished selector. `RehearsalPlayer` still uses `audioSourcePath` directly, pauses and reloads when its resolved media URL changes, and does not yet call `discoverPlaybackSourceOptions`, bind `PlaybackSourceSession`, render `Full mix | Vocals | Bass | Drums | Other instruments`, or execute the source-switch plan and receipt through the actual `audio.src` → `load()` → `loadedmetadata` lifecycle. + +The next source fix must therefore keep one mounted switch transaction owner: increment the renderer switch sequence when a currently selectable authority is chosen, capture continuity before replacing `src`, invalidate prior receipts immediately, admit `loadedmetadata` only against the exact current target/sequence, seek and restore playback rate only after target-duration admission, and resume only when the captured plan came from a previously looping transport. Revocation or malformed/short target media must fail closed to a non-playing state rather than silently falling back to stale transport. Current locale infrastructure supports English and Korean only. JA/ZH/VI/ES/DE/FR expansion, text expansion/CJK fallback checks, pointer/touch/keyboard/screen-reader interaction, persistence/reload, stale/revocation UI behavior and rights-cleared audible macOS/Windows acceptance therefore remain open. -Fresh Actions lookup on the preceding exact source heads returned no pull-request workflow runs for this stacked branch. CodeRabbit's latest manual-review request also hit its service rate limit, so there is no new qualifying review receipt. These are not substitutes for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft. +Fresh Actions lookup on the preceding exact source heads returned no pull-request workflow runs for this stacked branch. Source-level RED→fix lineage is not a substitute for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft. ## Delivery gate -**FAIL.** Stale/hostile discovery-session admission and pure source-switch continuity are represented in source, but the mounted selector, actual media-switch continuity, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and real-audio desktop acceptance are not complete. +**FAIL.** Stale/hostile discovery-session admission, source-switch continuity, and stale media-receipt rejection are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. From c899df46860921f74691ffb65351949f77e449bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:29:46 +0900 Subject: [PATCH 068/160] test(player): reject playback discovery sequence reuse --- .../workspace/playbackSourceSession.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts index e9e8ea550..52b0e7bdb 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts @@ -125,6 +125,28 @@ describe("playback source discovery session", () => { } }); + it("never reuses a discovery request identity after the safe-integer sequence is exhausted", () => { + const exhausted = { + ...createPlaybackSourceSession(projectA), + requestSequence: Number.MAX_SAFE_INTEGER, + }; + const next = beginPlaybackSourceDiscovery(exhausted, projectA); + + expect(next.request).toBeNull(); + expect(next.state).toEqual({ + fullMixAuthority: projectA, + options: [{ kind: "full_mix", authority: projectA }], + pendingRequest: null, + requestSequence: Number.MAX_SAFE_INTEGER, + selectedAuthority: projectA, + }); + + const ancientRequest = { fullMixAuthority: projectA, sequence: 1 }; + expect( + completePlaybackSourceDiscovery(next.state, ancientRequest, projectAOptions), + ).toEqual(next.state); + }); + it("admits selection only from the latest canonical option set", () => { const begin = beginPlaybackSourceDiscovery( createPlaybackSourceSession(projectA), From 8a9484424783a3950f4c3deb0c0e2ee6f33e2bc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:30:21 +0900 Subject: [PATCH 069/160] fix(player): never reuse playback discovery sequence identities --- .../workspace/playbackSourceSession.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.ts b/apps/desktop/src/features/workspace/playbackSourceSession.ts index 2f0b0a557..5275d4a5b 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.ts @@ -108,6 +108,9 @@ export function createPlaybackSourceSession( * * Native stem authority is revocable. Keeping old options visible while an async * refresh runs would let a stale button outlive the authority snapshot that created it. + * Request identities never wrap: after the safe-integer sequence is exhausted the + * session stays full-mix-only until a new session is created, so an ancient receipt + * cannot become current again by colliding with a reused sequence number. */ export function beginPlaybackSourceDiscovery( state: PlaybackSourceSession, @@ -116,21 +119,38 @@ export function beginPlaybackSourceDiscovery( state: PlaybackSourceSession; request: PlaybackSourceDiscoveryRequest | null; } { + const currentSequence = + Number.isSafeInteger(state.requestSequence) && state.requestSequence >= 0 + ? state.requestSequence + : Number.MAX_SAFE_INTEGER; const nextSequence = - state.requestSequence >= Number.MAX_SAFE_INTEGER ? 1 : state.requestSequence + 1; + currentSequence < Number.MAX_SAFE_INTEGER ? currentSequence + 1 : null; if (!isValidFullMixAuthority(currentFullMixAuthority)) { return { state: { fullMixAuthority: null, options: [], pendingRequest: null, - requestSequence: nextSequence, + requestSequence: nextSequence ?? currentSequence, selectedAuthority: null, }, request: null, }; } + if (nextSequence === null) { + return { + state: { + fullMixAuthority: currentFullMixAuthority, + options: fullMixOnly(currentFullMixAuthority), + pendingRequest: null, + requestSequence: currentSequence, + selectedAuthority: currentFullMixAuthority, + }, + request: null, + }; + } + const request = { fullMixAuthority: currentFullMixAuthority, sequence: nextSequence, From d988208d4acdd432e145d7f6235f1616afef0a07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:31:01 +0900 Subject: [PATCH 070/160] docs(traceability): record playback discovery identity exhaustion repair --- docs/traceability/playback-source-session.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md index feeefb940..be0b74113 100644 --- a/docs/traceability/playback-source-session.md +++ b/docs/traceability/playback-source-session.md @@ -4,9 +4,9 @@ - **Date:** 2026-09-05 - **Bounded context:** Active Player / renderer playback-source session and media-switch continuity - **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` -- **Canonical Active Player owner:** PR #971 `9c1b20e6df778e303fada3e170c93418c496394b` -- **Stem publication parent:** PR #1159 `22a9f18d960cc7df93db890b2a5aa9594428c2b4` -- **Current native/UI child source head before this documentation update:** PR #1160 `799c6b6e25a0fa290fba61f7ebdd303350f87888` +- **Canonical Active Player owner:** PR #971 `09bedd835475015379716292e63e6be376fceec9` +- **Stem publication parent:** PR #1159 `c27f3781ddcbcc013dce07a26c0baf6080e4b2ac` +- **Current native/UI child source head before this documentation update:** PR #1160 `8a9484424783a3950f4c3deb0c0e2ee6f33e2bc0` ## Problem @@ -45,6 +45,15 @@ The initial normalizer still had one fail-closed hole: an object with an own thr The catch is deliberately confined to untrusted discovery-payload normalization. It does not swallow unrelated player/media failures or weaken the canonical source projector. +## Discovery identity exhaustion repair + +The first session implementation wrapped `requestSequence` from `Number.MAX_SAFE_INTEGER` back to `1`. Request matching is intentionally value-based on `{ fullMixAuthority, sequence }`, so a long-lived renderer session could eventually make an ancient sequence-1 receipt indistinguishable from a newly wrapped sequence-1 request for the same project. The practical counter horizon is extremely large, but reusing an authority receipt identity violates the stale-response invariant and is unnecessary. + +- RED `c899df46860921f74691ffb65351949f77e449bc` requires the session never to reuse a discovery identity after safe-integer exhaustion and proves an ancient `{ sequence: 1 }` receipt remains inadmissible. +- Causal fix `8a9484424783a3950f4c3deb0c0e2ee6f33e2bc0` removes sequence wraparound. An exhausted or corrupted sequence fails closed to full-mix-only state with no pending native discovery. A new mounted `PlaybackSourceSession` is required before discovery can resume. + +This does not broaden authority or introduce another session owner. It makes the existing monotonic request identity actually monotonic for the lifetime of the renderer session. + ## Source-switch continuity and stale media-receipt repair The branch had already adopted a source-switch continuity contract: @@ -71,4 +80,4 @@ Fresh Actions lookup on the preceding exact source heads returned no pull-reques ## Delivery gate -**FAIL.** Stale/hostile discovery-session admission, source-switch continuity, and stale media-receipt rejection are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. +**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, source-switch continuity, and stale media-receipt rejection are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. From e175246ed31dd991d3c76c9ca3e12e92596e3702 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:06:39 +0900 Subject: [PATCH 071/160] test(player): reject foreign source switch authorities --- .../workspace/playbackSourceSwitch.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts index ce1011600..b94d4b814 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -115,6 +115,27 @@ describe("playback source switch continuity", () => { expect(capture("looping", 37.25, targetAuthority, sequence)).toBeNull(); }); + it.each([ + ["file:///private/source.wav", vocalsAuthority], + [fullMixAuthority, "https://example.com/reference.wav"], + [ + fullMixAuthority, + "bandscope-project://project-99-1/stem/vocals", + ], + [`${fullMixAuthority}/stem/guitar`, vocalsAuthority], + ])( + "rejects non-canonical or cross-project source-switch authority: %s -> %s", + (sourceAuthority, targetAuthority) => { + expect( + capturePlaybackSourceSwitch(transport("looping"), 37.25, { + sourceAuthority, + targetAuthority, + sequence: 3, + }), + ).toBeNull(); + }, + ); + it("admits a target only when its decoded duration and switch receipt still match the active target", () => { const plan = capture("looping", 37.25); expect(plan).not.toBeNull(); From bf33e412002ede67443f3ad2b3a19f7b9b869eae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:07:46 +0900 Subject: [PATCH 072/160] fix(player): bind source switches to one project authority --- .../features/workspace/playbackSourceSelection.ts | 8 ++++++++ .../src/features/workspace/playbackSourceSwitch.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.ts index 290c1d2f9..b91f65474 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSelection.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.ts @@ -17,6 +17,14 @@ const SOURCE_AUTHORITY = /^bandscope-project:\/\/(project-[0-9]+-[0-9]+)(?:\/stem\/(vocals|bass|drums|other))?$/; const STEM_ORDER = ["vocals", "bass", "drums", "other"] as const; +/** Return the owning project id only for one canonical renderer playback authority. */ +export function playbackSourceProjectId(authority: unknown): string | null { + if (typeof authority !== "string") { + return null; + } + return authority.match(SOURCE_AUTHORITY)?.[1] ?? null; +} + /** * Project native availability into renderer options without creating authority. * A generated set is all-or-nothing because native admission binds four stems atomically. diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index c5704d7e7..7f4152857 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -4,6 +4,7 @@ import { type RehearsalTransportPhase, type RehearsalTransportState, } from "./rehearsalTransport"; +import { playbackSourceProjectId } from "./playbackSourceSelection"; /** Identity of one renderer-owned media-source replacement attempt. */ export interface PlaybackSourceSwitchIdentity { @@ -23,11 +24,11 @@ export interface PlaybackSourceSwitchPlan extends PlaybackSourceSwitchIdentity { } function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean { + const sourceProjectId = playbackSourceProjectId(identity.sourceAuthority); + const targetProjectId = playbackSourceProjectId(identity.targetAuthority); return ( - typeof identity.sourceAuthority === "string" && - identity.sourceAuthority.length > 0 && - typeof identity.targetAuthority === "string" && - identity.targetAuthority.length > 0 && + sourceProjectId !== null && + sourceProjectId === targetProjectId && identity.sourceAuthority !== identity.targetAuthority && Number.isSafeInteger(identity.sequence) && identity.sequence > 0 @@ -41,7 +42,8 @@ function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean * count-in clock is running would create a second timing race. Looping/paused * switches retain the exact admitted media position; armed switches start from the * selected loop boundary. Invalid positions or switch identities fail closed rather - * than being clamped or converted into an ambiguous no-op. + * than being clamped or converted into an ambiguous no-op. Source and target must + * both be canonical opaque authorities for the same mounted playback project. */ export function capturePlaybackSourceSwitch( transport: RehearsalTransportState, From b3e3f89ef7058551a6360f8093a554704e9e06f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:08:39 +0900 Subject: [PATCH 073/160] test(player): cover canonical playback authority parsing --- .../workspace/playbackSourceSelection.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts index 9eee5fb42..b10500c9b 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { derivePlaybackSourceOptions } from "./playbackSourceSelection"; +import { + derivePlaybackSourceOptions, + playbackSourceProjectId, +} from "./playbackSourceSelection"; const fullMix = "bandscope-project://project-100-1"; const stems = { @@ -65,4 +68,18 @@ describe("playback source selection authority", () => { ]), ).toBeNull(); }); + + it("extracts project identity only from canonical opaque playback authorities", () => { + expect(playbackSourceProjectId(fullMix)).toBe("project-100-1"); + expect(playbackSourceProjectId(stems.other)).toBe("project-100-1"); + for (const invalid of [ + null, + 42, + "file:///private/source.wav", + "bandscope-project://project-100-1/stem/guitar", + `${stems.vocals}/../private.wav`, + ]) { + expect(playbackSourceProjectId(invalid)).toBeNull(); + } + }); }); From 7590d4322eb2c7511098be3bb839db2e174b1a2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:09:33 +0900 Subject: [PATCH 074/160] docs(player): trace same-project source switch authority --- docs/traceability/playback-source-session.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md index be0b74113..758e2e9a7 100644 --- a/docs/traceability/playback-source-session.md +++ b/docs/traceability/playback-source-session.md @@ -6,7 +6,7 @@ - **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` - **Canonical Active Player owner:** PR #971 `09bedd835475015379716292e63e6be376fceec9` - **Stem publication parent:** PR #1159 `c27f3781ddcbcc013dce07a26c0baf6080e4b2ac` -- **Current native/UI child source head before this documentation update:** PR #1160 `8a9484424783a3950f4c3deb0c0e2ee6f33e2bc0` +- **Current native/UI child source head before this documentation update:** PR #1160 `b3e3f89ef7058551a6360f8093a554704e9e06f1` ## Problem @@ -68,6 +68,16 @@ A second race remained because that plan had no immutable identity for the media The sequence is renderer-owned and carries no filesystem or native capability. The fix does not create a second playback authority; it only prevents a mutable media-element event from reactivating stale transport state. +## Same-project switch authority repair + +The first switch-identity guard checked only that source and target were non-empty, different strings with a positive safe sequence. The canonical source session normally supplies same-project opaque handles, but `capturePlaybackSourceSwitch` itself would still mint a continuity plan for an arbitrary URL, native-path-shaped string, unknown stem suffix, or a handle belonging to another app-minted project. Leaving that invariant to a future mounted caller makes the media-switch transaction easier to misuse during integration. + +- RED `e175246ed31dd991d3c76c9ca3e12e92596e3702` adds `file://`, `https://`, unknown-stem and cross-project source/target cases and requires all of them to be rejected before a continuity plan exists. +- Causal fix `bf33e412002ede67443f3ad2b3a19f7b9b869eae` adds the reusable `playbackSourceProjectId` parser at the existing renderer source-selection authority boundary and makes `capturePlaybackSourceSwitch` require canonical source and target handles owned by the same playback project. It does not accept a filesystem path or add a second URI grammar. +- Coverage follow-up `b3e3f89ef7058551a6360f8093a554704e9e06f1` exercises canonical full-mix/stem parsing plus non-string, native-path, unknown-stem and path-shaped rejection directly so the new public parser does not rely only on indirect switch tests. + +The repair narrows renderer state only. Native `PlaybackAuthority` remains the sole owner of the actual file identity and bytes, and the current source session still decides which same-project handles are selectable at any instant. + ## Current acceptance boundary The renderer-side authority/session and switch-receipt contracts are not the finished selector. `RehearsalPlayer` still uses `audioSourcePath` directly, pauses and reloads when its resolved media URL changes, and does not yet call `discoverPlaybackSourceOptions`, bind `PlaybackSourceSession`, render `Full mix | Vocals | Bass | Drums | Other instruments`, or execute the source-switch plan and receipt through the actual `audio.src` → `load()` → `loadedmetadata` lifecycle. @@ -80,4 +90,4 @@ Fresh Actions lookup on the preceding exact source heads returned no pull-reques ## Delivery gate -**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, source-switch continuity, and stale media-receipt rejection are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. +**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, same-project source-switch authority, source-switch continuity, and stale media-receipt rejection are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. From 102ceb0fcf13424fecd865701a960dc30a4f5f42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:32:39 +0900 Subject: [PATCH 075/160] test(player): require monotonic source-switch session receipts --- .../workspace/playbackSourceSwitch.test.ts | 93 ++++++++++++++++++- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts index b94d4b814..5b91c0c0c 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -5,7 +5,9 @@ import type { } from "./rehearsalTransport"; import { admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, capturePlaybackSourceSwitch, + createPlaybackSourceSwitchSession, } from "./playbackSourceSwitch"; const loop: RehearsalLoopWindow = { @@ -118,10 +120,7 @@ describe("playback source switch continuity", () => { it.each([ ["file:///private/source.wav", vocalsAuthority], [fullMixAuthority, "https://example.com/reference.wav"], - [ - fullMixAuthority, - "bandscope-project://project-99-1/stem/vocals", - ], + [fullMixAuthority, "bandscope-project://project-99-1/stem/vocals"], [`${fullMixAuthority}/stem/guitar`, vocalsAuthority], ])( "rejects non-canonical or cross-project source-switch authority: %s -> %s", @@ -170,4 +169,90 @@ describe("playback source switch continuity", () => { admitPlaybackSourceSwitchTarget(currentPlan, 45, bassAuthority, 4), ).toEqual(currentPlan); }); + + it("invalidates the prior media receipt as soon as a newer source switch begins", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport("looping"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect(first.plan?.sequence).toBe(1); + expect(second.plan?.sequence).toBe(2); + expect(second.state.activePlan).toEqual(second.plan); + expect( + admitPlaybackSourceSwitchTarget( + first.plan, + 45, + vocalsAuthority, + second.state.sequence, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + second.plan, + 45, + bassAuthority, + second.state.sequence, + ), + ).toEqual(second.plan); + }); + + it("burns a switch identity even when the newer attempt cannot produce a continuity plan", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const rejected = beginPlaybackSourceSwitch( + first.state, + transport("counting-in"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect(rejected.plan).toBeNull(); + expect(rejected.state.sequence).toBe(2); + expect(rejected.state.activePlan).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + first.plan, + 45, + vocalsAuthority, + rejected.state.sequence, + ), + ).toBeNull(); + }); + + it("fails closed without reusing a switch receipt after sequence exhaustion", () => { + const exhausted = { + sequence: Number.MAX_SAFE_INTEGER, + activePlan: capture("looping", 37.25, vocalsAuthority, Number.MAX_SAFE_INTEGER), + }; + const result = beginPlaybackSourceSwitch( + exhausted, + transport("looping"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect(result.plan).toBeNull(); + expect(result.state).toEqual({ + sequence: Number.MAX_SAFE_INTEGER, + activePlan: null, + }); + }); }); From 7cc68bff49cd1bc038ab2da56c04dff5a1bb4bd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:33:16 +0900 Subject: [PATCH 076/160] fix(player): invalidate stale media receipts on source switch start --- .../workspace/playbackSourceSwitch.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index 7f4152857..251838206 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -23,6 +23,12 @@ export interface PlaybackSourceSwitchPlan extends PlaybackSourceSwitchIdentity { resumeAfterLoad: boolean; } +/** Renderer-owned identity state for the mutable HTML media source lifecycle. */ +export interface PlaybackSourceSwitchSession { + sequence: number; + activePlan: PlaybackSourceSwitchPlan | null; +} + function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean { const sourceProjectId = playbackSourceProjectId(identity.sourceAuthority); const targetProjectId = playbackSourceProjectId(identity.targetAuthority); @@ -35,6 +41,11 @@ function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean ); } +/** Start a renderer switch session with no reusable media receipt. */ +export function createPlaybackSourceSwitchSession(): PlaybackSourceSwitchSession { + return { sequence: 0, activePlan: null }; +} + /** * Capture transport continuity before replacing the media source. * @@ -83,6 +94,48 @@ export function capturePlaybackSourceSwitch( }; } +/** + * Begin one media-source replacement and invalidate every older metadata receipt. + * + * The sequence is burned before continuity capture. A rejected target or transport + * phase therefore cannot leave an older `loadedmetadata` receipt authoritative. + * Sequence values never wrap; exhaustion clears the active plan until the player + * mounts a fresh switch session. + */ +export function beginPlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + transport: RehearsalTransportState, + currentMediaTimeSeconds: number, + sourceAuthority: string, + targetAuthority: string, +): { state: PlaybackSourceSwitchSession; plan: PlaybackSourceSwitchPlan | null } { + const currentSequence = + Number.isSafeInteger(state.sequence) && state.sequence >= 0 + ? state.sequence + : Number.MAX_SAFE_INTEGER; + if (currentSequence >= Number.MAX_SAFE_INTEGER) { + return { + state: { sequence: Number.MAX_SAFE_INTEGER, activePlan: null }, + plan: null, + }; + } + + const sequence = currentSequence + 1; + const plan = capturePlaybackSourceSwitch( + transport, + currentMediaTimeSeconds, + { + sourceAuthority, + targetAuthority, + sequence, + }, + ); + return { + state: { sequence, activePlan: plan }, + plan, + }; +} + /** * Admit the decoded target only when it still belongs to the active switch receipt * and can cover the selected loop and captured position. From 3be991ce6dd81bbee8f111876997d16176b82485 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:34:09 +0900 Subject: [PATCH 077/160] docs(player): trace source-switch receipt invalidation --- docs/traceability/playback-source-session.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md index 758e2e9a7..cf9b29ba9 100644 --- a/docs/traceability/playback-source-session.md +++ b/docs/traceability/playback-source-session.md @@ -6,7 +6,7 @@ - **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` - **Canonical Active Player owner:** PR #971 `09bedd835475015379716292e63e6be376fceec9` - **Stem publication parent:** PR #1159 `c27f3781ddcbcc013dce07a26c0baf6080e4b2ac` -- **Current native/UI child source head before this documentation update:** PR #1160 `b3e3f89ef7058551a6360f8093a554704e9e06f1` +- **Current native/UI child source head before this documentation update:** PR #1160 `7cc68bff49cd1bc038ab2da56c04dff5a1bb4bd9` ## Problem @@ -78,16 +78,25 @@ The first switch-identity guard checked only that source and target were non-emp The repair narrows renderer state only. Native `PlaybackAuthority` remains the sole owner of the actual file identity and bytes, and the current source session still decides which same-project handles are selectable at any instant. +## Switch-session receipt invalidation repair + +The continuity plan carried a sequence, but no production helper owned sequence advancement and active-plan invalidation. A future mounted selector could therefore update `audio.src` for a newer choice before invalidating the previous `loadedmetadata` receipt, leaving a narrow integration window where the older receipt still looked current. + +- RED `102ceb0fcf13424fecd865701a960dc30a4f5f42` requires a renderer switch session to mint monotonic sequence `1`, then `2`, reject the first receipt as soon as the second switch begins, burn a new identity even when continuity capture is rejected, and fail closed rather than wrap at `Number.MAX_SAFE_INTEGER`. +- Causal fix `7cc68bff49cd1bc038ab2da56c04dff5a1bb4bd9` adds `PlaybackSourceSwitchSession`, `createPlaybackSourceSwitchSession`, and `beginPlaybackSourceSwitch`. The session increments before continuity capture and replaces `activePlan` immediately, so a failed or superseding attempt cannot leave an older media receipt authoritative. Sequence exhaustion clears the plan and requires a freshly mounted session rather than identity reuse. + +The switch session is renderer-only receipt state. It neither decides native file availability nor creates a second transport or playback authority. + ## Current acceptance boundary The renderer-side authority/session and switch-receipt contracts are not the finished selector. `RehearsalPlayer` still uses `audioSourcePath` directly, pauses and reloads when its resolved media URL changes, and does not yet call `discoverPlaybackSourceOptions`, bind `PlaybackSourceSession`, render `Full mix | Vocals | Bass | Drums | Other instruments`, or execute the source-switch plan and receipt through the actual `audio.src` → `load()` → `loadedmetadata` lifecycle. -The next source fix must therefore keep one mounted switch transaction owner: increment the renderer switch sequence when a currently selectable authority is chosen, capture continuity before replacing `src`, invalidate prior receipts immediately, admit `loadedmetadata` only against the exact current target/sequence, seek and restore playback rate only after target-duration admission, and resume only when the captured plan came from a previously looping transport. Revocation or malformed/short target media must fail closed to a non-playing state rather than silently falling back to stale transport. +The next source fix must therefore mount the existing session contracts in one transaction owner: refresh native availability, keep selection within the current option snapshot, call `beginPlaybackSourceSwitch` before replacing `src`, admit `loadedmetadata` only against the exact current target/sequence, seek and restore playback rate only after target-duration admission, and resume only when the captured plan came from a previously looping transport. Revocation or malformed/short target media must fail closed to a non-playing state rather than silently falling back to stale transport. Current locale infrastructure supports English and Korean only. JA/ZH/VI/ES/DE/FR expansion, text expansion/CJK fallback checks, pointer/touch/keyboard/screen-reader interaction, persistence/reload, stale/revocation UI behavior and rights-cleared audible macOS/Windows acceptance therefore remain open. -Fresh Actions lookup on the preceding exact source heads returned no pull-request workflow runs for this stacked branch. Source-level RED→fix lineage is not a substitute for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft. +Source-level RED→fix lineage is not a substitute for the 14 protected repository/central required contexts or qualifying independent unchanged-head approval. PR #1160 remains Draft until exact-head evidence materializes and is terminal-success. ## Delivery gate -**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, same-project source-switch authority, source-switch continuity, and stale media-receipt rejection are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. +**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, same-project source-switch authority, source-switch continuity, stale media-receipt rejection, and immediate switch-session receipt invalidation are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. From 881e53b5e551ac050aa0d6b347b310937eb5412b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:04:13 +0900 Subject: [PATCH 078/160] test(player): reject mutable source-switch receipts --- .../playbackSourceSwitch.immutability.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts new file mode 100644 index 000000000..b0f821e19 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { RehearsalTransportState } from "./rehearsalTransport"; +import { + admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, + createPlaybackSourceSwitchSession, +} from "./playbackSourceSwitch"; + +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const bassAuthority = `${fullMixAuthority}/stem/bass`; + +const loopingTransport: RehearsalTransportState = { + phase: "looping", + loop: { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, + }, + countInRemainingBeats: 0, + playheadSeconds: 37.25, + playbackRate: 0.75, +}; + +describe("playback source switch receipt immutability", () => { + it("does not let later renderer code rewrite an issued receipt or its session identity", () => { + const started = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + loopingTransport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + + expect(started.plan).not.toBeNull(); + expect(Object.isFrozen(started.plan)).toBe(true); + expect(Object.isFrozen(started.state)).toBe(true); + expect( + Reflect.set(started.plan as object, "targetAuthority", bassAuthority), + ).toBe(false); + expect(Reflect.set(started.plan as object, "seekSeconds", 44)).toBe(false); + expect(Reflect.set(started.state as object, "sequence", 99)).toBe(false); + + expect(started.plan?.targetAuthority).toBe(vocalsAuthority); + expect(started.plan?.seekSeconds).toBe(37.25); + expect(started.state.sequence).toBe(1); + expect( + admitPlaybackSourceSwitchTarget( + started.plan, + 45, + vocalsAuthority, + started.state.sequence, + ), + ).toEqual(started.plan); + }); +}); From 3fbd43b887262c79b80aee0d873f7c6d67d2bcc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:04:36 +0900 Subject: [PATCH 079/160] fix(player): freeze source-switch receipt identity --- .../workspace/playbackSourceSwitch.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index 251838206..ee50da994 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -41,9 +41,16 @@ function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean ); } +function freezePlaybackSourceSwitchSession( + sequence: number, + activePlan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + return Object.freeze({ sequence, activePlan }); +} + /** Start a renderer switch session with no reusable media receipt. */ export function createPlaybackSourceSwitchSession(): PlaybackSourceSwitchSession { - return { sequence: 0, activePlan: null }; + return freezePlaybackSourceSwitchSession(0, null); } /** @@ -83,7 +90,7 @@ export function capturePlaybackSourceSwitch( return null; } - return { + return Object.freeze({ ...identity, loopStartSeconds: loop.startSeconds, loopEndSeconds: loop.endSeconds, @@ -91,7 +98,7 @@ export function capturePlaybackSourceSwitch( playbackRate: transport.playbackRate, sourcePhase: transport.phase, resumeAfterLoad: transport.phase === "looping", - }; + }); } /** @@ -100,7 +107,9 @@ export function capturePlaybackSourceSwitch( * The sequence is burned before continuity capture. A rejected target or transport * phase therefore cannot leave an older `loadedmetadata` receipt authoritative. * Sequence values never wrap; exhaustion clears the active plan until the player - * mounts a fresh switch session. + * mounts a fresh switch session. Issued receipts and session identities are frozen + * so later renderer code cannot rewrite what a future metadata event is allowed to + * restore. */ export function beginPlaybackSourceSwitch( state: PlaybackSourceSwitchSession, @@ -115,7 +124,7 @@ export function beginPlaybackSourceSwitch( : Number.MAX_SAFE_INTEGER; if (currentSequence >= Number.MAX_SAFE_INTEGER) { return { - state: { sequence: Number.MAX_SAFE_INTEGER, activePlan: null }, + state: freezePlaybackSourceSwitchSession(Number.MAX_SAFE_INTEGER, null), plan: null, }; } @@ -131,7 +140,7 @@ export function beginPlaybackSourceSwitch( }, ); return { - state: { sequence, activePlan: plan }, + state: freezePlaybackSourceSwitchSession(sequence, plan), plan, }; } From d1bf63fed18226c4d81fe7bfd99efba7f9d325f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:05:24 +0900 Subject: [PATCH 080/160] docs(traceability): record immutable playback receipts --- docs/traceability/playback-source-session.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/traceability/playback-source-session.md b/docs/traceability/playback-source-session.md index cf9b29ba9..f14707e74 100644 --- a/docs/traceability/playback-source-session.md +++ b/docs/traceability/playback-source-session.md @@ -6,7 +6,7 @@ - **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` - **Canonical Active Player owner:** PR #971 `09bedd835475015379716292e63e6be376fceec9` - **Stem publication parent:** PR #1159 `c27f3781ddcbcc013dce07a26c0baf6080e4b2ac` -- **Current native/UI child source head before this documentation update:** PR #1160 `7cc68bff49cd1bc038ab2da56c04dff5a1bb4bd9` +- **Current native/UI child source head before this documentation update:** PR #1160 `3fbd43b887262c79b80aee0d873f7c6d67d2bcc8` ## Problem @@ -87,6 +87,15 @@ The continuity plan carried a sequence, but no production helper owned sequence The switch session is renderer-only receipt state. It neither decides native file availability nor creates a second transport or playback authority. +## Issued receipt immutability repair + +The switch-session helper made receipt identity monotonic but still returned ordinary mutable JavaScript objects. `PlaybackSourceSwitchPlan` and `PlaybackSourceSwitchSession` could therefore be rewritten by later renderer code between `beginPlaybackSourceSwitch` and `loadedmetadata`. In particular, mutating `targetAuthority`, `seekSeconds`, or `sequence` after issue would silently change the facts that a future media receipt was allowed to restore. + +- RED `881e53b5e551ac050aa0d6b347b310937eb5412b` adds a focused regression proving the issued plan and session must reject `Reflect.set` attempts on target authority, seek position, and renderer sequence while remaining admissible under their original values. +- Causal fix `3fbd43b887262c79b80aee0d873f7c6d67d2bcc8` freezes every issued switch plan and session identity with `Object.freeze`. All receipt fields are scalar values, so shallow freezing is sufficient for this contract; no native capability or additional state owner is introduced. + +This is a renderer integrity boundary, not a substitute for target-duration admission. The current target authority, current sequence, and decoded duration must still match in `admitPlaybackSourceSwitchTarget` before transport continuity can be restored. + ## Current acceptance boundary The renderer-side authority/session and switch-receipt contracts are not the finished selector. `RehearsalPlayer` still uses `audioSourcePath` directly, pauses and reloads when its resolved media URL changes, and does not yet call `discoverPlaybackSourceOptions`, bind `PlaybackSourceSession`, render `Full mix | Vocals | Bass | Drums | Other instruments`, or execute the source-switch plan and receipt through the actual `audio.src` → `load()` → `loadedmetadata` lifecycle. @@ -99,4 +108,4 @@ Source-level RED→fix lineage is not a substitute for the 14 protected reposito ## Delivery gate -**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, same-project source-switch authority, source-switch continuity, stale media-receipt rejection, and immediate switch-session receipt invalidation are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. +**FAIL.** Stale/hostile discovery-session admission, non-reused discovery identity, same-project source-switch authority, source-switch continuity, stale media-receipt rejection, immediate switch-session receipt invalidation, and immutable issued receipt identity are represented in source. The mounted selector, actual media-switch transaction, current-head required CI/security/coverage/build evidence, eight-locale UI evidence and rights-cleared real-audio desktop acceptance are not complete. From a79af752577ea903875883247f8d89146c7c903a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:05:14 +0900 Subject: [PATCH 081/160] test(player): freeze playback source session receipts --- ...playbackSourceSession.immutability.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts new file mode 100644 index 000000000..be3bfc677 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + beginPlaybackSourceDiscovery, + completePlaybackSourceDiscovery, + createPlaybackSourceSession, + selectPlaybackSource, +} from "./playbackSourceSession"; + +const projectA = "bandscope-project://project-100-1"; +const projectAOptions = [ + { kind: "full_mix" as const, authority: projectA }, + { kind: "vocals" as const, authority: `${projectA}/stem/vocals` }, + { kind: "bass" as const, authority: `${projectA}/stem/bass` }, + { kind: "drums" as const, authority: `${projectA}/stem/drums` }, + { kind: "other" as const, authority: `${projectA}/stem/other` }, +]; + +describe("playback source session receipt integrity", () => { + it("keeps authority-bearing session snapshots immutable across discovery and selection", () => { + const initial = createPlaybackSourceSession(projectA); + expect(Object.isFrozen(initial)).toBe(true); + expect(Object.isFrozen(initial.options)).toBe(true); + expect(Object.isFrozen(initial.options[0])).toBe(true); + + const refresh = beginPlaybackSourceDiscovery(initial, projectA); + expect(Object.isFrozen(refresh.state)).toBe(true); + expect(Object.isFrozen(refresh.state.options)).toBe(true); + expect(Object.isFrozen(refresh.request)).toBe(true); + + const completed = completePlaybackSourceDiscovery( + refresh.state, + refresh.request, + projectAOptions, + ); + expect(Object.isFrozen(completed)).toBe(true); + expect(Object.isFrozen(completed.options)).toBe(true); + expect(completed.options.every(Object.isFrozen)).toBe(true); + expect(Reflect.set(completed.options[1], "authority", `${projectA}/stem/guitar`)).toBe( + false, + ); + expect(completed.options[1]?.authority).toBe(`${projectA}/stem/vocals`); + + const selected = selectPlaybackSource(completed, `${projectA}/stem/drums`); + expect(Object.isFrozen(selected)).toBe(true); + expect(Object.isFrozen(selected.options)).toBe(true); + expect(selected.selectedAuthority).toBe(`${projectA}/stem/drums`); + expect(Reflect.set(selected, "selectedAuthority", `${projectA}/stem/guitar`)).toBe( + false, + ); + expect(selected.selectedAuthority).toBe(`${projectA}/stem/drums`); + }); +}); From 580677af836251b62d1514024a15b8fa1527646a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:12:59 +0900 Subject: [PATCH 082/160] fix(player): freeze playback source authority snapshots --- .../workspace/playbackSourceSession.ts | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.ts b/apps/desktop/src/features/workspace/playbackSourceSession.ts index 5275d4a5b..a70aef0a5 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.ts @@ -23,6 +23,34 @@ function fullMixOnly(authority: string): PlaybackSourceOption[] { return [{ kind: "full_mix", authority }]; } +function freezePlaybackSourceOptions( + options: PlaybackSourceOption[], +): PlaybackSourceOption[] { + const frozenOptions = options.map((option) => + Object.freeze({ kind: option.kind, authority: option.authority }), + ); + return Object.freeze(frozenOptions) as PlaybackSourceOption[]; +} + +function freezePlaybackSourceDiscoveryRequest( + request: PlaybackSourceDiscoveryRequest | null, +): PlaybackSourceDiscoveryRequest | null { + if (request === null || Object.isFrozen(request)) { + return request; + } + return Object.freeze({ ...request }); +} + +function freezePlaybackSourceSession( + state: PlaybackSourceSession, +): PlaybackSourceSession { + return Object.freeze({ + ...state, + options: freezePlaybackSourceOptions(state.options), + pendingRequest: freezePlaybackSourceDiscoveryRequest(state.pendingRequest), + }) as PlaybackSourceSession; +} + function isValidFullMixAuthority(authority: string | null | undefined): authority is string { return ( typeof authority === "string" && @@ -86,21 +114,21 @@ export function createPlaybackSourceSession( fullMixAuthority: string | null | undefined, ): PlaybackSourceSession { if (!isValidFullMixAuthority(fullMixAuthority)) { - return { + return freezePlaybackSourceSession({ fullMixAuthority: null, options: [], pendingRequest: null, requestSequence: 0, selectedAuthority: null, - }; + }); } - return { + return freezePlaybackSourceSession({ fullMixAuthority, options: fullMixOnly(fullMixAuthority), pendingRequest: null, requestSequence: 0, selectedAuthority: fullMixAuthority, - }; + }); } /** @@ -127,42 +155,42 @@ export function beginPlaybackSourceDiscovery( currentSequence < Number.MAX_SAFE_INTEGER ? currentSequence + 1 : null; if (!isValidFullMixAuthority(currentFullMixAuthority)) { return { - state: { + state: freezePlaybackSourceSession({ fullMixAuthority: null, options: [], pendingRequest: null, requestSequence: nextSequence ?? currentSequence, selectedAuthority: null, - }, + }), request: null, }; } if (nextSequence === null) { return { - state: { + state: freezePlaybackSourceSession({ fullMixAuthority: currentFullMixAuthority, options: fullMixOnly(currentFullMixAuthority), pendingRequest: null, requestSequence: currentSequence, selectedAuthority: currentFullMixAuthority, - }, + }), request: null, }; } - const request = { + const request = Object.freeze({ fullMixAuthority: currentFullMixAuthority, sequence: nextSequence, - } satisfies PlaybackSourceDiscoveryRequest; + }) satisfies PlaybackSourceDiscoveryRequest; return { - state: { + state: freezePlaybackSourceSession({ fullMixAuthority: currentFullMixAuthority, options: fullMixOnly(currentFullMixAuthority), pendingRequest: request, requestSequence: nextSequence, selectedAuthority: currentFullMixAuthority, - }, + }), request, }; } @@ -198,12 +226,12 @@ export function completePlaybackSourceDiscovery( ? state.selectedAuthority : request.fullMixAuthority; - return { + return freezePlaybackSourceSession({ ...state, options, pendingRequest: null, selectedAuthority, - }; + }); } /** Select only an authority present in the current canonical option snapshot. */ @@ -212,10 +240,10 @@ export function selectPlaybackSource( authority: string, ): PlaybackSourceSession { if (state.options.some((option) => option.authority === authority)) { - return { ...state, selectedAuthority: authority }; + return freezePlaybackSourceSession({ ...state, selectedAuthority: authority }); } - return { + return freezePlaybackSourceSession({ ...state, selectedAuthority: state.fullMixAuthority, - }; + }); } From 3068f0159195317c7db62763c6ee5e951e7cb4a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:15:05 +0900 Subject: [PATCH 083/160] docs(traceability): record playback source snapshot immutability repair --- ...ck-source-session-snapshot-immutability.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/traceability/playback-source-session-snapshot-immutability.md diff --git a/docs/traceability/playback-source-session-snapshot-immutability.md b/docs/traceability/playback-source-session-snapshot-immutability.md new file mode 100644 index 000000000..bdfaebe74 --- /dev/null +++ b/docs/traceability/playback-source-session-snapshot-immutability.md @@ -0,0 +1,61 @@ +# Playback source session snapshot immutability + +- **Status:** Draft implementation evidence; not shipped or release acceptance +- **Date:** 2026-09-05 +- **Bounded context:** Active Player / renderer playback-source authority session +- **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` +- **Canonical Active Player owner:** PR #971 `09bedd835475015379716292e63e6be376fceec9` +- **Stem publication parent:** PR #1159 `c27f3781ddcbcc013dce07a26c0baf6080e4b2ac` +- **RED head:** PR #1160 `a79af752577ea903875883247f8d89146c7c903a` +- **Causal source fix:** `580677af836251b62d1514024a15b8fa1527646a` + +## Problem + +`PlaybackSourceSession` decides which opaque native playback authorities are currently visible and selectable in the renderer. Discovery request identity, canonical option membership and `selectedAuthority` are therefore authority-bearing receipt state rather than ordinary presentation data. + +The existing source returned mutable JavaScript objects and arrays. Code running after `createPlaybackSourceSession`, `beginPlaybackSourceDiscovery`, `completePlaybackSourceDiscovery`, or `selectPlaybackSource` could mutate a session, an option authority, or a pending discovery request after it had passed canonical validation. That would make the later renderer state differ from the receipt that was originally admitted, undermining the same stale/revocation boundary already enforced for `PlaybackSourceSwitchPlan`. + +## Constraint + +The repair must not introduce a second playback authority or parser, expose native paths/hashes/file identity, widen source kinds, keep stale stems visible during refresh, or change sequence/revocation semantics. Native `PlaybackAuthority` remains the owner of actual bytes and file identity; the existing source projector remains the authority grammar. + +## Test-first evidence + +Commit `a79af752577ea903875883247f8d89146c7c903a` adds `playbackSourceSession.immutability.test.ts` before the production repair. It requires: + +- the initial session, option array and option object to be frozen; +- refresh state, refresh options and the issued discovery request to be frozen; +- completed canonical option snapshots to be frozen recursively at the array/option level; +- mutation of an admitted stem authority through `Reflect.set` to fail without changing the authority; and +- selected session state to remain immutable after a valid selection. + +This is committed RED contract evidence. No hosted run is inferred from the test's presence. + +## Chosen repair + +Commit `580677af836251b62d1514024a15b8fa1527646a` centralizes immutable snapshot construction in `playbackSourceSession.ts`: + +- each emitted `PlaybackSourceOption` is copied to a frozen scalar object and the option list itself is frozen; +- each issued discovery request is frozen; +- each newly emitted session snapshot is frozen and contains frozen options plus a frozen pending request when present; and +- create, refresh, completion and selection paths all emit through that boundary. + +The stale-completion path continues to return the already-current session unchanged. Sessions produced by this API are already immutable, so stale receipt rejection does not manufacture another state transition merely to re-freeze the same current receipt. + +A deep-freeze utility was rejected. The authority-bearing structures here contain only scalar values and one array of scalar option objects; a generic recursive freezer would widen scope and complexity without protecting additional admitted data. + +## Focused verification + +The exact production source at `580677af...` and its current `playbackSourceSelection.ts` dependency were compiled under TypeScript 5.8.3 with `--strict`. A focused runtime harness exercised initial/refresh/completion/selection freezing, attempted authority/session mutation with `Reflect.set`, and retained the hostile Proxy fail-closed path. The harness completed successfully. + +This focused verification is not repository GREEN. PR-triggered GitHub Actions and the protected 14-context gate remain independently required on the unchanged exact head. + +## Effect and remaining risk + +The renderer can no longer rewrite an already-admitted source option, discovery request, selection, or session snapshot in place. State changes require a new value through the existing session transition functions, preserving canonical validation and stale-request checks. + +The buyer-visible source-to-audible vertical is still incomplete. `RehearsalPlayer` must mount the native availability refresh, actual `Full mix | Vocals | Bass | Drums | Other instruments` selector and the stale-safe `beginPlaybackSourceSwitch → audio.src/load → loadedmetadata admission → seek/rate restore → conditional resume` transaction. Project rotation/revocation, persistence/reload, pointer/touch/keyboard/screen-reader behavior, eight-locale expansion and rights-cleared audible Windows/macOS evidence remain open. + +## Delivery gate + +**FAIL.** This immutable session-snapshot slice has focused compile/runtime evidence, but PR #1160 does not yet have complete exact-head protected CI/security/coverage/build/review evidence and the mounted buyer journey remains unfinished. From 616cae06745b24ea2d947cba724135b1568ea0dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:03:47 +0900 Subject: [PATCH 084/160] test(player): reject forged playback discovery receipt --- .../workspace/playbackSourceSession.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts index 52b0e7bdb..852a1c4d0 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts @@ -61,6 +61,32 @@ describe("playback source discovery session", () => { expect(staleCompletion.pendingRequest).toEqual(rotated.request); }); + it("rejects a forged discovery receipt even when its scalar identity matches", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + expect(begin.request).not.toBeNull(); + const forgedRequest = begin.request + ? { + fullMixAuthority: begin.request.fullMixAuthority, + sequence: begin.request.sequence, + } + : null; + + const completed = completePlaybackSourceDiscovery( + begin.state, + forgedRequest, + projectAOptions, + ); + + expect(completed).toBe(begin.state); + expect(completed.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(completed.pendingRequest).toBe(begin.request); + }); + it("fails closed when completion is partial, malformed, or project-mismatched", () => { const begin = beginPlaybackSourceDiscovery( createPlaybackSourceSession(projectA), From c51437976fb2daa134000393d3ef70d8c07d8a92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:04:19 +0900 Subject: [PATCH 085/160] fix(player): bind playback discovery completion to issued receipt --- apps/desktop/src/features/workspace/playbackSourceSession.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.ts b/apps/desktop/src/features/workspace/playbackSourceSession.ts index a70aef0a5..b1f94bb9e 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSession.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSession.ts @@ -195,7 +195,7 @@ export function beginPlaybackSourceDiscovery( }; } -/** Apply only the latest matching discovery receipt; malformed results stay full-mix only. */ +/** Apply only the exact issued discovery receipt; malformed results stay full-mix only. */ export function completePlaybackSourceDiscovery( state: PlaybackSourceSession, request: PlaybackSourceDiscoveryRequest | null, @@ -204,6 +204,7 @@ export function completePlaybackSourceDiscovery( if ( request === null || state.pendingRequest === null || + state.pendingRequest !== request || state.pendingRequest.sequence !== request.sequence || state.pendingRequest.fullMixAuthority !== request.fullMixAuthority || state.fullMixAuthority !== request.fullMixAuthority From 0dbc5946681b488e08484de9fb00a49d67ec315f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:04:42 +0900 Subject: [PATCH 086/160] docs(traceability): bind playback discovery to exact receipt --- ...yback-source-discovery-receipt-identity.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/traceability/playback-source-discovery-receipt-identity.md diff --git a/docs/traceability/playback-source-discovery-receipt-identity.md b/docs/traceability/playback-source-discovery-receipt-identity.md new file mode 100644 index 000000000..7e0b190dd --- /dev/null +++ b/docs/traceability/playback-source-discovery-receipt-identity.md @@ -0,0 +1,31 @@ +# Playback source discovery receipt identity + +## Problem + +`PlaybackSourceSession` issues one renderer-local `PlaybackSourceDiscoveryRequest` when a native playback-source refresh begins. Before this repair, `completePlaybackSourceDiscovery` accepted any object whose `fullMixAuthority` and `sequence` scalar values matched the pending request. Code running in the renderer could therefore reconstruct a look-alike receipt and submit a discovery payload that had not been paired with the exact request object issued by the current session. + +The scalar comparison still rejected ordinary stale project and sequence results, but it did not preserve the stronger invariant established by the immutable receipt work: only the exact frozen receipt created by the current transition may complete that transition. + +## Constraints + +- Native `PlaybackAuthority` remains the only owner of filesystem identity and playable bytes. +- The renderer must not mint playback authority or infer a native path from a receipt. +- Discovery request identities remain renderer-local and are not serialized through IPC; native discovery receives only the already-owned full-mix authority. +- Sequence values remain monotonic and non-reused, and project rotation continues to invalidate older discovery work. +- Malformed or hostile native responses still fail closed to full-mix-only state. + +## Test-first evidence + +RED commit `616cae06745b24ea2d947cba724135b1568ea0dc` adds a regression that begins a valid discovery, reconstructs a distinct request object with the same authority and sequence, and attempts to complete the refresh with an otherwise canonical five-source payload. The expected result is the unchanged pending session. The predecessor production implementation compared scalar fields only and therefore admitted the forged receipt. + +GREEN commit `c51437976fb2daa134000393d3ef70d8c07d8a92` adds object-identity admission (`state.pendingRequest === request`) before the existing scalar/project checks. `beginPlaybackSourceDiscovery` stores and returns the same frozen request object, so legitimate async completion keeps working without a compatibility alias or second receipt format. + +## Alternatives considered + +Using only the monotonic sequence was rejected because a renderer-local caller can copy the current sequence. Adding a random nonce was also rejected: the request never crosses a process boundary, so the already-issued frozen object is the narrower authority token and avoids a second identity mechanism. Moving discovery receipts into native state was rejected because it would duplicate renderer lifecycle ownership and expand the IPC contract without solving a native authority problem. + +## Effect and remaining risk + +A look-alike object can no longer complete the current renderer discovery transition even when every scalar field matches. Older requests, cross-project requests, malformed options, throwing getters/proxies, sequence exhaustion and in-place receipt mutation remain covered by the surrounding session contracts. + +This repair does not make stems buyer-visible. `RehearsalPlayer` still needs to mount the session, refresh native availability, render the actual source selector and execute source changes through `PlaybackSourceSwitchSession` before mutating `audio.src`. Exact target/sequence/duration admission, transport restoration, project rotation/revocation, persistence/reload, keyboard/pointer/touch/screen-reader behavior, eight-locale evidence and rights-cleared Windows/macOS audible acceptance remain open. From c8c09f3e3bdb2bb66086c85c01bc2989cbb61125 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:07:57 +0900 Subject: [PATCH 087/160] docs(traceability): record focused receipt verification --- .../playback-source-discovery-receipt-identity.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/traceability/playback-source-discovery-receipt-identity.md b/docs/traceability/playback-source-discovery-receipt-identity.md index 7e0b190dd..efe8fc00c 100644 --- a/docs/traceability/playback-source-discovery-receipt-identity.md +++ b/docs/traceability/playback-source-discovery-receipt-identity.md @@ -20,6 +20,12 @@ RED commit `616cae06745b24ea2d947cba724135b1568ea0dc` adds a regression that beg GREEN commit `c51437976fb2daa134000393d3ef70d8c07d8a92` adds object-identity admission (`state.pendingRequest === request`) before the existing scalar/project checks. `beginPlaybackSourceDiscovery` stores and returns the same frozen request object, so legitimate async completion keeps working without a compatibility alias or second receipt format. +## Focused verification + +The exact repaired `playbackSourceSession.ts` and its current `playbackSourceSelection.ts` dependency compile under TypeScript 5.8.3 with `--strict`, targeting ES2022. A focused Node 22.16.0 runtime harness verifies both sides of the receipt boundary: a copied look-alike request leaves the original pending full-mix-only session unchanged, while the exact issued frozen request admits the canonical five-source response and clears the pending request. The admitted session, option array and every emitted option remain frozen. + +This is focused verification of the causal slice only. It is not a substitute for repository Vitest/coverage, cross-platform build, security, review or release evidence on the unchanged PR head. + ## Alternatives considered Using only the monotonic sequence was rejected because a renderer-local caller can copy the current sequence. Adding a random nonce was also rejected: the request never crosses a process boundary, so the already-issued frozen object is the narrower authority token and avoids a second identity mechanism. Moving discovery receipts into native state was rejected because it would duplicate renderer lifecycle ownership and expand the IPC contract without solving a native authority problem. From a2222a42a175a1196c32544c6e7bd521992a42a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:15:57 +0900 Subject: [PATCH 088/160] test(player): retire only exact admitted switch receipt --- .../playbackSourceSwitchCompletion.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts new file mode 100644 index 000000000..435bbc10a --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { + RehearsalLoopWindow, + RehearsalTransportState, +} from "./rehearsalTransport"; +import { + admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, + completePlaybackSourceSwitch, + createPlaybackSourceSwitchSession, +} from "./playbackSourceSwitch"; + +const loop: RehearsalLoopWindow = { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, +}; + +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const bassAuthority = `${fullMixAuthority}/stem/bass`; + +const transport: RehearsalTransportState = { + phase: "looping", + loop, + countInRemainingBeats: 0, + playheadSeconds: 37.25, + playbackRate: 0.75, +}; + +describe("playback source switch completion", () => { + it("retires only the exact active receipt after target metadata is admitted", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const admitted = admitPlaybackSourceSwitchTarget( + begun.plan, + 45, + vocalsAuthority, + begun.state.sequence, + ); + expect(admitted).toBe(begun.plan); + + const completed = completePlaybackSourceSwitch(begun.state, admitted); + + expect(completed).toEqual({ sequence: 1, activePlan: null }); + expect(Object.isFrozen(completed)).toBe(true); + expect(completePlaybackSourceSwitch(begun.state, { ...begun.plan! })).toBe( + begun.state, + ); + }); + + it("does not let an admitted stale receipt clear a newer active switch", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const staleAdmitted = admitPlaybackSourceSwitchTarget( + first.plan, + 45, + vocalsAuthority, + first.state.sequence, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport, + 37.25, + vocalsAuthority, + bassAuthority, + ); + + expect(completePlaybackSourceSwitch(second.state, staleAdmitted)).toBe( + second.state, + ); + expect(second.state.activePlan).toBe(second.plan); + }); +}); From 73b0e47d112e5baa52e19ffb98640eda271df9af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:16:18 +0900 Subject: [PATCH 089/160] fix(player): retire exact admitted source-switch receipt --- .../workspace/playbackSourceSwitch.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index ee50da994..f8b64a610 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -175,3 +175,24 @@ export function admitPlaybackSourceSwitchTarget( } return plan; } + +/** + * Retire one admitted media-switch receipt without letting a stale or copied plan + * clear a newer target. The caller may invoke this only after target admission; + * premature retirement fails safe by removing restoration authority, never by + * granting playback authority. + */ +export function completePlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + admittedPlan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + if ( + admittedPlan === null || + state.activePlan === null || + state.activePlan !== admittedPlan || + state.sequence !== admittedPlan.sequence + ) { + return state; + } + return freezePlaybackSourceSwitchSession(state.sequence, null); +} From 1b8ace15f17e549b5acfee57e6c171d62c6491db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:16:55 +0900 Subject: [PATCH 090/160] docs(traceability): retire consumed playback switch receipts --- ...ayback-source-switch-receipt-retirement.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/traceability/playback-source-switch-receipt-retirement.md diff --git a/docs/traceability/playback-source-switch-receipt-retirement.md b/docs/traceability/playback-source-switch-receipt-retirement.md new file mode 100644 index 000000000..65bfbe17d --- /dev/null +++ b/docs/traceability/playback-source-switch-receipt-retirement.md @@ -0,0 +1,41 @@ +# Playback source-switch receipt retirement + +- **Status:** Draft implementation evidence; not shipped or release acceptance +- **Date:** 2026-09-05 +- **Bounded context:** Active Player / renderer media-source replacement +- **Protected product source:** `develop@314ddeae7b775a4957594b599358c8255617eb2e` +- **Canonical Active Player owner:** PR #971 `09bedd835475015379716292e63e6be376fceec9` +- **Stem publication parent:** PR #1159 `c27f3781ddcbcc013dce07a26c0baf6080e4b2ac` + +## Problem + +`PlaybackSourceSwitchSession` already invalidates older `loadedmetadata` receipts when a newer source switch begins, and `admitPlaybackSourceSwitchTarget` admits only the exact current target, renderer sequence and decoded duration. The lifecycle still lacked one explicit terminal transition: after a target receipt is admitted and transport continuity is restored, the admitted `activePlan` remained stored in the session. + +Leaving a completed receipt active is not itself permission to read native bytes, but it unnecessarily extends restoration authority across later media-element events. A repeated `loadedmetadata` event for the same target/sequence could remain admissible, and a future mounted player would have to mutate or replace renderer session state ad hoc to retire the receipt. That is incompatible with the single-writer, immutable-receipt boundary already established for source switching. + +## Test-first evidence + +RED commit `a2222a42a175a1196c32544c6e7bd521992a42a4` adds `playbackSourceSwitchCompletion.test.ts` before a production completion transition exists. It requires: + +- an admitted exact active receipt to retire to the same monotonic sequence with `activePlan: null`; +- the resulting session to remain frozen; +- a copied look-alike plan not to retire the issued active receipt; and +- a stale previously admitted receipt not to clear a newer active switch. + +The predecessor production module had no `completePlaybackSourceSwitch` export, so this is a source-level RED contract rather than a claim of hosted execution. + +## Causal fix + +GREEN source commit `73b0e47d112e5baa52e19ffb98640eda271df9af` adds `completePlaybackSourceSwitch`. It retires a receipt only when the caller supplies the exact object currently stored in `state.activePlan` and the sequence still matches. Success returns the existing frozen-session representation with the same sequence and `activePlan: null`; copied, stale, null or sequence-mismatched plans return the current session unchanged. + +The helper does not create playback authority, mutate native availability, reset the sequence, or infer filesystem identity. It is intentionally narrower than a general state setter. The caller must first pass target metadata through `admitPlaybackSourceSwitchTarget`; premature retirement can only remove restoration authority and therefore fails safe rather than granting playback authority. + +## Alternatives considered + +Keeping the active plan until the next switch was rejected because completion should terminate authority as soon as it is consumed, not at some unrelated future selection. Resetting the renderer sequence to zero was rejected because sequence reuse would weaken stale-receipt rejection. Allowing structural equality was rejected for the same reason as discovery-receipt impersonation: a copied JavaScript object must not acquire the authority of the exact immutable receipt issued by the current session. + +## Remaining buyer gap + +This closes a lifecycle prerequisite, not the source selector. `RehearsalPlayer` still needs one mounted transaction owner that refreshes native availability, displays only the current `Full mix | Vocals | Bass | Drums | Other instruments` options, calls `beginPlaybackSourceSwitch` before changing `audio.src`, admits the exact target/sequence/duration on `loadedmetadata`, restores seek/playback rate/resume intent, then immediately retires that admitted plan with `completePlaybackSourceSwitch`. + +Project rotation, native revocation, malformed or short target media, persistence/reload, pointer/touch/keyboard/screen-reader behavior, JA/ZH/VI/ES/DE/FR plus CJK/text-expansion/font-fallback evidence, and rights-cleared Windows/macOS audible acceptance remain open. Source-level RED→fix evidence is not a substitute for the protected exact-head CI, security, coverage, build, review or release gates. From 77a708e28b64a73abd5e49133cbdc59449c1f937 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:05:25 +0900 Subject: [PATCH 091/160] test(player): require failed switch receipt retirement --- .../playbackSourceSwitchCompletion.test.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts index 435bbc10a..ae063ac63 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts @@ -4,6 +4,7 @@ import type { RehearsalTransportState, } from "./rehearsalTransport"; import { + abortPlaybackSourceSwitch, admitPlaybackSourceSwitchTarget, beginPlaybackSourceSwitch, completePlaybackSourceSwitch, @@ -87,4 +88,50 @@ describe("playback source switch completion", () => { ); expect(second.state.activePlan).toBe(second.plan); }); -}); + + it("retires only the exact active receipt when target metadata fails admission", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + expect( + admitPlaybackSourceSwitchTarget( + begun.plan, + 40, + vocalsAuthority, + begun.state.sequence, + ), + ).toBeNull(); + + const aborted = abortPlaybackSourceSwitch(begun.state, begun.plan); + + expect(aborted).toEqual({ sequence: 1, activePlan: null }); + expect(Object.isFrozen(aborted)).toBe(true); + expect(abortPlaybackSourceSwitch(begun.state, { ...begun.plan! })).toBe( + begun.state, + ); + }); + + it("does not let a stale failed receipt clear a newer active switch", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport, + 37.25, + vocalsAuthority, + bassAuthority, + ); + + expect(abortPlaybackSourceSwitch(second.state, first.plan)).toBe(second.state); + expect(second.state.activePlan).toBe(second.plan); + }); +}); \ No newline at end of file From ba06f0c0427a33f93a1e01b01e8043c983dc3679 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:06:03 +0900 Subject: [PATCH 092/160] fix(player): retire failed switch receipts --- .../workspace/playbackSourceSwitch.ts | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index f8b64a610..76f005fa9 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -48,6 +48,21 @@ function freezePlaybackSourceSwitchSession( return Object.freeze({ sequence, activePlan }); } +function retireExactPlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + plan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + if ( + plan === null || + state.activePlan === null || + state.activePlan !== plan || + state.sequence !== plan.sequence + ) { + return state; + } + return freezePlaybackSourceSwitchSession(state.sequence, null); +} + /** Start a renderer switch session with no reusable media receipt. */ export function createPlaybackSourceSwitchSession(): PlaybackSourceSwitchSession { return freezePlaybackSourceSwitchSession(0, null); @@ -186,13 +201,19 @@ export function completePlaybackSourceSwitch( state: PlaybackSourceSwitchSession, admittedPlan: PlaybackSourceSwitchPlan | null, ): PlaybackSourceSwitchSession { - if ( - admittedPlan === null || - state.activePlan === null || - state.activePlan !== admittedPlan || - state.sequence !== admittedPlan.sequence - ) { - return state; - } - return freezePlaybackSourceSwitchSession(state.sequence, null); + return retireExactPlaybackSourceSwitch(state, admittedPlan); +} + +/** + * Retire one failed media-switch receipt after target loading or admission fails. + * + * A failed target must not leave restoration authority alive for a later metadata + * event from the same mutable media element. Exact issued-object identity prevents + * a copied or stale failure receipt from cancelling a newer switch. + */ +export function abortPlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + failedPlan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + return retireExactPlaybackSourceSwitch(state, failedPlan); } From 594f6472ad0ea42f9f8d4431bc4ee997e44ec2c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:07:52 +0900 Subject: [PATCH 093/160] docs(traceability): record failed switch retirement --- ...ayback-source-switch-receipt-retirement.md | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/traceability/playback-source-switch-receipt-retirement.md b/docs/traceability/playback-source-switch-receipt-retirement.md index 65bfbe17d..3b185061f 100644 --- a/docs/traceability/playback-source-switch-receipt-retirement.md +++ b/docs/traceability/playback-source-switch-receipt-retirement.md @@ -9,11 +9,13 @@ ## Problem -`PlaybackSourceSwitchSession` already invalidates older `loadedmetadata` receipts when a newer source switch begins, and `admitPlaybackSourceSwitchTarget` admits only the exact current target, renderer sequence and decoded duration. The lifecycle still lacked one explicit terminal transition: after a target receipt is admitted and transport continuity is restored, the admitted `activePlan` remained stored in the session. +`PlaybackSourceSwitchSession` invalidates older `loadedmetadata` receipts when a newer source switch begins, and `admitPlaybackSourceSwitchTarget` admits only the exact current target, renderer sequence and decoded duration. Two terminal paths need explicit ownership: successful continuity restoration and failed target load/admission. -Leaving a completed receipt active is not itself permission to read native bytes, but it unnecessarily extends restoration authority across later media-element events. A repeated `loadedmetadata` event for the same target/sequence could remain admissible, and a future mounted player would have to mutate or replace renderer session state ad hoc to retire the receipt. That is incompatible with the single-writer, immutable-receipt boundary already established for source switching. +Without success retirement, an admitted `activePlan` remains stored after its authority has been consumed. Without failure retirement, a short/malformed target or media-load failure can leave the issued plan alive long enough for a later metadata event from the same mutable media element to satisfy admission and restore transport after the switch was already considered failed. -## Test-first evidence +Neither case creates permission to read native bytes, but both unnecessarily extend renderer restoration authority. A mounted player must be able to terminate the exact issued receipt on either outcome without resetting sequence identity or allowing a copied/stale plan to affect a newer switch. + +## Successful completion: test-first evidence RED commit `a2222a42a175a1196c32544c6e7bd521992a42a4` adds `playbackSourceSwitchCompletion.test.ts` before a production completion transition exists. It requires: @@ -24,18 +26,27 @@ RED commit `a2222a42a175a1196c32544c6e7bd521992a42a4` adds `playbackSourceSwitch The predecessor production module had no `completePlaybackSourceSwitch` export, so this is a source-level RED contract rather than a claim of hosted execution. -## Causal fix - GREEN source commit `73b0e47d112e5baa52e19ffb98640eda271df9af` adds `completePlaybackSourceSwitch`. It retires a receipt only when the caller supplies the exact object currently stored in `state.activePlan` and the sequence still matches. Success returns the existing frozen-session representation with the same sequence and `activePlan: null`; copied, stale, null or sequence-mismatched plans return the current session unchanged. -The helper does not create playback authority, mutate native availability, reset the sequence, or infer filesystem identity. It is intentionally narrower than a general state setter. The caller must first pass target metadata through `admitPlaybackSourceSwitchTarget`; premature retirement can only remove restoration authority and therefore fails safe rather than granting playback authority. +## Failed target: test-first evidence + +RED commit `77a708e28b64a73abd5e49133cbdc59449c1f937` extends the same completion regression before a failure transition exists. A target whose decoded duration is shorter than the selected loop must fail admission, after which the exact issued plan must be retireable. A copied look-alike plan must not retire it, and an older failed plan must not clear a newer active switch. + +Causal fix `ba06f0c0427a33f93a1e01b01e8043c983dc3679` adds `abortPlaybackSourceSwitch`. Successful completion and failure abort share one private exact-plan retirement primitive so the identity rule cannot drift between terminal paths. The public functions remain separate because their caller preconditions differ: `completePlaybackSourceSwitch` follows successful target admission and continuity restoration; `abortPlaybackSourceSwitch` follows failed loading or failed admission. -## Alternatives considered +A focused TypeScript 5.8.3 `--strict` / Node 22.16.0 harness exercised the current production function against exact-plan failure retirement, copied-plan rejection, stale-plan rejection, and the existing successful completion path. It passed. This is focused causal GREEN only; it is not repository exact-head CI evidence. -Keeping the active plan until the next switch was rejected because completion should terminate authority as soon as it is consumed, not at some unrelated future selection. Resetting the renderer sequence to zero was rejected because sequence reuse would weaken stale-receipt rejection. Allowing structural equality was rejected for the same reason as discovery-receipt impersonation: a copied JavaScript object must not acquire the authority of the exact immutable receipt issued by the current session. +## Authority and alternatives + +The terminal helpers do not create playback authority, mutate native availability, reset renderer sequence identity, or infer filesystem identity. Exact issued-object identity remains the narrowest renderer-local cancellation/retirement token because these plans are not serialized across IPC. + +Keeping failed or completed plans until the next selection was rejected because termination should occur at the outcome that consumes or rejects the receipt, not at an unrelated future action. Resetting the sequence was rejected because reuse weakens stale-event rejection. Structural equality was rejected because a reconstructed JavaScript object must not acquire the authority of the immutable plan stored by the current session. A second native receipt owner was rejected because `PlaybackAuthority` remains the sole owner of playable bytes. ## Remaining buyer gap -This closes a lifecycle prerequisite, not the source selector. `RehearsalPlayer` still needs one mounted transaction owner that refreshes native availability, displays only the current `Full mix | Vocals | Bass | Drums | Other instruments` options, calls `beginPlaybackSourceSwitch` before changing `audio.src`, admits the exact target/sequence/duration on `loadedmetadata`, restores seek/playback rate/resume intent, then immediately retires that admitted plan with `completePlaybackSourceSwitch`. +This closes the terminal receipt prerequisite, not the source selector. `RehearsalPlayer` still needs one mounted transaction owner that refreshes native availability, displays only the current `Full mix | Vocals | Bass | Drums | Other instruments` options, calls `beginPlaybackSourceSwitch` before changing `audio.src`, and then follows exactly one terminal path: + +- successful load: exact target/sequence/duration admission → restore seek/playback rate/resume intent → `completePlaybackSourceSwitch`; +- failed load/admission: keep transport non-playing → `abortPlaybackSourceSwitch` before any later media event can reuse the failed receipt. -Project rotation, native revocation, malformed or short target media, persistence/reload, pointer/touch/keyboard/screen-reader behavior, JA/ZH/VI/ES/DE/FR plus CJK/text-expansion/font-fallback evidence, and rights-cleared Windows/macOS audible acceptance remain open. Source-level RED→fix evidence is not a substitute for the protected exact-head CI, security, coverage, build, review or release gates. +Project rotation, native revocation, persistence/reload, pointer/touch/keyboard/screen-reader behavior, JA/ZH/VI/ES/DE/FR plus CJK/text-expansion/font-fallback evidence, and rights-cleared Windows/macOS audible acceptance remain open. Source-level RED→fix and focused verification are not substitutes for protected exact-head CI, security, coverage, build, review or release gates. From 772810f36c7f37ebc0fb2c3614d4744d88c4cec7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:34:13 +0900 Subject: [PATCH 094/160] test(player): reject copied switch admission receipt --- .../workspace/playbackSourceSwitch.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts index 5b91c0c0c..92e549305 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -153,6 +153,27 @@ describe("playback source switch continuity", () => { ).toBeNull(); }); + it("rejects a copied switch plan instead of treating equal scalar fields as an issued active receipt", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + expect(begun.plan).not.toBeNull(); + const copiedPlan = Object.freeze({ ...begun.plan! }); + + expect( + admitPlaybackSourceSwitchTarget( + copiedPlan, + 45, + vocalsAuthority, + begun.state.sequence, + ), + ).toBeNull(); + }); + it("rejects stale loadedmetadata receipts after a newer source switch supersedes the target", () => { const stalePlan = capture("looping", 37.25, vocalsAuthority, 3); const currentPlan = capture("looping", 37.25, bassAuthority, 4); @@ -255,4 +276,4 @@ describe("playback source switch continuity", () => { activePlan: null, }); }); -}); +}); \ No newline at end of file From 06548e160b46092dd7b57dd637f046792dbff9ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:34:50 +0900 Subject: [PATCH 095/160] fix(player): bind metadata admission to active switch receipt --- .../workspace/playbackSourceSwitch.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts index 76f005fa9..c79f6c9fe 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -161,28 +161,30 @@ export function beginPlaybackSourceSwitch( } /** - * Admit the decoded target only when it still belongs to the active switch receipt - * and can cover the selected loop and captured position. + * Admit decoded target metadata only for the exact active switch receipt. * * `loadedmetadata` belongs to a mutable media element rather than to the source that - * initiated the event. Matching both the target authority and monotonic renderer - * sequence prevents a late receipt from an older load from restoring stale transport - * state after a newer source selection has already superseded it. + * initiated the event. The caller therefore supplies the current renderer switch + * session, and admission requires exact issued-plan identity as well as target, + * sequence, and duration coverage. A frozen look-alike object with identical scalar + * fields is not authority and cannot restore transport state. */ export function admitPlaybackSourceSwitchTarget( + state: PlaybackSourceSwitchSession, plan: PlaybackSourceSwitchPlan | null, targetDurationSeconds: number, currentTargetAuthority: string, - currentSequence: number, ): PlaybackSourceSwitchPlan | null { if ( !plan || + state.activePlan === null || + state.activePlan !== plan || + state.sequence !== plan.sequence || !Number.isFinite(targetDurationSeconds) || targetDurationSeconds <= 0 || plan.targetAuthority !== currentTargetAuthority || - plan.sequence !== currentSequence || - !Number.isSafeInteger(currentSequence) || - currentSequence <= 0 || + !Number.isSafeInteger(state.sequence) || + state.sequence <= 0 || plan.seekSeconds >= targetDurationSeconds || plan.loopEndSeconds > targetDurationSeconds ) { @@ -216,4 +218,4 @@ export function abortPlaybackSourceSwitch( failedPlan: PlaybackSourceSwitchPlan | null, ): PlaybackSourceSwitchSession { return retireExactPlaybackSourceSwitch(state, failedPlan); -} +} \ No newline at end of file From d9ebc135dbfc64306afa7e909af0e370b466db3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:35:30 +0900 Subject: [PATCH 096/160] test(player): bind switch admission to active session --- .../workspace/playbackSourceSwitch.test.ts | 106 ++++++++++++++---- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts index 92e549305..1aa055a02 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -136,19 +136,48 @@ describe("playback source switch continuity", () => { ); it("admits a target only when its decoded duration and switch receipt still match the active target", () => { - const plan = capture("looping", 37.25); - expect(plan).not.toBeNull(); + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + expect(begun.plan).not.toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, 45, vocalsAuthority, 3)).toEqual(plan); - expect(admitPlaybackSourceSwitchTarget(plan, 44.999, vocalsAuthority, 3)).toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, 37.25, vocalsAuthority, 3)).toBeNull(); - expect(admitPlaybackSourceSwitchTarget(plan, Number.NaN, vocalsAuthority, 3)).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget(begun.state, begun.plan, 45, vocalsAuthority), + ).toBe(begun.plan); expect( admitPlaybackSourceSwitchTarget( - plan, + begun.state, + begun.plan, + 44.999, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 37.25, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + Number.NaN, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, Number.POSITIVE_INFINITY, vocalsAuthority, - 3, ), ).toBeNull(); }); @@ -166,29 +195,62 @@ describe("playback source switch continuity", () => { expect( admitPlaybackSourceSwitchTarget( + begun.state, copiedPlan, 45, vocalsAuthority, - begun.state.sequence, ), ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 45, + vocalsAuthority, + ), + ).toBe(begun.plan); }); it("rejects stale loadedmetadata receipts after a newer source switch supersedes the target", () => { - const stalePlan = capture("looping", 37.25, vocalsAuthority, 3); - const currentPlan = capture("looping", 37.25, bassAuthority, 4); - expect(stalePlan).not.toBeNull(); - expect(currentPlan).not.toBeNull(); + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport("looping"), + 37.25, + fullMixAuthority, + bassAuthority, + ); expect( - admitPlaybackSourceSwitchTarget(stalePlan, 45, bassAuthority, 4), + admitPlaybackSourceSwitchTarget( + second.state, + first.plan, + 45, + vocalsAuthority, + ), ).toBeNull(); expect( - admitPlaybackSourceSwitchTarget(stalePlan, 45, vocalsAuthority, 4), + admitPlaybackSourceSwitchTarget( + second.state, + first.plan, + 45, + bassAuthority, + ), ).toBeNull(); expect( - admitPlaybackSourceSwitchTarget(currentPlan, 45, bassAuthority, 4), - ).toEqual(currentPlan); + admitPlaybackSourceSwitchTarget( + second.state, + second.plan, + 45, + bassAuthority, + ), + ).toBe(second.plan); }); it("invalidates the prior media receipt as soon as a newer source switch begins", () => { @@ -209,23 +271,23 @@ describe("playback source switch continuity", () => { expect(first.plan?.sequence).toBe(1); expect(second.plan?.sequence).toBe(2); - expect(second.state.activePlan).toEqual(second.plan); + expect(second.state.activePlan).toBe(second.plan); expect( admitPlaybackSourceSwitchTarget( + second.state, first.plan, 45, vocalsAuthority, - second.state.sequence, ), ).toBeNull(); expect( admitPlaybackSourceSwitchTarget( + second.state, second.plan, 45, bassAuthority, - second.state.sequence, ), - ).toEqual(second.plan); + ).toBe(second.plan); }); it("burns a switch identity even when the newer attempt cannot produce a continuity plan", () => { @@ -249,10 +311,10 @@ describe("playback source switch continuity", () => { expect(rejected.state.activePlan).toBeNull(); expect( admitPlaybackSourceSwitchTarget( + rejected.state, first.plan, 45, vocalsAuthority, - rejected.state.sequence, ), ).toBeNull(); }); From 25926525f3783cf96a1e7461fcffbc68a6289efd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:35:44 +0900 Subject: [PATCH 097/160] test(player): update switch completion admission contract --- .../workspace/playbackSourceSwitchCompletion.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts index ae063ac63..1de42005f 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts @@ -45,10 +45,10 @@ describe("playback source switch completion", () => { vocalsAuthority, ); const admitted = admitPlaybackSourceSwitchTarget( + begun.state, begun.plan, 45, vocalsAuthority, - begun.state.sequence, ); expect(admitted).toBe(begun.plan); @@ -70,10 +70,10 @@ describe("playback source switch completion", () => { vocalsAuthority, ); const staleAdmitted = admitPlaybackSourceSwitchTarget( + first.state, first.plan, 45, vocalsAuthority, - first.state.sequence, ); const second = beginPlaybackSourceSwitch( first.state, @@ -99,10 +99,10 @@ describe("playback source switch completion", () => { ); expect( admitPlaybackSourceSwitchTarget( + begun.state, begun.plan, 40, vocalsAuthority, - begun.state.sequence, ), ).toBeNull(); From a998f69e5e86ffc406235a8f2e541c8a9443fb25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:36:06 +0900 Subject: [PATCH 098/160] test(player): update switch immutability admission contract --- .../workspace/playbackSourceSwitch.immutability.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts index b0f821e19..9b380cd7c 100644 --- a/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts @@ -52,11 +52,11 @@ describe("playback source switch receipt immutability", () => { expect(started.state.sequence).toBe(1); expect( admitPlaybackSourceSwitchTarget( + started.state, started.plan, 45, vocalsAuthority, - started.state.sequence, ), - ).toEqual(started.plan); + ).toBe(started.plan); }); -}); +}); \ No newline at end of file From 7316b6ba79913827fa3f55cae7f8693a2fd05f2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:36:51 +0900 Subject: [PATCH 099/160] docs(traceability): bind switch admission to issued active plan --- ...ayback-source-switch-receipt-retirement.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/traceability/playback-source-switch-receipt-retirement.md b/docs/traceability/playback-source-switch-receipt-retirement.md index 3b185061f..67805e7d4 100644 --- a/docs/traceability/playback-source-switch-receipt-retirement.md +++ b/docs/traceability/playback-source-switch-receipt-retirement.md @@ -9,11 +9,11 @@ ## Problem -`PlaybackSourceSwitchSession` invalidates older `loadedmetadata` receipts when a newer source switch begins, and `admitPlaybackSourceSwitchTarget` admits only the exact current target, renderer sequence and decoded duration. Two terminal paths need explicit ownership: successful continuity restoration and failed target load/admission. +`PlaybackSourceSwitchSession` invalidates older `loadedmetadata` receipts when a newer source switch begins. The renderer must also bind metadata admission and both terminal outcomes to the exact issued active plan rather than accepting a structurally equal JavaScript object. -Without success retirement, an admitted `activePlan` remains stored after its authority has been consumed. Without failure retirement, a short/malformed target or media-load failure can leave the issued plan alive long enough for a later metadata event from the same mutable media element to satisfy admission and restore transport after the switch was already considered failed. +Without success retirement, an admitted `activePlan` remains stored after its authority has been consumed. Without failure retirement, a short/malformed target or media-load failure can leave the issued plan alive long enough for a later metadata event from the same mutable media element to satisfy admission and restore transport after the switch was already considered failed. Before the admission-identity repair below, `admitPlaybackSourceSwitchTarget` still accepted a copied frozen plan when its target, sequence and duration scalars matched, even though completion/abort correctly rejected that copied object. -Neither case creates permission to read native bytes, but both unnecessarily extend renderer restoration authority. A mounted player must be able to terminate the exact issued receipt on either outcome without resetting sequence identity or allowing a copied/stale plan to affect a newer switch. +Neither case creates permission to read native bytes, but both unnecessarily extend or counterfeit renderer restoration authority. A mounted player must be able to admit and terminate only the exact issued receipt on either outcome without resetting sequence identity or allowing a copied/stale plan to affect a newer switch. ## Successful completion: test-first evidence @@ -34,19 +34,27 @@ RED commit `77a708e28b64a73abd5e49133cbdc59449c1f937` extends the same completio Causal fix `ba06f0c0427a33f93a1e01b01e8043c983dc3679` adds `abortPlaybackSourceSwitch`. Successful completion and failure abort share one private exact-plan retirement primitive so the identity rule cannot drift between terminal paths. The public functions remain separate because their caller preconditions differ: `completePlaybackSourceSwitch` follows successful target admission and continuity restoration; `abortPlaybackSourceSwitch` follows failed loading or failed admission. -A focused TypeScript 5.8.3 `--strict` / Node 22.16.0 harness exercised the current production function against exact-plan failure retirement, copied-plan rejection, stale-plan rejection, and the existing successful completion path. It passed. This is focused causal GREEN only; it is not repository exact-head CI evidence. +A focused TypeScript 5.8.3 `--strict` / Node 22.16.0 harness exercised the then-current production function against exact-plan failure retirement, copied-plan rejection, stale-plan rejection, and the existing successful completion path. It passed. This is historical focused causal GREEN only; it is not evidence for the newer exact head. + +## Admission identity repair + +RED commit `772810f36c7f37ebc0fb2c3614d4744d88c4cec7` adds a regression proving the asymmetry that remained after retirement was hardened: `Object.freeze({ ...issuedPlan })` had the same scalar target/sequence/loop values and was accepted by `admitPlaybackSourceSwitchTarget`, even though that object was never the `activePlan` stored by the current switch session. + +Causal fix `06548e160b46092dd7b57dd637f046792dbff9ed` changes target admission to take the current `PlaybackSourceSwitchSession` and requires both `state.activePlan === plan` and `state.sequence === plan.sequence` before any duration/target restoration check. The redundant caller-supplied sequence argument is removed; the session is the renderer authority for current switch identity. Follow-up test-contract commits `d9ebc135dbfc64306afa7e909af0e370b466db3e`, `25926525f3783cf96a1e7461fcffbc68a6289efd`, and `a998f69e5e86ffc406235a8f2e541c8a9443fb25` update continuity, terminal lifecycle, and immutability call sites to the same exact-session admission contract. + +The chosen repair is intentionally narrower than adding a new nonce, global registry, or native receipt. The plan and session never cross IPC, and the native `PlaybackAuthority` already owns playable-byte authority. Exact renderer object identity is sufficient for the restoration receipt while target authority, same-project validation, monotonic sequence and decoded-duration coverage remain independent checks. ## Authority and alternatives -The terminal helpers do not create playback authority, mutate native availability, reset renderer sequence identity, or infer filesystem identity. Exact issued-object identity remains the narrowest renderer-local cancellation/retirement token because these plans are not serialized across IPC. +The switch helpers do not create playback authority, mutate native availability, reset renderer sequence identity, or infer filesystem identity. Exact issued-object identity remains the narrowest renderer-local admission/cancellation/retirement token because these plans are not serialized across IPC. -Keeping failed or completed plans until the next selection was rejected because termination should occur at the outcome that consumes or rejects the receipt, not at an unrelated future action. Resetting the sequence was rejected because reuse weakens stale-event rejection. Structural equality was rejected because a reconstructed JavaScript object must not acquire the authority of the immutable plan stored by the current session. A second native receipt owner was rejected because `PlaybackAuthority` remains the sole owner of playable bytes. +Keeping failed or completed plans until the next selection was rejected because termination should occur at the outcome that consumes or rejects the receipt, not at an unrelated future action. Resetting the sequence was rejected because reuse weakens stale-event rejection. Structural equality was rejected because a reconstructed JavaScript object must not acquire the authority of the immutable plan stored by the current session. A second native receipt owner was rejected because `PlaybackAuthority` remains the sole owner of playable bytes. A module-global registry was also rejected because the current session already contains the exact active plan and is easier to reason about, test, and discard on project rotation. ## Remaining buyer gap -This closes the terminal receipt prerequisite, not the source selector. `RehearsalPlayer` still needs one mounted transaction owner that refreshes native availability, displays only the current `Full mix | Vocals | Bass | Drums | Other instruments` options, calls `beginPlaybackSourceSwitch` before changing `audio.src`, and then follows exactly one terminal path: +This closes the admission/terminal receipt prerequisite, not the source selector. `RehearsalPlayer` still needs one mounted transaction owner that refreshes native availability, displays only the current `Full mix | Vocals | Bass | Drums | Other instruments` options, calls `beginPlaybackSourceSwitch` before changing `audio.src`, and then follows exactly one terminal path: -- successful load: exact target/sequence/duration admission → restore seek/playback rate/resume intent → `completePlaybackSourceSwitch`; +- successful load: exact active-session/plan target/duration admission → restore seek/playback rate/resume intent → `completePlaybackSourceSwitch`; - failed load/admission: keep transport non-playing → `abortPlaybackSourceSwitch` before any later media event can reuse the failed receipt. -Project rotation, native revocation, persistence/reload, pointer/touch/keyboard/screen-reader behavior, JA/ZH/VI/ES/DE/FR plus CJK/text-expansion/font-fallback evidence, and rights-cleared Windows/macOS audible acceptance remain open. Source-level RED→fix and focused verification are not substitutes for protected exact-head CI, security, coverage, build, review or release gates. +Project rotation, native revocation, persistence/reload, pointer/touch/keyboard/screen-reader behavior, JA/ZH/VI/ES/DE/FR plus CJK/text-expansion/font-fallback evidence, and rights-cleared Windows/macOS audible acceptance remain open. The new exact head still requires protected CI, security, coverage, native build and independent review evidence before any Ready/merge or release claim. \ No newline at end of file From 6a9f892f08d5ebc7c8e67bb7372401f3d64b5e58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:08:40 +0900 Subject: [PATCH 100/160] test(player): require mounted stem source selection --- .../RehearsalPlayer.sourceSelection.test.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx new file mode 100644 index 000000000..d9a27096b --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: vi.fn( + (source: string) => `bandscope-playback://localhost/${source}`, + ), + invoke: vi.fn(), +})); + +const fullMixAuthority = "bandscope-project://project-100-1"; +const stemAuthorities = [ + `${fullMixAuthority}/stem/vocals`, + `${fullMixAuthority}/stem/bass`, + `${fullMixAuthority}/stem/drums`, + `${fullMixAuthority}/stem/other`, +] as const; + +describe("RehearsalPlayer mounted playback-source selection", () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockClear(); + vi.mocked(invoke).mockReset(); + vi.mocked(invoke).mockResolvedValue([ + stemAuthorities[3], + fullMixAuthority, + stemAuthorities[1], + stemAuthorities[0], + stemAuthorities[2], + ]); + }); + + it("discovers the current atomic stem set and switches the mounted player only through opaque authority", async () => { + render( + , + ); + + expect(await screen.findByRole("group", { name: "Playback source" })).toBeInTheDocument(); + expect(vi.mocked(invoke)).toHaveBeenCalledWith( + "get_playback_source_availability", + { currentFullMixAuthority: fullMixAuthority }, + ); + + const fullMix = screen.getByRole("radio", { name: "Full mix" }); + const vocals = screen.getByRole("radio", { name: "Vocals" }); + expect(fullMix).toBeChecked(); + expect(vocals).not.toBeChecked(); + + fireEvent.click(vocals); + + await waitFor(() => expect(vocals).toBeChecked()); + expect(vi.mocked(convertFileSrc)).toHaveBeenCalledWith( + "project-100-1/stem/vocals", + "bandscope-playback", + ); + expect(vi.mocked(convertFileSrc)).not.toHaveBeenCalledWith( + expect.stringContaining("/private/"), + expect.anything(), + ); + }); + + it("never renders partial native availability as buyer-selectable stems", async () => { + vi.mocked(invoke).mockResolvedValue([ + fullMixAuthority, + stemAuthorities[0], + stemAuthorities[1], + ]); + + render( + , + ); + + await waitFor(() => expect(vi.mocked(invoke)).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + expect(screen.queryByRole("radio", { name: "Vocals" })).not.toBeInTheDocument(); + }); +}); From 88ded97b67f6b63bdccacd7226c3b9518b668e9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:09:31 +0900 Subject: [PATCH 101/160] feat(player): mount native stem source selector --- .../features/workspace/RehearsalPlayer.tsx | 1284 ++--------------- .../workspace/RehearsalPlayerCore.tsx | 1199 +++++++++++++++ 2 files changed, 1323 insertions(+), 1160 deletions(-) create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index fe12f227d..2e9abcd26 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -4,1196 +4,160 @@ import { useMemo, useRef, useState, - type ChangeEvent, - type FocusEvent, - type KeyboardEvent as ReactKeyboardEvent, + type ComponentProps, type ReactElement, } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { - MAX_SECTION_TIME_SECONDS, - type RehearsalSong, -} from "@bandscope/shared-types"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { Button } from "@/components/ui/button"; + RehearsalPlayer as RehearsalPlayerCore, +} from "./RehearsalPlayerCore"; +import { discoverPlaybackSourceOptions, type PlaybackSourceInvoke } from "./playbackSourceDiscovery"; import { - createTranslator, - detectPreferredLocale, - type TranslationKey, -} from "../../i18n"; -import { - createRehearsalCountInClickEngine, - type RehearsalCountInClickEngine, -} from "./rehearsalCountInClick"; -import { - beatDurationMs, - createIdleTransportState, - fillRehearsalCopy, - formatRehearsalClock, - nextActionTemplateKey, - nextActionValues, - isRehearsalPlaybackRate, - rehearsalPlaybackRates, - reduceRehearsalTransport, - resolveLoopWindows, - type RehearsalLoopWindow, - type RehearsalTransportState, -} from "./rehearsalTransport"; - -interface RehearsalPlayerProps { - song: RehearsalSong; - onSongUpdate?: (song: RehearsalSong) => void; - onSelectedSectionIndexChange?: (sectionIndex: number | null) => void; - sectionSelectionRequest?: { - sectionIndex: number; - requestId: number; - } | null; - hasLocalAudio?: boolean; - audioSourcePath?: string | null; - activeRole?: string | null; - activeRoleName?: string | null; - startNonce?: number; -} - -const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://"; -const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/; -const PLAYBACK_SOURCE_SUFFIX = /^(?:\/stem\/(?:vocals|bass|drums|other))?$/; - -/** Convert an opaque current-project authority into BandScope's native media URL. */ -function resolveAudioSourceUrl( - sourcePath: string | null | undefined, -): string | null { - if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) { - return null; - } - const authorityToken = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); - const separatorIndex = authorityToken.indexOf("/"); - const projectId = - separatorIndex === -1 - ? authorityToken - : authorityToken.slice(0, separatorIndex); - const sourceSuffix = - separatorIndex === -1 ? "" : authorityToken.slice(separatorIndex); - if ( - !PLAYBACK_PROJECT_ID.test(projectId) || - !PLAYBACK_SOURCE_SUFFIX.test(sourceSuffix) - ) { - return null; - } - try { - return convertFileSrc(`${projectId}${sourceSuffix}`, "bandscope-playback"); - } catch { - return null; - } -} - -/** Return whether a source authority can be converted into a playable native URL. */ -export function isPlayableAudioSource( - sourcePath: string | null | undefined, -): boolean { - return resolveAudioSourceUrl(sourcePath) !== null; -} - -/** Return the displayed map-clock progress for the current loop. */ -function loopProgressPercent(state: RehearsalTransportState): number { - if (!state.loop) { - return 0; - } - const duration = state.loop.endSeconds - state.loop.startSeconds; - if (!(duration > 0)) { - return 0; - } - return Math.min( - 100, - Math.max( - 0, - ((state.playheadSeconds - state.loop.startSeconds) / duration) * 100, - ), - ); -} - -/** Return whether two loop windows describe the same transport timing authority. */ -function hasSameLoopTiming( - current: RehearsalLoopWindow, - next: RehearsalLoopWindow, -): boolean { - return ( - current.selectionKey === next.selectionKey && - current.sectionId === next.sectionId && - current.startSeconds === next.startSeconds && - current.endSeconds === next.endSeconds && - current.tempoBpm === next.tempoBpm && - current.countInBeats === next.countInBeats - ); + beginPlaybackSourceDiscovery, + completePlaybackSourceDiscovery, + createPlaybackSourceSession, + selectPlaybackSource, + type PlaybackSourceSession, +} from "./playbackSourceSession"; +import type { PlaybackSourceKind } from "./playbackSourceSelection"; + +export { isPlayableAudioSource } from "./RehearsalPlayerCore"; + +type RehearsalPlayerCoreProps = ComponentProps; + +export type RehearsalPlayerProps = RehearsalPlayerCoreProps & { + /** Test seam for the renderer-safe Tauri availability command. */ + playbackSourceInvoke?: PlaybackSourceInvoke; +}; + +const PLAYBACK_SOURCE_LABEL: Readonly> = { + full_mix: "Full mix", + vocals: "Vocals", + bass: "Bass", + drums: "Drums", + other: "Other instruments", +}; + +function commitPlaybackSourceSession( + sessionRef: { current: PlaybackSourceSession }, + setSession: (next: PlaybackSourceSession) => void, + next: PlaybackSourceSession, +): void { + sessionRef.current = next; + setSession(next); } -/** Return a stable selection key when analysis emits duplicate section IDs. */ -function loopSelectionKey(loop: RehearsalLoopWindow): string { - return loop.selectionKey; -} - -/** Return whether a selected loop is fully covered by admitted local media. */ -function loopFitsAdmittedMedia( - loop: RehearsalLoopWindow, - mediaDurationSeconds: number | null, -): boolean { - return ( - mediaDurationSeconds !== null && - Number.isFinite(mediaDurationSeconds) && - mediaDurationSeconds > 0 && - loop.startSeconds < mediaDurationSeconds && - loop.endSeconds <= mediaDurationSeconds - ); -} - -/** Render tonight's first section loop with a count-in and a named next action. */ +/** + * Bind renderer-safe source discovery to the mounted rehearsal player. + * + * This wrapper owns only option discovery/selection. The existing player remains + * the transport owner and receives exactly one opaque current source authority. + */ export function RehearsalPlayer({ - song, - onSongUpdate, - onSelectedSectionIndexChange, - sectionSelectionRequest = null, - hasLocalAudio = false, + playbackSourceInvoke, audioSourcePath = null, - activeRole = null, - activeRoleName = null, - startNonce = 0, + hasLocalAudio = false, + ...coreProps }: RehearsalPlayerProps): ReactElement { - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const playableLoops = useMemo( - () => resolveLoopWindows(song, activeRole), - [activeRole, song], - ); - const [selectedLoopKey, setSelectedLoopKey] = useState(null); - const lastHandledSectionSelectionRequestId = useRef(0); - const [boundaryError, setBoundaryError] = useState(false); - const selectedLoop = - playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? - playableLoops[0] ?? - null; - useEffect(() => { - onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null); - }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]); - useEffect(() => { - if (!sectionSelectionRequest) { - return; - } - const { requestId, sectionIndex } = sectionSelectionRequest; - if ( - !Number.isSafeInteger(requestId) || - requestId <= lastHandledSectionSelectionRequestId.current - ) { - return; - } - lastHandledSectionSelectionRequestId.current = requestId; - if ( - !Number.isSafeInteger(sectionIndex) || - sectionIndex < 0 || - sectionIndex >= song.sections.length - ) { - return; - } - const requestedLoop = playableLoops.find( - (loop) => loop.sourceIndex === sectionIndex, - ); - if (!requestedLoop) { - return; - } - setSelectedLoopKey(loopSelectionKey(requestedLoop)); - }, [playableLoops, sectionSelectionRequest, song.sections.length]); - const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; - const [boundaryDraft, setBoundaryDraft] = useState(() => ({ - end: selectedLoop ? String(selectedLoop.endSeconds) : "", - start: selectedLoop ? String(selectedLoop.startSeconds) : "", - })); - useEffect(() => { - setBoundaryError(false); - setBoundaryDraft({ - end: selectedLoop ? String(selectedLoop.endSeconds) : "", - start: selectedLoop ? String(selectedLoop.startSeconds) : "", - }); - }, [selectedBoundaryKey, selectedLoop?.endSeconds, selectedLoop?.startSeconds]); - const handleSectionKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { - return; - } - const focusedIndex = Number(event.currentTarget.dataset.loopIndex); - const selectedIndex = selectedLoop - ? playableLoops.indexOf(selectedLoop) - : -1; - const currentIndex = - Number.isSafeInteger(focusedIndex) && - focusedIndex >= 0 && - focusedIndex < playableLoops.length - ? focusedIndex - : selectedIndex; - const nextIndex = - currentIndex + (event.key === "ArrowRight" ? 1 : -1); - if ( - currentIndex < 0 || - nextIndex < 0 || - nextIndex >= playableLoops.length - ) { - return; - } - event.preventDefault(); - const nextLoop = playableLoops[nextIndex]; - setSelectedLoopKey(loopSelectionKey(nextLoop)); - document - .getElementById( - `rehearsal-loop-section-${loopSelectionKey(nextLoop)}-${nextIndex}`, - ) - ?.focus(); - }, - [playableLoops, selectedLoop], - ); - const [transport, setTransport] = useState(() => - reduceRehearsalTransport(createIdleTransportState(), { - type: "arm", - loop: playableLoops[0] ?? null, - }), - ); - const countInClickEngine = useMemo( - () => createRehearsalCountInClickEngine(), - [], + const invokePlaybackSource = useMemo( + () => + playbackSourceInvoke ?? + ((command, args) => invoke(command, args)), + [playbackSourceInvoke], ); - const lastHandledStartNonce = useRef(0); - const restartAudioOnLoopRef = useRef(false); - const lastCountInClickKeyRef = useRef(null); - const countInBeatRef = useRef<{ - durationMs: number; - startedAt: number; - remainingBeats: number; - progress: number; - } | null>(null); - const audioRef = useRef(null); - const playbackIntentRef = useRef<"active" | "inactive">("inactive"); - const playRequestSequenceRef = useRef(0); - const audioSourceUrl = useMemo( - () => resolveAudioSourceUrl(audioSourcePath), - [audioSourcePath], - ); - const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null; - const hasNativeAudioConversionError = Boolean( - hasLocalAudio && - audioSourcePath && - !audioSourcePath.startsWith("browser://") && - audioSourceUrl === null, - ); - const [playbackError, setPlaybackError] = useState(false); - const [mediaDurationSeconds, setMediaDurationSeconds] = useState( - null, - ); - - useEffect( - () => () => { - void countInClickEngine.dispose(); - }, - [countInClickEngine], - ); - - const handlePlaybackError = useCallback(() => { - playbackIntentRef.current = "inactive"; - setPlaybackError(true); - setTransport((current) => { - if (current.phase === "idle" || current.phase === "armed") { - return current; - } - return reduceRehearsalTransport(current, { type: "stop" }); - }); - }, []); - - const handlePlayRejection = useCallback( - (error: unknown, requestSequence: number) => { - if (requestSequence !== playRequestSequenceRef.current) { - return; - } - const expectedInterruption = - playbackIntentRef.current === "inactive" && - typeof error === "object" && - error !== null && - "name" in error && - error.name === "AbortError"; - if (!expectedInterruption) { - handlePlaybackError(); - } - }, - [handlePlaybackError], - ); - - const startAudio = useCallback( - (loop: RehearsalLoopWindow, resume: boolean) => { - const audio = audioRef.current; - if (!audio || !audioSourceUrl) { - handlePlaybackError(); - return; - } - try { - restartAudioOnLoopRef.current = !resume; - if (!resume) { - audio.currentTime = loop.startSeconds; - audio.volume = 0; - } else { - audio.volume = 1; - } - playbackIntentRef.current = "active"; - const requestSequence = ++playRequestSequenceRef.current; - const playPromise = audio.play(); - if (playPromise) { - void playPromise.catch((error: unknown) => - handlePlayRejection(error, requestSequence), - ); - } - } catch { - handlePlaybackError(); - } - }, - [audioSourceUrl, handlePlaybackError, handlePlayRejection], + const [sourceSession, setSourceSession] = useState(() => + createPlaybackSourceSession(hasLocalAudio ? audioSourcePath : null), ); + const sourceSessionRef = useRef(sourceSession); useEffect(() => { - const audio = audioRef.current; - if (!audio) { - return undefined; - } - playbackIntentRef.current = "inactive"; - setMediaDurationSeconds(null); - if (!audio.paused) { - audio.pause(); - } - audio.volume = 1; - if (audioSourceUrl) { - audio.src = audioSourceUrl; - audio.load(); - } else { - audio.removeAttribute("src"); - } - setPlaybackError(hasNativeAudioConversionError); - return () => { - playbackIntentRef.current = "inactive"; - if (!audio.paused) { - audio.pause(); - } - }; - }, [audioSourceUrl, hasNativeAudioConversionError]); - - /** Admit only a finite positive duration from the currently loaded local source. */ - const handleLoadedMetadata = useCallback(() => { - const duration = audioRef.current?.duration ?? Number.NaN; - setMediaDurationSeconds( - Number.isFinite(duration) && duration > 0 ? duration : null, + let cancelled = false; + const resetSession = createPlaybackSourceSession( + hasLocalAudio ? audioSourcePath : null, + ); + const started = beginPlaybackSourceDiscovery( + resetSession, + hasLocalAudio ? audioSourcePath : null, + ); + commitPlaybackSourceSession( + sourceSessionRef, + setSourceSession, + started.state, ); - }, []); - - useEffect(() => { - setTransport((current) => { - if ( - current.loop && - selectedLoop && - hasSameLoopTiming(current.loop, selectedLoop) - ) { - if ( - current.loop.sectionLabel === selectedLoop.sectionLabel && - current.loop.tempoAssumed === selectedLoop.tempoAssumed && - current.loop.sourceIndex === selectedLoop.sourceIndex - ) { - return current; - } - return { ...current, loop: selectedLoop }; - } - return reduceRehearsalTransport(current, { - type: "arm", - loop: selectedLoop, - }); - }); - }, [selectedLoop]); - - useEffect(() => { - const audio = audioRef.current; - if (!audio) { - return; - } - try { - audio.playbackRate = transport.playbackRate; - if ("preservesPitch" in audio) { - audio.preservesPitch = true; - } - } catch { - handlePlaybackError(); - } - }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]); - - useEffect(() => { - if (startNonce <= lastHandledStartNonce.current) { - return; - } - if (!hasPlayableAudio || !selectedLoop || mediaDurationSeconds === null) { - return; - } - if (!loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)) { - lastHandledStartNonce.current = startNonce; - return; - } - lastHandledStartNonce.current = startNonce; - setPlaybackError(false); - startAudio(selectedLoop, false); - setTransport((current) => { - const armed = reduceRehearsalTransport(current, { - type: "arm", - loop: selectedLoop, - }); - return reduceRehearsalTransport(armed, { type: "start" }); - }); - }, [ - startAudio, - startNonce, - hasPlayableAudio, - mediaDurationSeconds, - selectedLoop, - ]); - - useEffect(() => { - const transportLoopCovered = - transport.loop === null || - loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds); - if (hasPlayableAudio && transportLoopCovered) { - return; - } - playbackIntentRef.current = "inactive"; - setTransport((current) => { - if (current.phase === "idle" || current.phase === "armed") { - return current; - } - return reduceRehearsalTransport(current, { type: "stop" }); - }); - }, [hasPlayableAudio, mediaDurationSeconds, transport.loop]); - - useEffect(() => { - if (transport.phase !== "counting-in" || !transport.loop) { - countInBeatRef.current = null; - lastCountInClickKeyRef.current = null; - countInClickEngine.stop(); - return undefined; - } - const durationMs = - beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate; - const now = performance.now(); - const previous = countInBeatRef.current; - const sameBeat = - previous?.remainingBeats === transport.countInRemainingBeats; - const elapsedProgress = sameBeat - ? Math.max(0, now - previous.startedAt) / previous.durationMs - : 0; - const progress = sameBeat - ? Math.min(1, previous.progress + elapsedProgress) - : 0; - const currentClickKey = `${transport.loop.selectionKey}:${transport.countInRemainingBeats}`; - if ( - countInClickEngine.available && - lastCountInClickKeyRef.current !== currentClickKey - ) { - lastCountInClickKeyRef.current = currentClickKey; - void countInClickEngine - .click(transport.countInRemainingBeats === transport.loop.countInBeats) - .catch(() => { - // Count-in click failure must not gain authority over admitted song playback. - }); - } - countInBeatRef.current = { - durationMs, - startedAt: now, - remainingBeats: transport.countInRemainingBeats, - progress, - }; - let timer: number | undefined; - /** Schedule the next count-in beat without coupling it to React commits. */ - const scheduleBeat = (delayMs: number) => { - timer = window.setTimeout(() => { - const current = countInBeatRef.current; - if (!current || current.remainingBeats <= 0) { - return; - } - current.remainingBeats -= 1; - current.progress = 0; - setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); - if (current.remainingBeats > 0) { - const nextClickKey = `${transport.loop?.selectionKey ?? ""}:${current.remainingBeats}`; - if ( - countInClickEngine.available && - lastCountInClickKeyRef.current !== nextClickKey - ) { - lastCountInClickKeyRef.current = nextClickKey; - void countInClickEngine.click(false).catch(() => { - // The transport remains authoritative when Web Audio is unavailable. - }); - } - current.startedAt = performance.now(); - scheduleBeat(current.durationMs); - } - }, delayMs); - }; - scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); - return () => { - if (timer !== undefined) { - window.clearTimeout(timer); - } - }; - }, [ - countInClickEngine, - transport.loop, - transport.phase, - transport.playbackRate, - ]); - useEffect(() => { - if (!audioSourceUrl || !transport.loop) { - return undefined; - } - const audio = audioRef.current; - if (!audio) { - return undefined; - } - if (transport.phase === "looping") { - try { - if (restartAudioOnLoopRef.current) { - audio.currentTime = transport.loop.startSeconds; - restartAudioOnLoopRef.current = false; - } - audio.volume = 1; - playbackIntentRef.current = "active"; - const requestSequence = ++playRequestSequenceRef.current; - const playPromise = audio.play(); - if (playPromise) { - void playPromise.catch((error: unknown) => - handlePlayRejection(error, requestSequence), - ); - } - } catch { - handlePlaybackError(); - } - } else if ( - transport.phase === "armed" || - transport.phase === "paused" || - transport.phase === "idle" - ) { - playbackIntentRef.current = "inactive"; - if (!audio.paused) { - audio.pause(); - } - audio.volume = 1; + if (started.request === null) { + return () => { + cancelled = true; + }; } - return undefined; - }, [ - audioSourceUrl, - handlePlaybackError, - handlePlayRejection, - transport.phase, - transport.loop, - ]); - useEffect(() => { - if (!audioSourceUrl || transport.phase !== "looping" || !transport.loop) { - return undefined; - } - const audio = audioRef.current; - if (!audio) { - return undefined; - } - const loop = transport.loop; - const playbackRate = transport.playbackRate; - let boundaryTimer: number | undefined; - /** Cancel the pending media-clock boundary check. */ - const clearBoundaryTimer = () => { - if (boundaryTimer !== undefined) { - window.clearTimeout(boundaryTimer); - boundaryTimer = undefined; - } - }; - /** Restart media at the exact selected section boundary. */ - const restartLoop = () => { - try { - audio.currentTime = loop.startSeconds; - playbackIntentRef.current = "active"; - const requestSequence = ++playRequestSequenceRef.current; - const playPromise = audio.play(); - if (playPromise) { - void playPromise.catch((error: unknown) => - handlePlayRejection(error, requestSequence), - ); - } - } catch { - handlePlaybackError(); + const request = started.request; + void discoverPlaybackSourceOptions( + request.fullMixAuthority, + invokePlaybackSource, + ).then((discovered) => { + if (cancelled) { return; } - scheduleLoopBoundary(); - }; - /** Schedule a media-clock boundary check and reschedule if timers fire early. */ - const scheduleLoopBoundary = () => { - clearBoundaryTimer(); - const remainingSeconds = loop.endSeconds - audio.currentTime; - if (!Number.isFinite(remainingSeconds)) { - return; - } - if (remainingSeconds <= 0) { - restartLoop(); - return; - } - boundaryTimer = window.setTimeout(() => { - boundaryTimer = undefined; - if (audio.currentTime >= loop.endSeconds) { - restartLoop(); - } else { - scheduleLoopBoundary(); - } - }, - Math.min( - (remainingSeconds / playbackRate) * 1000, - 2_147_483_647, - ), + const completed = completePlaybackSourceDiscovery( + sourceSessionRef.current, + request, + discovered, ); - }; - /** Keep the map playhead aligned with the scoped audio element. */ - const syncPlayhead = () => { - if (audio.currentTime >= loop.endSeconds) { - restartLoop(); - } else { - scheduleLoopBoundary(); - } - setTransport((current) => - reduceRehearsalTransport(current, { - type: "sync", - playheadSeconds: audio.currentTime, - }), + commitPlaybackSourceSession( + sourceSessionRef, + setSourceSession, + completed, ); - }; - /** Stop the transport when the media element reports a real playback error. */ - const failPlayback = () => handlePlaybackError(); - audio.addEventListener("timeupdate", syncPlayhead); - audio.addEventListener("error", failPlayback); - audio.addEventListener("ended", restartLoop); - scheduleLoopBoundary(); + }); + return () => { - clearBoundaryTimer(); - audio.removeEventListener("timeupdate", syncPlayhead); - audio.removeEventListener("error", failPlayback); - audio.removeEventListener("ended", restartLoop); + cancelled = true; }; - }, [ - audioSourceUrl, - handlePlaybackError, - handlePlayRejection, - transport.phase, - transport.loop, - transport.playbackRate, - ]); - - const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); - const nextAction = - activeRoleName && playableLoops.length === 0 - ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { - roleName: activeRoleName, - }) - : fillRehearsalCopy( - t(actionKey as TranslationKey), - nextActionValues(transport), - ); - const sectionPickerLabel = activeRoleName - ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { - roleName: activeRoleName, - }) - : t("workspaceLoopSectionPickerLabel"); - const canStart = - transport.loop !== null && - hasPlayableAudio && - loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && - (transport.phase === "armed" || transport.phase === "paused"); - const canPause = - transport.phase === "counting-in" || transport.phase === "looping"; - const canStop = transport.phase !== "idle" && transport.loop !== null; - const startLabel = - transport.phase === "paused" - ? t("workspaceLoopResume") - : t("workspaceLoopStart"); - const handleBoundaryBlur = useCallback( - (boundary: "start" | "end", event: FocusEvent) => { - if (!selectedLoop || !onSongUpdate) { - return; - } - const rawValue = event.currentTarget.value.trim(); - const value = Number(rawValue); - const withinLoadedMedia = - !hasPlayableAudio || - (mediaDurationSeconds !== null && - (boundary === "start" - ? value < mediaDurationSeconds - : value <= mediaDurationSeconds)); - const valid = - rawValue !== "" && - Number.isSafeInteger(value) && - value >= 0 && - value <= MAX_SECTION_TIME_SECONDS && - withinLoadedMedia && - (boundary === "start" - ? value < selectedLoop.endSeconds - : value > selectedLoop.startSeconds); - if (!valid) { - const currentValue = - boundary === "start" - ? selectedLoop.startSeconds - : selectedLoop.endSeconds; - setBoundaryDraft((current) => ({ - ...current, - [boundary]: String(currentValue), - })); - setBoundaryError(true); - return; - } + }, [audioSourcePath, hasLocalAudio, invokePlaybackSource]); - setBoundaryError(false); - const currentValue = - boundary === "start" - ? selectedLoop.startSeconds - : selectedLoop.endSeconds; - if (value === currentValue) { - setBoundaryDraft((current) => ({ - ...current, - [boundary]: String(currentValue), - })); - return; - } + const choosePlaybackSource = useCallback((authority: string) => { + const selected = selectPlaybackSource(sourceSessionRef.current, authority); + commitPlaybackSourceSession(sourceSessionRef, setSourceSession, selected); + }, []); - const sectionIndex = selectedLoop.sourceIndex; - const section = song.sections[sectionIndex]; - if ( - !section || - section.id !== selectedLoop.sectionId || - section.timeRange.start !== selectedLoop.startSeconds || - section.timeRange.end !== selectedLoop.endSeconds - ) { - setBoundaryDraft({ - end: String(selectedLoop.endSeconds), - start: String(selectedLoop.startSeconds), - }); - setBoundaryError(true); - return; - } - const nextSong = { - ...song, - sections: song.sections.map((currentSection, index) => - index === sectionIndex - ? { - ...section, - timeRange: { - ...section.timeRange, - [boundary]: value, - }, - } - : currentSection, - ), - }; - const nextLoop = - boundary === "start" - ? { ...selectedLoop, startSeconds: value } - : { ...selectedLoop, endSeconds: value }; - setBoundaryDraft((current) => ({ - ...current, - [boundary]: String(value), - })); - setSelectedLoopKey(loopSelectionKey(nextLoop)); - onSongUpdate(nextSong); - }, - [ - hasPlayableAudio, - mediaDurationSeconds, - onSongUpdate, - selectedLoop, - song, - ], - ); - const canSeek = - transport.loop !== null && - hasPlayableAudio && - loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && - (transport.phase === "looping" || - (transport.phase === "paused" && transport.countInRemainingBeats === 0)); - const handleSeek = useCallback( - (event: ChangeEvent) => { - if (!canSeek || !transport.loop || !audioSourceUrl) { - return; - } - const nextTransport = reduceRehearsalTransport(transport, { - type: "seek", - playheadSeconds: Number(event.currentTarget.value), - }); - try { - const audio = audioRef.current; - if (!audio) { - return; - } - audio.currentTime = nextTransport.playheadSeconds; - setPlaybackError(false); - setTransport(nextTransport); - } catch { - handlePlaybackError(); - } - }, - [audioSourceUrl, canSeek, handlePlaybackError, transport], - ); - const startOrResume = useCallback(() => { - if (!canStart) { - return; - } - setPlaybackError(false); - if (transport.loop) { - startAudio( - transport.loop, - transport.phase === "paused" && transport.countInRemainingBeats === 0, - ); - } - setTransport((current) => - reduceRehearsalTransport(current, { type: "start" }), - ); - }, [canStart, startAudio, transport]); - const pauseTransport = useCallback(() => { - if (!canPause) { - return; - } - playbackIntentRef.current = "inactive"; - setTransport((current) => - reduceRehearsalTransport(current, { type: "pause" }), - ); - }, [canPause]); - const stopTransport = useCallback(() => { - if (!canStop) { - return; - } - playbackIntentRef.current = "inactive"; - setTransport((current) => - reduceRehearsalTransport(current, { type: "stop" }), - ); - }, [canStop]); - useEffect(() => { - /** Keep transport shortcuts out of editable controls. */ - const handleTransportShortcut = (event: KeyboardEvent) => { - const target = event.target; - const targetIsButtonOrLink = - target instanceof Element && target.closest("button, a") !== null; - const targetIsScrollableRegion = - target instanceof Element && - target.closest('[role="region"][tabindex="0"]') !== null; - const targetIsEditable = - target instanceof HTMLElement && - (target.isContentEditable || - target.closest("input, select, textarea") !== null); - if ( - event.defaultPrevented || - event.repeat || - targetIsEditable - ) { - return; - } - if ( - event.key === " " && - !targetIsButtonOrLink && - !targetIsScrollableRegion && - !event.altKey && - !event.ctrlKey && - !event.metaKey && - !event.shiftKey && - (canPause || canStart) - ) { - event.preventDefault(); - if (canPause) { - pauseTransport(); - } else { - startOrResume(); - } - } else if ( - event.key === "Escape" && - !event.altKey && - !event.ctrlKey && - !event.metaKey && - !event.shiftKey && - canStop - ) { - event.preventDefault(); - stopTransport(); - } - }; - window.addEventListener("keydown", handleTransportShortcut); - return () => window.removeEventListener("keydown", handleTransportShortcut); - }, [canPause, canStart, canStop, pauseTransport, startOrResume, stopTransport]); + const selectedAuthority = + sourceSession.selectedAuthority ?? + (hasLocalAudio ? audioSourcePath : null); + const hasStemChoices = sourceSession.options.length > 1; return ( -
-

- {t("workspaceLoopTitle")} -

-

- {nextAction} -

- {activeRoleName && playableLoops.length > 0 ? ( -

- {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), { - roleName: activeRoleName, - })} -

- ) : null} - {playableLoops.length > 0 ? ( -
- {playableLoops.map((loop, index) => { - const selectionKey = loopSelectionKey(loop); - const selected = - selectedLoop !== null && - selectionKey === loopSelectionKey(selectedLoop); - return ( - - ); - })} -
- ) : null} - {playableLoops.length > 1 ? ( -

- {t("workspaceLoopSectionKeyboardHint")} -

- ) : null} - {selectedLoop && onSongUpdate ? ( -
-
-

- {t("workspaceLoopBoundaryTitle")} -

- - {t("workspaceLoopBoundaryCorrectionBadge")} - -
-

- {boundaryError - ? t("workspaceLoopBoundaryError") - : t("workspaceLoopBoundaryHint")} -

-
- - -
-
- ) : null} -
- ))} - - -

- {t("workspaceLoopPlaybackRateHint")} -

-
- + ) : null} -
+ ); } diff --git a/apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx b/apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx new file mode 100644 index 000000000..fe12f227d --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx @@ -0,0 +1,1199 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type KeyboardEvent as ReactKeyboardEvent, + type ReactElement, +} from "react"; +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalSong, +} from "@bandscope/shared-types"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + type TranslationKey, +} from "../../i18n"; +import { + createRehearsalCountInClickEngine, + type RehearsalCountInClickEngine, +} from "./rehearsalCountInClick"; +import { + beatDurationMs, + createIdleTransportState, + fillRehearsalCopy, + formatRehearsalClock, + nextActionTemplateKey, + nextActionValues, + isRehearsalPlaybackRate, + rehearsalPlaybackRates, + reduceRehearsalTransport, + resolveLoopWindows, + type RehearsalLoopWindow, + type RehearsalTransportState, +} from "./rehearsalTransport"; + +interface RehearsalPlayerProps { + song: RehearsalSong; + onSongUpdate?: (song: RehearsalSong) => void; + onSelectedSectionIndexChange?: (sectionIndex: number | null) => void; + sectionSelectionRequest?: { + sectionIndex: number; + requestId: number; + } | null; + hasLocalAudio?: boolean; + audioSourcePath?: string | null; + activeRole?: string | null; + activeRoleName?: string | null; + startNonce?: number; +} + +const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://"; +const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/; +const PLAYBACK_SOURCE_SUFFIX = /^(?:\/stem\/(?:vocals|bass|drums|other))?$/; + +/** Convert an opaque current-project authority into BandScope's native media URL. */ +function resolveAudioSourceUrl( + sourcePath: string | null | undefined, +): string | null { + if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) { + return null; + } + const authorityToken = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); + const separatorIndex = authorityToken.indexOf("/"); + const projectId = + separatorIndex === -1 + ? authorityToken + : authorityToken.slice(0, separatorIndex); + const sourceSuffix = + separatorIndex === -1 ? "" : authorityToken.slice(separatorIndex); + if ( + !PLAYBACK_PROJECT_ID.test(projectId) || + !PLAYBACK_SOURCE_SUFFIX.test(sourceSuffix) + ) { + return null; + } + try { + return convertFileSrc(`${projectId}${sourceSuffix}`, "bandscope-playback"); + } catch { + return null; + } +} + +/** Return whether a source authority can be converted into a playable native URL. */ +export function isPlayableAudioSource( + sourcePath: string | null | undefined, +): boolean { + return resolveAudioSourceUrl(sourcePath) !== null; +} + +/** Return the displayed map-clock progress for the current loop. */ +function loopProgressPercent(state: RehearsalTransportState): number { + if (!state.loop) { + return 0; + } + const duration = state.loop.endSeconds - state.loop.startSeconds; + if (!(duration > 0)) { + return 0; + } + return Math.min( + 100, + Math.max( + 0, + ((state.playheadSeconds - state.loop.startSeconds) / duration) * 100, + ), + ); +} + +/** Return whether two loop windows describe the same transport timing authority. */ +function hasSameLoopTiming( + current: RehearsalLoopWindow, + next: RehearsalLoopWindow, +): boolean { + return ( + current.selectionKey === next.selectionKey && + current.sectionId === next.sectionId && + current.startSeconds === next.startSeconds && + current.endSeconds === next.endSeconds && + current.tempoBpm === next.tempoBpm && + current.countInBeats === next.countInBeats + ); +} + +/** Return a stable selection key when analysis emits duplicate section IDs. */ +function loopSelectionKey(loop: RehearsalLoopWindow): string { + return loop.selectionKey; +} + +/** Return whether a selected loop is fully covered by admitted local media. */ +function loopFitsAdmittedMedia( + loop: RehearsalLoopWindow, + mediaDurationSeconds: number | null, +): boolean { + return ( + mediaDurationSeconds !== null && + Number.isFinite(mediaDurationSeconds) && + mediaDurationSeconds > 0 && + loop.startSeconds < mediaDurationSeconds && + loop.endSeconds <= mediaDurationSeconds + ); +} + +/** Render tonight's first section loop with a count-in and a named next action. */ +export function RehearsalPlayer({ + song, + onSongUpdate, + onSelectedSectionIndexChange, + sectionSelectionRequest = null, + hasLocalAudio = false, + audioSourcePath = null, + activeRole = null, + activeRoleName = null, + startNonce = 0, +}: RehearsalPlayerProps): ReactElement { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const playableLoops = useMemo( + () => resolveLoopWindows(song, activeRole), + [activeRole, song], + ); + const [selectedLoopKey, setSelectedLoopKey] = useState(null); + const lastHandledSectionSelectionRequestId = useRef(0); + const [boundaryError, setBoundaryError] = useState(false); + const selectedLoop = + playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? + playableLoops[0] ?? + null; + useEffect(() => { + onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null); + }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]); + useEffect(() => { + if (!sectionSelectionRequest) { + return; + } + const { requestId, sectionIndex } = sectionSelectionRequest; + if ( + !Number.isSafeInteger(requestId) || + requestId <= lastHandledSectionSelectionRequestId.current + ) { + return; + } + lastHandledSectionSelectionRequestId.current = requestId; + if ( + !Number.isSafeInteger(sectionIndex) || + sectionIndex < 0 || + sectionIndex >= song.sections.length + ) { + return; + } + const requestedLoop = playableLoops.find( + (loop) => loop.sourceIndex === sectionIndex, + ); + if (!requestedLoop) { + return; + } + setSelectedLoopKey(loopSelectionKey(requestedLoop)); + }, [playableLoops, sectionSelectionRequest, song.sections.length]); + const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; + const [boundaryDraft, setBoundaryDraft] = useState(() => ({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + })); + useEffect(() => { + setBoundaryError(false); + setBoundaryDraft({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + }); + }, [selectedBoundaryKey, selectedLoop?.endSeconds, selectedLoop?.startSeconds]); + const handleSectionKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + const focusedIndex = Number(event.currentTarget.dataset.loopIndex); + const selectedIndex = selectedLoop + ? playableLoops.indexOf(selectedLoop) + : -1; + const currentIndex = + Number.isSafeInteger(focusedIndex) && + focusedIndex >= 0 && + focusedIndex < playableLoops.length + ? focusedIndex + : selectedIndex; + const nextIndex = + currentIndex + (event.key === "ArrowRight" ? 1 : -1); + if ( + currentIndex < 0 || + nextIndex < 0 || + nextIndex >= playableLoops.length + ) { + return; + } + event.preventDefault(); + const nextLoop = playableLoops[nextIndex]; + setSelectedLoopKey(loopSelectionKey(nextLoop)); + document + .getElementById( + `rehearsal-loop-section-${loopSelectionKey(nextLoop)}-${nextIndex}`, + ) + ?.focus(); + }, + [playableLoops, selectedLoop], + ); + const [transport, setTransport] = useState(() => + reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop: playableLoops[0] ?? null, + }), + ); + const countInClickEngine = useMemo( + () => createRehearsalCountInClickEngine(), + [], + ); + const lastHandledStartNonce = useRef(0); + const restartAudioOnLoopRef = useRef(false); + const lastCountInClickKeyRef = useRef(null); + const countInBeatRef = useRef<{ + durationMs: number; + startedAt: number; + remainingBeats: number; + progress: number; + } | null>(null); + const audioRef = useRef(null); + const playbackIntentRef = useRef<"active" | "inactive">("inactive"); + const playRequestSequenceRef = useRef(0); + const audioSourceUrl = useMemo( + () => resolveAudioSourceUrl(audioSourcePath), + [audioSourcePath], + ); + const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null; + const hasNativeAudioConversionError = Boolean( + hasLocalAudio && + audioSourcePath && + !audioSourcePath.startsWith("browser://") && + audioSourceUrl === null, + ); + const [playbackError, setPlaybackError] = useState(false); + const [mediaDurationSeconds, setMediaDurationSeconds] = useState( + null, + ); + + useEffect( + () => () => { + void countInClickEngine.dispose(); + }, + [countInClickEngine], + ); + + const handlePlaybackError = useCallback(() => { + playbackIntentRef.current = "inactive"; + setPlaybackError(true); + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, []); + + const handlePlayRejection = useCallback( + (error: unknown, requestSequence: number) => { + if (requestSequence !== playRequestSequenceRef.current) { + return; + } + const expectedInterruption = + playbackIntentRef.current === "inactive" && + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError"; + if (!expectedInterruption) { + handlePlaybackError(); + } + }, + [handlePlaybackError], + ); + + const startAudio = useCallback( + (loop: RehearsalLoopWindow, resume: boolean) => { + const audio = audioRef.current; + if (!audio || !audioSourceUrl) { + handlePlaybackError(); + return; + } + try { + restartAudioOnLoopRef.current = !resume; + if (!resume) { + audio.currentTime = loop.startSeconds; + audio.volume = 0; + } else { + audio.volume = 1; + } + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, handlePlaybackError, handlePlayRejection], + ); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return undefined; + } + playbackIntentRef.current = "inactive"; + setMediaDurationSeconds(null); + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + if (audioSourceUrl) { + audio.src = audioSourceUrl; + audio.load(); + } else { + audio.removeAttribute("src"); + } + setPlaybackError(hasNativeAudioConversionError); + return () => { + playbackIntentRef.current = "inactive"; + if (!audio.paused) { + audio.pause(); + } + }; + }, [audioSourceUrl, hasNativeAudioConversionError]); + + /** Admit only a finite positive duration from the currently loaded local source. */ + const handleLoadedMetadata = useCallback(() => { + const duration = audioRef.current?.duration ?? Number.NaN; + setMediaDurationSeconds( + Number.isFinite(duration) && duration > 0 ? duration : null, + ); + }, []); + + useEffect(() => { + setTransport((current) => { + if ( + current.loop && + selectedLoop && + hasSameLoopTiming(current.loop, selectedLoop) + ) { + if ( + current.loop.sectionLabel === selectedLoop.sectionLabel && + current.loop.tempoAssumed === selectedLoop.tempoAssumed && + current.loop.sourceIndex === selectedLoop.sourceIndex + ) { + return current; + } + return { ...current, loop: selectedLoop }; + } + return reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + }); + }, [selectedLoop]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return; + } + try { + audio.playbackRate = transport.playbackRate; + if ("preservesPitch" in audio) { + audio.preservesPitch = true; + } + } catch { + handlePlaybackError(); + } + }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]); + + useEffect(() => { + if (startNonce <= lastHandledStartNonce.current) { + return; + } + if (!hasPlayableAudio || !selectedLoop || mediaDurationSeconds === null) { + return; + } + if (!loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)) { + lastHandledStartNonce.current = startNonce; + return; + } + lastHandledStartNonce.current = startNonce; + setPlaybackError(false); + startAudio(selectedLoop, false); + setTransport((current) => { + const armed = reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + return reduceRehearsalTransport(armed, { type: "start" }); + }); + }, [ + startAudio, + startNonce, + hasPlayableAudio, + mediaDurationSeconds, + selectedLoop, + ]); + + useEffect(() => { + const transportLoopCovered = + transport.loop === null || + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds); + if (hasPlayableAudio && transportLoopCovered) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, [hasPlayableAudio, mediaDurationSeconds, transport.loop]); + + useEffect(() => { + if (transport.phase !== "counting-in" || !transport.loop) { + countInBeatRef.current = null; + lastCountInClickKeyRef.current = null; + countInClickEngine.stop(); + return undefined; + } + const durationMs = + beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate; + const now = performance.now(); + const previous = countInBeatRef.current; + const sameBeat = + previous?.remainingBeats === transport.countInRemainingBeats; + const elapsedProgress = sameBeat + ? Math.max(0, now - previous.startedAt) / previous.durationMs + : 0; + const progress = sameBeat + ? Math.min(1, previous.progress + elapsedProgress) + : 0; + const currentClickKey = `${transport.loop.selectionKey}:${transport.countInRemainingBeats}`; + if ( + countInClickEngine.available && + lastCountInClickKeyRef.current !== currentClickKey + ) { + lastCountInClickKeyRef.current = currentClickKey; + void countInClickEngine + .click(transport.countInRemainingBeats === transport.loop.countInBeats) + .catch(() => { + // Count-in click failure must not gain authority over admitted song playback. + }); + } + countInBeatRef.current = { + durationMs, + startedAt: now, + remainingBeats: transport.countInRemainingBeats, + progress, + }; + let timer: number | undefined; + /** Schedule the next count-in beat without coupling it to React commits. */ + const scheduleBeat = (delayMs: number) => { + timer = window.setTimeout(() => { + const current = countInBeatRef.current; + if (!current || current.remainingBeats <= 0) { + return; + } + current.remainingBeats -= 1; + current.progress = 0; + setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); + if (current.remainingBeats > 0) { + const nextClickKey = `${transport.loop?.selectionKey ?? ""}:${current.remainingBeats}`; + if ( + countInClickEngine.available && + lastCountInClickKeyRef.current !== nextClickKey + ) { + lastCountInClickKeyRef.current = nextClickKey; + void countInClickEngine.click(false).catch(() => { + // The transport remains authoritative when Web Audio is unavailable. + }); + } + current.startedAt = performance.now(); + scheduleBeat(current.durationMs); + } + }, delayMs); + }; + scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [ + countInClickEngine, + transport.loop, + transport.phase, + transport.playbackRate, + ]); + + useEffect(() => { + if (!audioSourceUrl || !transport.loop) { + return undefined; + } + const audio = audioRef.current; + if (!audio) { + return undefined; + } + if (transport.phase === "looping") { + try { + if (restartAudioOnLoopRef.current) { + audio.currentTime = transport.loop.startSeconds; + restartAudioOnLoopRef.current = false; + } + audio.volume = 1; + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + } + } else if ( + transport.phase === "armed" || + transport.phase === "paused" || + transport.phase === "idle" + ) { + playbackIntentRef.current = "inactive"; + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + } + return undefined; + }, [ + audioSourceUrl, + handlePlaybackError, + handlePlayRejection, + transport.phase, + transport.loop, + ]); + + useEffect(() => { + if (!audioSourceUrl || transport.phase !== "looping" || !transport.loop) { + return undefined; + } + const audio = audioRef.current; + if (!audio) { + return undefined; + } + const loop = transport.loop; + const playbackRate = transport.playbackRate; + let boundaryTimer: number | undefined; + /** Cancel the pending media-clock boundary check. */ + const clearBoundaryTimer = () => { + if (boundaryTimer !== undefined) { + window.clearTimeout(boundaryTimer); + boundaryTimer = undefined; + } + }; + /** Restart media at the exact selected section boundary. */ + const restartLoop = () => { + try { + audio.currentTime = loop.startSeconds; + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + return; + } + scheduleLoopBoundary(); + }; + /** Schedule a media-clock boundary check and reschedule if timers fire early. */ + const scheduleLoopBoundary = () => { + clearBoundaryTimer(); + const remainingSeconds = loop.endSeconds - audio.currentTime; + if (!Number.isFinite(remainingSeconds)) { + return; + } + if (remainingSeconds <= 0) { + restartLoop(); + return; + } + boundaryTimer = window.setTimeout(() => { + boundaryTimer = undefined; + if (audio.currentTime >= loop.endSeconds) { + restartLoop(); + } else { + scheduleLoopBoundary(); + } + }, + Math.min( + (remainingSeconds / playbackRate) * 1000, + 2_147_483_647, + ), + ); + }; + /** Keep the map playhead aligned with the scoped audio element. */ + const syncPlayhead = () => { + if (audio.currentTime >= loop.endSeconds) { + restartLoop(); + } else { + scheduleLoopBoundary(); + } + setTransport((current) => + reduceRehearsalTransport(current, { + type: "sync", + playheadSeconds: audio.currentTime, + }), + ); + }; + /** Stop the transport when the media element reports a real playback error. */ + const failPlayback = () => handlePlaybackError(); + audio.addEventListener("timeupdate", syncPlayhead); + audio.addEventListener("error", failPlayback); + audio.addEventListener("ended", restartLoop); + scheduleLoopBoundary(); + return () => { + clearBoundaryTimer(); + audio.removeEventListener("timeupdate", syncPlayhead); + audio.removeEventListener("error", failPlayback); + audio.removeEventListener("ended", restartLoop); + }; + }, [ + audioSourceUrl, + handlePlaybackError, + handlePlayRejection, + transport.phase, + transport.loop, + transport.playbackRate, + ]); + + const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); + const nextAction = + activeRoleName && playableLoops.length === 0 + ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { + roleName: activeRoleName, + }) + : fillRehearsalCopy( + t(actionKey as TranslationKey), + nextActionValues(transport), + ); + const sectionPickerLabel = activeRoleName + ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { + roleName: activeRoleName, + }) + : t("workspaceLoopSectionPickerLabel"); + const canStart = + transport.loop !== null && + hasPlayableAudio && + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && + (transport.phase === "armed" || transport.phase === "paused"); + const canPause = + transport.phase === "counting-in" || transport.phase === "looping"; + const canStop = transport.phase !== "idle" && transport.loop !== null; + const startLabel = + transport.phase === "paused" + ? t("workspaceLoopResume") + : t("workspaceLoopStart"); + const handleBoundaryBlur = useCallback( + (boundary: "start" | "end", event: FocusEvent) => { + if (!selectedLoop || !onSongUpdate) { + return; + } + const rawValue = event.currentTarget.value.trim(); + const value = Number(rawValue); + const withinLoadedMedia = + !hasPlayableAudio || + (mediaDurationSeconds !== null && + (boundary === "start" + ? value < mediaDurationSeconds + : value <= mediaDurationSeconds)); + const valid = + rawValue !== "" && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_SECTION_TIME_SECONDS && + withinLoadedMedia && + (boundary === "start" + ? value < selectedLoop.endSeconds + : value > selectedLoop.startSeconds); + if (!valid) { + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + setBoundaryError(true); + return; + } + + setBoundaryError(false); + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + if (value === currentValue) { + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + return; + } + + const sectionIndex = selectedLoop.sourceIndex; + const section = song.sections[sectionIndex]; + if ( + !section || + section.id !== selectedLoop.sectionId || + section.timeRange.start !== selectedLoop.startSeconds || + section.timeRange.end !== selectedLoop.endSeconds + ) { + setBoundaryDraft({ + end: String(selectedLoop.endSeconds), + start: String(selectedLoop.startSeconds), + }); + setBoundaryError(true); + return; + } + const nextSong = { + ...song, + sections: song.sections.map((currentSection, index) => + index === sectionIndex + ? { + ...section, + timeRange: { + ...section.timeRange, + [boundary]: value, + }, + } + : currentSection, + ), + }; + const nextLoop = + boundary === "start" + ? { ...selectedLoop, startSeconds: value } + : { ...selectedLoop, endSeconds: value }; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(value), + })); + setSelectedLoopKey(loopSelectionKey(nextLoop)); + onSongUpdate(nextSong); + }, + [ + hasPlayableAudio, + mediaDurationSeconds, + onSongUpdate, + selectedLoop, + song, + ], + ); + const canSeek = + transport.loop !== null && + hasPlayableAudio && + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && + (transport.phase === "looping" || + (transport.phase === "paused" && transport.countInRemainingBeats === 0)); + const handleSeek = useCallback( + (event: ChangeEvent) => { + if (!canSeek || !transport.loop || !audioSourceUrl) { + return; + } + const nextTransport = reduceRehearsalTransport(transport, { + type: "seek", + playheadSeconds: Number(event.currentTarget.value), + }); + try { + const audio = audioRef.current; + if (!audio) { + return; + } + audio.currentTime = nextTransport.playheadSeconds; + setPlaybackError(false); + setTransport(nextTransport); + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, canSeek, handlePlaybackError, transport], + ); + const startOrResume = useCallback(() => { + if (!canStart) { + return; + } + setPlaybackError(false); + if (transport.loop) { + startAudio( + transport.loop, + transport.phase === "paused" && transport.countInRemainingBeats === 0, + ); + } + setTransport((current) => + reduceRehearsalTransport(current, { type: "start" }), + ); + }, [canStart, startAudio, transport]); + const pauseTransport = useCallback(() => { + if (!canPause) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => + reduceRehearsalTransport(current, { type: "pause" }), + ); + }, [canPause]); + const stopTransport = useCallback(() => { + if (!canStop) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => + reduceRehearsalTransport(current, { type: "stop" }), + ); + }, [canStop]); + useEffect(() => { + /** Keep transport shortcuts out of editable controls. */ + const handleTransportShortcut = (event: KeyboardEvent) => { + const target = event.target; + const targetIsButtonOrLink = + target instanceof Element && target.closest("button, a") !== null; + const targetIsScrollableRegion = + target instanceof Element && + target.closest('[role="region"][tabindex="0"]') !== null; + const targetIsEditable = + target instanceof HTMLElement && + (target.isContentEditable || + target.closest("input, select, textarea") !== null); + if ( + event.defaultPrevented || + event.repeat || + targetIsEditable + ) { + return; + } + if ( + event.key === " " && + !targetIsButtonOrLink && + !targetIsScrollableRegion && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + (canPause || canStart) + ) { + event.preventDefault(); + if (canPause) { + pauseTransport(); + } else { + startOrResume(); + } + } else if ( + event.key === "Escape" && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + canStop + ) { + event.preventDefault(); + stopTransport(); + } + }; + window.addEventListener("keydown", handleTransportShortcut); + return () => window.removeEventListener("keydown", handleTransportShortcut); + }, [canPause, canStart, canStop, pauseTransport, startOrResume, stopTransport]); + + return ( +
+

+ {t("workspaceLoopTitle")} +

+

+ {nextAction} +

+ {activeRoleName && playableLoops.length > 0 ? ( +

+ {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), { + roleName: activeRoleName, + })} +

+ ) : null} + {playableLoops.length > 0 ? ( +
+ {playableLoops.map((loop, index) => { + const selectionKey = loopSelectionKey(loop); + const selected = + selectedLoop !== null && + selectionKey === loopSelectionKey(selectedLoop); + return ( + + ); + })} +
+ ) : null} + {playableLoops.length > 1 ? ( +

+ {t("workspaceLoopSectionKeyboardHint")} +

+ ) : null} + {selectedLoop && onSongUpdate ? ( +
+
+

+ {t("workspaceLoopBoundaryTitle")} +

+ + {t("workspaceLoopBoundaryCorrectionBadge")} + +
+

+ {boundaryError + ? t("workspaceLoopBoundaryError") + : t("workspaceLoopBoundaryHint")} +

+
+ + +
+
+ ) : null} +
+ +

+ {t("workspaceLoopPlaybackRateHint")} +

+
+
+ ); +} From ad96e16ac54246d1dd70922ecd64f262412dc713 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:10:58 +0900 Subject: [PATCH 102/160] test(player): reject stale selector discovery after project rotation --- .../RehearsalPlayer.sourceSelection.test.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx index d9a27096b..9b6a7aeac 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx @@ -19,6 +19,17 @@ const stemAuthorities = [ `${fullMixAuthority}/stem/other`, ] as const; +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("RehearsalPlayer mounted playback-source selection", () => { beforeEach(() => { vi.mocked(convertFileSrc).mockClear(); @@ -84,4 +95,57 @@ describe("RehearsalPlayer mounted playback-source selection", () => { expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); expect(screen.queryByRole("radio", { name: "Vocals" })).not.toBeInTheDocument(); }); + + it("does not let a late discovery result from the previous project repopulate the selector", async () => { + const first = deferred(); + const second = deferred(); + const nextFullMixAuthority = "bandscope-project://project-200-2"; + const nextStems = [ + `${nextFullMixAuthority}/stem/vocals`, + `${nextFullMixAuthority}/stem/bass`, + `${nextFullMixAuthority}/stem/drums`, + `${nextFullMixAuthority}/stem/other`, + ] as const; + vi.mocked(invoke) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const song = createDemoRehearsalSong(); + const { rerender } = render( + , + ); + + rerender( + , + ); + second.resolve([ + nextStems[2], + nextFullMixAuthority, + nextStems[0], + nextStems[3], + nextStems[1], + ]); + + const nextVocals = await screen.findByRole("radio", { name: "Vocals" }); + expect(nextVocals).toHaveValue(nextStems[0]); + + first.resolve([ + fullMixAuthority, + stemAuthorities[0], + stemAuthorities[1], + stemAuthorities[2], + stemAuthorities[3], + ]); + + await waitFor(() => expect(screen.getByRole("radio", { name: "Vocals" })).toHaveValue(nextStems[0])); + expect(screen.queryByDisplayValue(stemAuthorities[0])).not.toBeInTheDocument(); + }); }); From 71c03bcc12de4806804d93db45e1d8f0ea764668 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:11:31 +0900 Subject: [PATCH 103/160] fix(player): hide stale source snapshot during project rotation --- .../src/features/workspace/RehearsalPlayer.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 2e9abcd26..f2064cfd6 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -121,10 +121,17 @@ export function RehearsalPlayer({ commitPlaybackSourceSession(sourceSessionRef, setSourceSession, selected); }, []); - const selectedAuthority = - sourceSession.selectedAuthority ?? - (hasLocalAudio ? audioSourcePath : null); - const hasStemChoices = sourceSession.options.length > 1; + const sessionMatchesMountedProject = + hasLocalAudio && sourceSession.fullMixAuthority === audioSourcePath; + const visibleOptions = sessionMatchesMountedProject + ? sourceSession.options + : []; + const selectedAuthority = sessionMatchesMountedProject + ? sourceSession.selectedAuthority ?? audioSourcePath + : hasLocalAudio + ? audioSourcePath + : null; + const hasStemChoices = visibleOptions.length > 1; return ( <> @@ -134,7 +141,7 @@ export function RehearsalPlayer({ Playback source
- {sourceSession.options.map((option) => ( + {visibleOptions.map((option) => (